Compare commits

...

2 Commits

Author SHA1 Message Date
zhx
ecf03ae362 游戏 2026-06-11 01:02:20 +08:00
zhx
ebd14c7f85 游戏 2026-06-11 01:02:16 +08:00
160 changed files with 25903 additions and 2308 deletions

View File

@ -92,8 +92,9 @@
## Coding Rules
- 默认使用 Go 标准库和仓库已有小包;不要为了轻量逻辑引入重框架。
- 手写代码保持高密度有效注释,注释解释工程语义和失败分支,不复述语法。
- 手写代码保持高密度有效注释,所有业务逻辑、状态流转、权限判断、支付链路、异步流程都要解释工程语义和失败分支,不复述语法。
- 新增配置必须同时考虑本地 `config.yaml`、Docker `config.docker.yaml`、以及线上配置示例。
- 所有逻辑功能配置默认开启;密钥、地址、三方账号等必须由运行环境提供的敏感配置只能使用占位符或环境配置,不能写入真实值,也不能伪造可用凭证。
- 新增服务依赖必须在 `docker-compose.yml` 中体现本地可运行形态。
- 数据库表结构变更必须同步更新对应 owner service 的 `deploy/mysql/initdb`
- 不要把线上密钥、云数据库密码、Redis 密码写进真实配置。只能写占位示例。

View File

@ -4675,7 +4675,7 @@ func (x *ListRegistrationRewardClaimsResponse) GetTodayClaimedCount() int64 {
return 0
}
// FirstRechargeRewardTier 是首冲奖励的一个金币充值档位max_coin_amount=0 表示无上限
// FirstRechargeRewardTier 是首冲奖励的一个 Google 商品档位;旧金币区间字段仅保留兼容
type FirstRechargeRewardTier struct {
state protoimpl.MessageState `protogen:"open.v1"`
TierId int64 `protobuf:"varint,1,opt,name=tier_id,json=tierId,proto3" json:"tier_id,omitempty"`
@ -4688,6 +4688,9 @@ type FirstRechargeRewardTier struct {
SortOrder int32 `protobuf:"varint,8,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
CreatedAtMs int64 `protobuf:"varint,9,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,10,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
UsdMinorAmount int64 `protobuf:"varint,11,opt,name=usd_minor_amount,json=usdMinorAmount,proto3" json:"usd_minor_amount,omitempty"`
GoogleProductId string `protobuf:"bytes,12,opt,name=google_product_id,json=googleProductId,proto3" json:"google_product_id,omitempty"`
DiscountPercent int32 `protobuf:"varint,13,opt,name=discount_percent,json=discountPercent,proto3" json:"discount_percent,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4792,6 +4795,27 @@ func (x *FirstRechargeRewardTier) GetUpdatedAtMs() int64 {
return 0
}
func (x *FirstRechargeRewardTier) GetUsdMinorAmount() int64 {
if x != nil {
return x.UsdMinorAmount
}
return 0
}
func (x *FirstRechargeRewardTier) GetGoogleProductId() string {
if x != nil {
return x.GoogleProductId
}
return ""
}
func (x *FirstRechargeRewardTier) GetDiscountPercent() int32 {
if x != nil {
return x.DiscountPercent
}
return 0
}
// FirstRechargeRewardConfig 是当前 App 的首冲奖励配置;档位内容在 activity-service 内保持版本一致。
type FirstRechargeRewardConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -4877,7 +4901,7 @@ func (x *FirstRechargeRewardConfig) GetUpdatedAtMs() int64 {
return 0
}
// FirstRechargeRewardClaim 是某个用户首笔充值触发后的发奖事实user_id 唯一保证一生只处理一次。
// FirstRechargeRewardClaim 是某个用户购买某个首充档位后的发奖事实;同一用户同一档位只处理一次。
type FirstRechargeRewardClaim struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClaimId string `protobuf:"bytes,1,opt,name=claim_id,json=claimId,proto3" json:"claim_id,omitempty"`
@ -4899,6 +4923,9 @@ type FirstRechargeRewardClaim struct {
GrantedAtMs int64 `protobuf:"varint,17,opt,name=granted_at_ms,json=grantedAtMs,proto3" json:"granted_at_ms,omitempty"`
CreatedAtMs int64 `protobuf:"varint,18,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,19,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
TierUsdMinorAmount int64 `protobuf:"varint,20,opt,name=tier_usd_minor_amount,json=tierUsdMinorAmount,proto3" json:"tier_usd_minor_amount,omitempty"`
GoogleProductId string `protobuf:"bytes,21,opt,name=google_product_id,json=googleProductId,proto3" json:"google_product_id,omitempty"`
RechargeUsdMinor int64 `protobuf:"varint,22,opt,name=recharge_usd_minor,json=rechargeUsdMinor,proto3" json:"recharge_usd_minor,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -5066,14 +5093,36 @@ func (x *FirstRechargeRewardClaim) GetUpdatedAtMs() int64 {
return 0
}
func (x *FirstRechargeRewardClaim) GetTierUsdMinorAmount() int64 {
if x != nil {
return x.TierUsdMinorAmount
}
return 0
}
func (x *FirstRechargeRewardClaim) GetGoogleProductId() string {
if x != nil {
return x.GoogleProductId
}
return ""
}
func (x *FirstRechargeRewardClaim) GetRechargeUsdMinor() int64 {
if x != nil {
return x.RechargeUsdMinor
}
return 0
}
// FirstRechargeRewardStatus 是 App 首冲奖励面板可直接展示的当前状态。
type FirstRechargeRewardStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*FirstRechargeRewardTier `protobuf:"bytes,2,rep,name=tiers,proto3" json:"tiers,omitempty"`
Rewarded bool `protobuf:"varint,3,opt,name=rewarded,proto3" json:"rewarded,omitempty"`
Claim *FirstRechargeRewardClaim `protobuf:"bytes,4,opt,name=claim,proto3" json:"claim,omitempty"`
ServerTimeMs int64 `protobuf:"varint,5,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*FirstRechargeRewardTier `protobuf:"bytes,2,rep,name=tiers,proto3" json:"tiers,omitempty"`
Rewarded bool `protobuf:"varint,3,opt,name=rewarded,proto3" json:"rewarded,omitempty"`
Claim *FirstRechargeRewardClaim `protobuf:"bytes,4,opt,name=claim,proto3" json:"claim,omitempty"`
ServerTimeMs int64 `protobuf:"varint,5,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
Claims []*FirstRechargeRewardClaim `protobuf:"bytes,6,rep,name=claims,proto3" json:"claims,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -5143,6 +5192,13 @@ func (x *FirstRechargeRewardStatus) GetServerTimeMs() int64 {
return 0
}
func (x *FirstRechargeRewardStatus) GetClaims() []*FirstRechargeRewardClaim {
if x != nil {
return x.Claims
}
return nil
}
type GetFirstRechargeRewardStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -5239,7 +5295,7 @@ func (x *GetFirstRechargeRewardStatusResponse) GetStatus() *FirstRechargeRewardS
return nil
}
// ConsumeFirstRechargeRewardRequest 是 wallet 充值事实消费入口;只处理 recharge_sequence=1 的成功充值
// ConsumeFirstRechargeRewardRequest 是 wallet 充值事实消费入口;Google 商品 ID 命中档位后发奖
type ConsumeFirstRechargeRewardRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -5251,6 +5307,8 @@ type ConsumeFirstRechargeRewardRequest struct {
RechargeSequence int64 `protobuf:"varint,7,opt,name=recharge_sequence,json=rechargeSequence,proto3" json:"recharge_sequence,omitempty"`
RechargeType string `protobuf:"bytes,8,opt,name=recharge_type,json=rechargeType,proto3" json:"recharge_type,omitempty"`
OccurredAtMs int64 `protobuf:"varint,9,opt,name=occurred_at_ms,json=occurredAtMs,proto3" json:"occurred_at_ms,omitempty"`
RechargeUsdMinor int64 `protobuf:"varint,10,opt,name=recharge_usd_minor,json=rechargeUsdMinor,proto3" json:"recharge_usd_minor,omitempty"`
GoogleProductId string `protobuf:"bytes,11,opt,name=google_product_id,json=googleProductId,proto3" json:"google_product_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -5348,6 +5406,20 @@ func (x *ConsumeFirstRechargeRewardRequest) GetOccurredAtMs() int64 {
return 0
}
func (x *ConsumeFirstRechargeRewardRequest) GetRechargeUsdMinor() int64 {
if x != nil {
return x.RechargeUsdMinor
}
return 0
}
func (x *ConsumeFirstRechargeRewardRequest) GetGoogleProductId() string {
if x != nil {
return x.GoogleProductId
}
return ""
}
type ConsumeFirstRechargeRewardResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Claim *FirstRechargeRewardClaim `protobuf:"bytes,1,opt,name=claim,proto3" json:"claim,omitempty"`
@ -16927,7 +16999,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"$ListRegistrationRewardClaimsResponse\x12B\n" +
"\x06claims\x18\x01 \x03(\v2*.hyapp.activity.v1.RegistrationRewardClaimR\x06claims\x12\x14\n" +
"\x05total\x18\x02 \x01(\x03R\x05total\x12.\n" +
"\x13today_claimed_count\x18\x03 \x01(\x03R\x11todayClaimedCount\"\xe7\x02\n" +
"\x13today_claimed_count\x18\x03 \x01(\x03R\x11todayClaimedCount\"\xe8\x03\n" +
"\x17FirstRechargeRewardTier\x12\x17\n" +
"\atier_id\x18\x01 \x01(\x03R\x06tierId\x12\x1b\n" +
"\ttier_code\x18\x02 \x01(\tR\btierCode\x12\x1b\n" +
@ -16940,14 +17012,17 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"sort_order\x18\b \x01(\x05R\tsortOrder\x12\"\n" +
"\rcreated_at_ms\x18\t \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\n" +
" \x01(\x03R\vupdatedAtMs\"\x89\x02\n" +
" \x01(\x03R\vupdatedAtMs\x12(\n" +
"\x10usd_minor_amount\x18\v \x01(\x03R\x0eusdMinorAmount\x12*\n" +
"\x11google_product_id\x18\f \x01(\tR\x0fgoogleProductId\x12)\n" +
"\x10discount_percent\x18\r \x01(\x05R\x0fdiscountPercent\"\x89\x02\n" +
"\x19FirstRechargeRewardConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12@\n" +
"\x05tiers\x18\x03 \x03(\v2*.hyapp.activity.v1.FirstRechargeRewardTierR\x05tiers\x12-\n" +
"\x13updated_by_admin_id\x18\x04 \x01(\x03R\x10updatedByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\xbc\x05\n" +
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\xc9\x06\n" +
"\x18FirstRechargeRewardClaim\x12\x19\n" +
"\bclaim_id\x18\x01 \x01(\tR\aclaimId\x12\x19\n" +
"\bevent_id\x18\x02 \x01(\tR\aeventId\x12%\n" +
@ -16969,18 +17044,22 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x0frecharged_at_ms\x18\x10 \x01(\x03R\rrechargedAtMs\x12\"\n" +
"\rgranted_at_ms\x18\x11 \x01(\x03R\vgrantedAtMs\x12\"\n" +
"\rcreated_at_ms\x18\x12 \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x13 \x01(\x03R\vupdatedAtMs\"\xfc\x01\n" +
"\rupdated_at_ms\x18\x13 \x01(\x03R\vupdatedAtMs\x121\n" +
"\x15tier_usd_minor_amount\x18\x14 \x01(\x03R\x12tierUsdMinorAmount\x12*\n" +
"\x11google_product_id\x18\x15 \x01(\tR\x0fgoogleProductId\x12,\n" +
"\x12recharge_usd_minor\x18\x16 \x01(\x03R\x10rechargeUsdMinor\"\xc1\x02\n" +
"\x19FirstRechargeRewardStatus\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x12@\n" +
"\x05tiers\x18\x02 \x03(\v2*.hyapp.activity.v1.FirstRechargeRewardTierR\x05tiers\x12\x1a\n" +
"\brewarded\x18\x03 \x01(\bR\brewarded\x12A\n" +
"\x05claim\x18\x04 \x01(\v2+.hyapp.activity.v1.FirstRechargeRewardClaimR\x05claim\x12$\n" +
"\x0eserver_time_ms\x18\x05 \x01(\x03R\fserverTimeMs\"r\n" +
"\x0eserver_time_ms\x18\x05 \x01(\x03R\fserverTimeMs\x12C\n" +
"\x06claims\x18\x06 \x03(\v2+.hyapp.activity.v1.FirstRechargeRewardClaimR\x06claims\"r\n" +
"#GetFirstRechargeRewardStatusRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\"l\n" +
"$GetFirstRechargeRewardStatusResponse\x12D\n" +
"\x06status\x18\x01 \x01(\v2,.hyapp.activity.v1.FirstRechargeRewardStatusR\x06status\"\xfb\x02\n" +
"\x06status\x18\x01 \x01(\v2,.hyapp.activity.v1.FirstRechargeRewardStatusR\x06status\"\xd5\x03\n" +
"!ConsumeFirstRechargeRewardRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x19\n" +
"\bevent_id\x18\x02 \x01(\tR\aeventId\x12%\n" +
@ -16991,7 +17070,10 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x14recharge_coin_amount\x18\x06 \x01(\x03R\x12rechargeCoinAmount\x12+\n" +
"\x11recharge_sequence\x18\a \x01(\x03R\x10rechargeSequence\x12#\n" +
"\rrecharge_type\x18\b \x01(\tR\frechargeType\x12$\n" +
"\x0eoccurred_at_ms\x18\t \x01(\x03R\foccurredAtMs\"\x9b\x01\n" +
"\x0eoccurred_at_ms\x18\t \x01(\x03R\foccurredAtMs\x12,\n" +
"\x12recharge_usd_minor\x18\n" +
" \x01(\x03R\x10rechargeUsdMinor\x12*\n" +
"\x11google_product_id\x18\v \x01(\tR\x0fgoogleProductId\"\x9b\x01\n" +
"\"ConsumeFirstRechargeRewardResponse\x12A\n" +
"\x05claim\x18\x01 \x01(\v2+.hyapp.activity.v1.FirstRechargeRewardClaimR\x05claim\x12\x1a\n" +
"\bconsumed\x18\x02 \x01(\bR\bconsumed\x12\x16\n" +
@ -18358,317 +18440,318 @@ var file_proto_activity_v1_activity_proto_depIdxs = []int32{
62, // 40: hyapp.activity.v1.FirstRechargeRewardConfig.tiers:type_name -> hyapp.activity.v1.FirstRechargeRewardTier
62, // 41: hyapp.activity.v1.FirstRechargeRewardStatus.tiers:type_name -> hyapp.activity.v1.FirstRechargeRewardTier
64, // 42: hyapp.activity.v1.FirstRechargeRewardStatus.claim:type_name -> hyapp.activity.v1.FirstRechargeRewardClaim
0, // 43: hyapp.activity.v1.GetFirstRechargeRewardStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
65, // 44: hyapp.activity.v1.GetFirstRechargeRewardStatusResponse.status:type_name -> hyapp.activity.v1.FirstRechargeRewardStatus
0, // 45: hyapp.activity.v1.ConsumeFirstRechargeRewardRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
64, // 46: hyapp.activity.v1.ConsumeFirstRechargeRewardResponse.claim:type_name -> hyapp.activity.v1.FirstRechargeRewardClaim
0, // 47: hyapp.activity.v1.GetFirstRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
63, // 48: hyapp.activity.v1.GetFirstRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.FirstRechargeRewardConfig
0, // 49: hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
62, // 50: hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest.tiers:type_name -> hyapp.activity.v1.FirstRechargeRewardTier
63, // 51: hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.FirstRechargeRewardConfig
0, // 52: hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
64, // 53: hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse.claims:type_name -> hyapp.activity.v1.FirstRechargeRewardClaim
76, // 54: hyapp.activity.v1.CumulativeRechargeRewardConfig.tiers:type_name -> hyapp.activity.v1.CumulativeRechargeRewardTier
77, // 55: hyapp.activity.v1.CumulativeRechargeRewardStatus.config:type_name -> hyapp.activity.v1.CumulativeRechargeRewardConfig
79, // 56: hyapp.activity.v1.CumulativeRechargeRewardStatus.progress:type_name -> hyapp.activity.v1.CumulativeRechargeRewardProgress
78, // 57: hyapp.activity.v1.CumulativeRechargeRewardStatus.grants:type_name -> hyapp.activity.v1.CumulativeRechargeRewardGrant
0, // 58: hyapp.activity.v1.GetCumulativeRechargeRewardStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
80, // 59: hyapp.activity.v1.GetCumulativeRechargeRewardStatusResponse.status:type_name -> hyapp.activity.v1.CumulativeRechargeRewardStatus
0, // 60: hyapp.activity.v1.ConsumeCumulativeRechargeRewardRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
78, // 61: hyapp.activity.v1.ConsumeCumulativeRechargeRewardResponse.grants:type_name -> hyapp.activity.v1.CumulativeRechargeRewardGrant
0, // 62: hyapp.activity.v1.GetCumulativeRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
77, // 63: hyapp.activity.v1.GetCumulativeRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.CumulativeRechargeRewardConfig
0, // 64: hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
76, // 65: hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigRequest.tiers:type_name -> hyapp.activity.v1.CumulativeRechargeRewardTier
77, // 66: hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.CumulativeRechargeRewardConfig
0, // 67: hyapp.activity.v1.ListCumulativeRechargeRewardGrantsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
78, // 68: hyapp.activity.v1.ListCumulativeRechargeRewardGrantsResponse.grants:type_name -> hyapp.activity.v1.CumulativeRechargeRewardGrant
91, // 69: hyapp.activity.v1.RoomTurnoverRewardConfig.tiers:type_name -> hyapp.activity.v1.RoomTurnoverRewardTier
92, // 70: hyapp.activity.v1.RoomTurnoverRewardStatus.config:type_name -> hyapp.activity.v1.RoomTurnoverRewardConfig
91, // 71: hyapp.activity.v1.RoomTurnoverRewardStatus.matched_tier:type_name -> hyapp.activity.v1.RoomTurnoverRewardTier
93, // 72: hyapp.activity.v1.RoomTurnoverRewardStatus.latest_settlement:type_name -> hyapp.activity.v1.RoomTurnoverRewardSettlement
0, // 73: hyapp.activity.v1.GetRoomTurnoverRewardStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
94, // 74: hyapp.activity.v1.GetRoomTurnoverRewardStatusResponse.status:type_name -> hyapp.activity.v1.RoomTurnoverRewardStatus
0, // 75: hyapp.activity.v1.GetRoomTurnoverRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
92, // 76: hyapp.activity.v1.GetRoomTurnoverRewardConfigResponse.config:type_name -> hyapp.activity.v1.RoomTurnoverRewardConfig
0, // 77: hyapp.activity.v1.UpdateRoomTurnoverRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
91, // 78: hyapp.activity.v1.UpdateRoomTurnoverRewardConfigRequest.tiers:type_name -> hyapp.activity.v1.RoomTurnoverRewardTier
92, // 79: hyapp.activity.v1.UpdateRoomTurnoverRewardConfigResponse.config:type_name -> hyapp.activity.v1.RoomTurnoverRewardConfig
0, // 80: hyapp.activity.v1.ListRoomTurnoverRewardSettlementsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
93, // 81: hyapp.activity.v1.ListRoomTurnoverRewardSettlementsResponse.settlements:type_name -> hyapp.activity.v1.RoomTurnoverRewardSettlement
0, // 82: hyapp.activity.v1.RetryRoomTurnoverRewardSettlementRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
93, // 83: hyapp.activity.v1.RetryRoomTurnoverRewardSettlementResponse.settlement:type_name -> hyapp.activity.v1.RoomTurnoverRewardSettlement
105, // 84: hyapp.activity.v1.SevenDayCheckInConfig.rewards:type_name -> hyapp.activity.v1.SevenDayCheckInReward
0, // 85: hyapp.activity.v1.GetSevenDayCheckInStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
107, // 86: hyapp.activity.v1.GetSevenDayCheckInStatusResponse.rewards:type_name -> hyapp.activity.v1.SevenDayCheckInRewardStatus
0, // 87: hyapp.activity.v1.SignSevenDayCheckInRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 88: hyapp.activity.v1.GetSevenDayCheckInConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
106, // 89: hyapp.activity.v1.GetSevenDayCheckInConfigResponse.config:type_name -> hyapp.activity.v1.SevenDayCheckInConfig
0, // 90: hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
105, // 91: hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest.rewards:type_name -> hyapp.activity.v1.SevenDayCheckInReward
106, // 92: hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse.config:type_name -> hyapp.activity.v1.SevenDayCheckInConfig
0, // 93: hyapp.activity.v1.ListSevenDayCheckInClaimsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
116, // 94: hyapp.activity.v1.ListSevenDayCheckInClaimsResponse.claims:type_name -> hyapp.activity.v1.SevenDayCheckInClaim
0, // 95: hyapp.activity.v1.GetMyLevelOverviewRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
122, // 96: hyapp.activity.v1.GetMyLevelOverviewResponse.tracks:type_name -> hyapp.activity.v1.LevelTrackOverview
0, // 97: hyapp.activity.v1.GetLevelTrackRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
122, // 98: hyapp.activity.v1.GetLevelTrackResponse.overview:type_name -> hyapp.activity.v1.LevelTrackOverview
120, // 99: hyapp.activity.v1.GetLevelTrackResponse.rules:type_name -> hyapp.activity.v1.LevelRule
121, // 100: hyapp.activity.v1.GetLevelTrackResponse.tiers:type_name -> hyapp.activity.v1.LevelTier
127, // 101: hyapp.activity.v1.UserLevelDisplayProfile.wealth:type_name -> hyapp.activity.v1.LevelDisplayTrackProfile
127, // 102: hyapp.activity.v1.UserLevelDisplayProfile.game:type_name -> hyapp.activity.v1.LevelDisplayTrackProfile
127, // 103: hyapp.activity.v1.UserLevelDisplayProfile.charm:type_name -> hyapp.activity.v1.LevelDisplayTrackProfile
0, // 104: hyapp.activity.v1.BatchGetUserLevelDisplayProfilesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
128, // 105: hyapp.activity.v1.BatchGetUserLevelDisplayProfilesResponse.profiles:type_name -> hyapp.activity.v1.UserLevelDisplayProfile
0, // 106: hyapp.activity.v1.ListLevelRewardsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
131, // 107: hyapp.activity.v1.ListLevelRewardsResponse.rewards:type_name -> hyapp.activity.v1.LevelRewardJob
0, // 108: hyapp.activity.v1.ConsumeLevelEventRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 109: hyapp.activity.v1.SetUserLevelRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 110: hyapp.activity.v1.IssueRegistrationLevelBadgesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
138, // 111: hyapp.activity.v1.IssueRegistrationLevelBadgesResponse.grants:type_name -> hyapp.activity.v1.RegistrationLevelBadgeGrant
0, // 112: hyapp.activity.v1.UpsertLevelTrackRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
119, // 113: hyapp.activity.v1.UpsertLevelTrackResponse.track:type_name -> hyapp.activity.v1.LevelTrack
0, // 114: hyapp.activity.v1.UpsertLevelRuleRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
120, // 115: hyapp.activity.v1.UpsertLevelRuleResponse.rule:type_name -> hyapp.activity.v1.LevelRule
0, // 116: hyapp.activity.v1.UpsertLevelTierRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
121, // 117: hyapp.activity.v1.UpsertLevelTierResponse.tier:type_name -> hyapp.activity.v1.LevelTier
0, // 118: hyapp.activity.v1.ListLevelConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
119, // 119: hyapp.activity.v1.ListLevelConfigResponse.tracks:type_name -> hyapp.activity.v1.LevelTrack
120, // 120: hyapp.activity.v1.ListLevelConfigResponse.rules:type_name -> hyapp.activity.v1.LevelRule
121, // 121: hyapp.activity.v1.ListLevelConfigResponse.tiers:type_name -> hyapp.activity.v1.LevelTier
149, // 122: hyapp.activity.v1.AchievementDefinition.conditions:type_name -> hyapp.activity.v1.AchievementCondition
150, // 123: hyapp.activity.v1.UserAchievement.definition:type_name -> hyapp.activity.v1.AchievementDefinition
0, // 124: hyapp.activity.v1.ListAchievementsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
151, // 125: hyapp.activity.v1.ListAchievementsResponse.achievements:type_name -> hyapp.activity.v1.UserAchievement
0, // 126: hyapp.activity.v1.ConsumeAchievementEventRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 127: hyapp.activity.v1.ListMyBadgesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
156, // 128: hyapp.activity.v1.ListMyBadgesResponse.strip_badges:type_name -> hyapp.activity.v1.BadgeDisplayItem
156, // 129: hyapp.activity.v1.ListMyBadgesResponse.profile_tile_badges:type_name -> hyapp.activity.v1.BadgeDisplayItem
156, // 130: hyapp.activity.v1.ListMyBadgesResponse.honor_badges:type_name -> hyapp.activity.v1.BadgeDisplayItem
0, // 131: hyapp.activity.v1.SetBadgeDisplayRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
156, // 132: hyapp.activity.v1.SetBadgeDisplayRequest.items:type_name -> hyapp.activity.v1.BadgeDisplayItem
158, // 133: hyapp.activity.v1.SetBadgeDisplayResponse.profile:type_name -> hyapp.activity.v1.ListMyBadgesResponse
0, // 134: hyapp.activity.v1.UpsertAchievementDefinitionRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
149, // 135: hyapp.activity.v1.UpsertAchievementDefinitionRequest.conditions:type_name -> hyapp.activity.v1.AchievementCondition
150, // 136: hyapp.activity.v1.UpsertAchievementDefinitionResponse.achievement:type_name -> hyapp.activity.v1.AchievementDefinition
0, // 137: hyapp.activity.v1.LuckyGiftMeta.meta:type_name -> hyapp.activity.v1.RequestMeta
164, // 138: hyapp.activity.v1.LuckyGiftConfig.tiers:type_name -> hyapp.activity.v1.LuckyGiftTier
166, // 139: hyapp.activity.v1.LuckyGiftRuleStage.tiers:type_name -> hyapp.activity.v1.LuckyGiftRuleTier
167, // 140: hyapp.activity.v1.LuckyGiftRuleConfig.stages:type_name -> hyapp.activity.v1.LuckyGiftRuleStage
0, // 141: hyapp.activity.v1.CheckLuckyGiftRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
163, // 142: hyapp.activity.v1.ExecuteLuckyGiftDrawRequest.lucky_gift:type_name -> hyapp.activity.v1.LuckyGiftMeta
171, // 143: hyapp.activity.v1.ExecuteLuckyGiftDrawResponse.result:type_name -> hyapp.activity.v1.LuckyGiftDrawResult
0, // 144: hyapp.activity.v1.GetLuckyGiftConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
168, // 145: hyapp.activity.v1.GetLuckyGiftConfigResponse.config:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
0, // 146: hyapp.activity.v1.UpsertLuckyGiftConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
168, // 147: hyapp.activity.v1.UpsertLuckyGiftConfigRequest.config:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
168, // 148: hyapp.activity.v1.UpsertLuckyGiftConfigResponse.config:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
0, // 149: hyapp.activity.v1.ListLuckyGiftConfigsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
168, // 150: hyapp.activity.v1.ListLuckyGiftConfigsResponse.configs:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
0, // 151: hyapp.activity.v1.ListLuckyGiftDrawsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
171, // 152: hyapp.activity.v1.ListLuckyGiftDrawsResponse.draws:type_name -> hyapp.activity.v1.LuckyGiftDrawResult
0, // 153: hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
182, // 154: hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.activity.v1.LuckyGiftDrawSummary
185, // 155: hyapp.activity.v1.WeeklyStarCycle.gifts:type_name -> hyapp.activity.v1.WeeklyStarGift
186, // 156: hyapp.activity.v1.WeeklyStarCycle.rewards:type_name -> hyapp.activity.v1.WeeklyStarReward
0, // 157: hyapp.activity.v1.ListWeeklyStarCyclesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 158: hyapp.activity.v1.ListWeeklyStarCyclesResponse.cycles:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 159: hyapp.activity.v1.GetWeeklyStarCycleRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 160: hyapp.activity.v1.GetWeeklyStarCycleResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 161: hyapp.activity.v1.UpsertWeeklyStarCycleRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 162: hyapp.activity.v1.UpsertWeeklyStarCycleRequest.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
187, // 163: hyapp.activity.v1.UpsertWeeklyStarCycleResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 164: hyapp.activity.v1.SetWeeklyStarCycleStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 165: hyapp.activity.v1.SetWeeklyStarCycleStatusResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 166: hyapp.activity.v1.ListWeeklyStarLeaderboardRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 167: hyapp.activity.v1.ListWeeklyStarLeaderboardResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
188, // 168: hyapp.activity.v1.ListWeeklyStarLeaderboardResponse.entries:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
0, // 169: hyapp.activity.v1.ListWeeklyStarSettlementsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
189, // 170: hyapp.activity.v1.ListWeeklyStarSettlementsResponse.settlements:type_name -> hyapp.activity.v1.WeeklyStarSettlement
0, // 171: hyapp.activity.v1.GetWeeklyStarCurrentRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 172: hyapp.activity.v1.GetWeeklyStarCurrentResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
188, // 173: hyapp.activity.v1.GetWeeklyStarCurrentResponse.top_entries:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
188, // 174: hyapp.activity.v1.GetWeeklyStarCurrentResponse.my_entry:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
0, // 175: hyapp.activity.v1.ListWeeklyStarHistoryRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 176: hyapp.activity.v1.WeeklyStarHistoryCycle.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
188, // 177: hyapp.activity.v1.WeeklyStarHistoryCycle.top_entries:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
205, // 178: hyapp.activity.v1.ListWeeklyStarHistoryResponse.cycles:type_name -> hyapp.activity.v1.WeeklyStarHistoryCycle
1, // 179: hyapp.activity.v1.ActivityService.PingActivity:input_type -> hyapp.activity.v1.PingActivityRequest
3, // 180: hyapp.activity.v1.ActivityService.GetActivityStatus:input_type -> hyapp.activity.v1.GetActivityStatusRequest
6, // 181: hyapp.activity.v1.MessageInboxService.ListMessageTabs:input_type -> hyapp.activity.v1.ListMessageTabsRequest
9, // 182: hyapp.activity.v1.MessageInboxService.ListInboxMessages:input_type -> hyapp.activity.v1.ListInboxMessagesRequest
11, // 183: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:input_type -> hyapp.activity.v1.MarkInboxMessageReadRequest
13, // 184: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:input_type -> hyapp.activity.v1.MarkInboxSectionReadRequest
15, // 185: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:input_type -> hyapp.activity.v1.DeleteInboxMessageRequest
17, // 186: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:input_type -> hyapp.activity.v1.CreateInboxMessageRequest
19, // 187: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:input_type -> hyapp.activity.v1.CreateFanoutJobRequest
21, // 188: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 189: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 190: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 191: hyapp.activity.v1.ActivityCronService.ProcessRoomTurnoverRewardSettlementBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 192: hyapp.activity.v1.ActivityCronService.ProcessWeeklyStarSettlementBatch:input_type -> hyapp.activity.v1.CronBatchRequest
25, // 193: hyapp.activity.v1.TaskService.ListUserTasks:input_type -> hyapp.activity.v1.ListUserTasksRequest
27, // 194: hyapp.activity.v1.TaskService.ClaimTaskReward:input_type -> hyapp.activity.v1.ClaimTaskRewardRequest
29, // 195: hyapp.activity.v1.TaskService.ConsumeTaskEvent:input_type -> hyapp.activity.v1.ConsumeTaskEventRequest
123, // 196: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:input_type -> hyapp.activity.v1.GetMyLevelOverviewRequest
125, // 197: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:input_type -> hyapp.activity.v1.GetLevelTrackRequest
129, // 198: hyapp.activity.v1.GrowthLevelService.BatchGetUserLevelDisplayProfiles:input_type -> hyapp.activity.v1.BatchGetUserLevelDisplayProfilesRequest
132, // 199: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:input_type -> hyapp.activity.v1.ListLevelRewardsRequest
134, // 200: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:input_type -> hyapp.activity.v1.ConsumeLevelEventRequest
136, // 201: hyapp.activity.v1.GrowthLevelService.SetUserLevel:input_type -> hyapp.activity.v1.SetUserLevelRequest
139, // 202: hyapp.activity.v1.GrowthLevelService.IssueRegistrationLevelBadges:input_type -> hyapp.activity.v1.IssueRegistrationLevelBadgesRequest
152, // 203: hyapp.activity.v1.AchievementService.ListAchievements:input_type -> hyapp.activity.v1.ListAchievementsRequest
154, // 204: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:input_type -> hyapp.activity.v1.ConsumeAchievementEventRequest
157, // 205: hyapp.activity.v1.AchievementService.ListMyBadges:input_type -> hyapp.activity.v1.ListMyBadgesRequest
159, // 206: hyapp.activity.v1.AchievementService.SetBadgeDisplay:input_type -> hyapp.activity.v1.SetBadgeDisplayRequest
169, // 207: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.activity.v1.CheckLuckyGiftRequest
172, // 208: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawRequest
95, // 209: hyapp.activity.v1.RoomTurnoverRewardService.GetRoomTurnoverRewardStatus:input_type -> hyapp.activity.v1.GetRoomTurnoverRewardStatusRequest
202, // 210: hyapp.activity.v1.WeeklyStarService.GetWeeklyStarCurrent:input_type -> hyapp.activity.v1.GetWeeklyStarCurrentRequest
198, // 211: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarLeaderboard:input_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardRequest
204, // 212: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarHistory:input_type -> hyapp.activity.v1.ListWeeklyStarHistoryRequest
32, // 213: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:input_type -> hyapp.activity.v1.EnsureBroadcastGroupsRequest
34, // 214: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:input_type -> hyapp.activity.v1.PublishRegionBroadcastRequest
35, // 215: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:input_type -> hyapp.activity.v1.PublishGlobalBroadcastRequest
37, // 216: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:input_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberRequest
21, // 217: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:input_type -> hyapp.activity.v1.CronBatchRequest
40, // 218: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:input_type -> hyapp.activity.v1.ConsumeRoomEventRequest
43, // 219: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:input_type -> hyapp.activity.v1.ListTaskDefinitionsRequest
45, // 220: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:input_type -> hyapp.activity.v1.UpsertTaskDefinitionRequest
47, // 221: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:input_type -> hyapp.activity.v1.SetTaskDefinitionStatusRequest
52, // 222: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:input_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityRequest
54, // 223: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:input_type -> hyapp.activity.v1.IssueRegistrationRewardRequest
56, // 224: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:input_type -> hyapp.activity.v1.GetRegistrationRewardConfigRequest
58, // 225: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:input_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigRequest
60, // 226: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:input_type -> hyapp.activity.v1.ListRegistrationRewardClaimsRequest
66, // 227: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:input_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusRequest
68, // 228: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:input_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardRequest
70, // 229: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigRequest
72, // 230: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest
74, // 231: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:input_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest
81, // 232: hyapp.activity.v1.CumulativeRechargeRewardService.GetCumulativeRechargeRewardStatus:input_type -> hyapp.activity.v1.GetCumulativeRechargeRewardStatusRequest
83, // 233: hyapp.activity.v1.CumulativeRechargeRewardService.ConsumeCumulativeRechargeReward:input_type -> hyapp.activity.v1.ConsumeCumulativeRechargeRewardRequest
85, // 234: hyapp.activity.v1.AdminCumulativeRechargeRewardService.GetCumulativeRechargeRewardConfig:input_type -> hyapp.activity.v1.GetCumulativeRechargeRewardConfigRequest
87, // 235: hyapp.activity.v1.AdminCumulativeRechargeRewardService.UpdateCumulativeRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigRequest
89, // 236: hyapp.activity.v1.AdminCumulativeRechargeRewardService.ListCumulativeRechargeRewardGrants:input_type -> hyapp.activity.v1.ListCumulativeRechargeRewardGrantsRequest
97, // 237: hyapp.activity.v1.AdminRoomTurnoverRewardService.GetRoomTurnoverRewardConfig:input_type -> hyapp.activity.v1.GetRoomTurnoverRewardConfigRequest
99, // 238: hyapp.activity.v1.AdminRoomTurnoverRewardService.UpdateRoomTurnoverRewardConfig:input_type -> hyapp.activity.v1.UpdateRoomTurnoverRewardConfigRequest
101, // 239: hyapp.activity.v1.AdminRoomTurnoverRewardService.ListRoomTurnoverRewardSettlements:input_type -> hyapp.activity.v1.ListRoomTurnoverRewardSettlementsRequest
103, // 240: hyapp.activity.v1.AdminRoomTurnoverRewardService.RetryRoomTurnoverRewardSettlement:input_type -> hyapp.activity.v1.RetryRoomTurnoverRewardSettlementRequest
190, // 241: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarCycles:input_type -> hyapp.activity.v1.ListWeeklyStarCyclesRequest
194, // 242: hyapp.activity.v1.AdminWeeklyStarService.CreateWeeklyStarCycle:input_type -> hyapp.activity.v1.UpsertWeeklyStarCycleRequest
192, // 243: hyapp.activity.v1.AdminWeeklyStarService.GetWeeklyStarCycle:input_type -> hyapp.activity.v1.GetWeeklyStarCycleRequest
194, // 244: hyapp.activity.v1.AdminWeeklyStarService.UpdateWeeklyStarCycle:input_type -> hyapp.activity.v1.UpsertWeeklyStarCycleRequest
196, // 245: hyapp.activity.v1.AdminWeeklyStarService.SetWeeklyStarCycleStatus:input_type -> hyapp.activity.v1.SetWeeklyStarCycleStatusRequest
198, // 246: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarLeaderboard:input_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardRequest
200, // 247: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarSettlements:input_type -> hyapp.activity.v1.ListWeeklyStarSettlementsRequest
108, // 248: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:input_type -> hyapp.activity.v1.GetSevenDayCheckInStatusRequest
110, // 249: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:input_type -> hyapp.activity.v1.SignSevenDayCheckInRequest
112, // 250: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:input_type -> hyapp.activity.v1.GetSevenDayCheckInConfigRequest
114, // 251: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:input_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest
117, // 252: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:input_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsRequest
147, // 253: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:input_type -> hyapp.activity.v1.ListLevelConfigRequest
141, // 254: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:input_type -> hyapp.activity.v1.UpsertLevelTrackRequest
143, // 255: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:input_type -> hyapp.activity.v1.UpsertLevelRuleRequest
145, // 256: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:input_type -> hyapp.activity.v1.UpsertLevelTierRequest
152, // 257: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:input_type -> hyapp.activity.v1.ListAchievementsRequest
161, // 258: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:input_type -> hyapp.activity.v1.UpsertAchievementDefinitionRequest
174, // 259: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.activity.v1.GetLuckyGiftConfigRequest
176, // 260: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.activity.v1.UpsertLuckyGiftConfigRequest
178, // 261: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.activity.v1.ListLuckyGiftConfigsRequest
180, // 262: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.activity.v1.ListLuckyGiftDrawsRequest
183, // 263: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest
2, // 264: hyapp.activity.v1.ActivityService.PingActivity:output_type -> hyapp.activity.v1.PingActivityResponse
4, // 265: hyapp.activity.v1.ActivityService.GetActivityStatus:output_type -> hyapp.activity.v1.GetActivityStatusResponse
7, // 266: hyapp.activity.v1.MessageInboxService.ListMessageTabs:output_type -> hyapp.activity.v1.ListMessageTabsResponse
10, // 267: hyapp.activity.v1.MessageInboxService.ListInboxMessages:output_type -> hyapp.activity.v1.ListInboxMessagesResponse
12, // 268: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:output_type -> hyapp.activity.v1.MarkInboxMessageReadResponse
14, // 269: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:output_type -> hyapp.activity.v1.MarkInboxSectionReadResponse
16, // 270: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:output_type -> hyapp.activity.v1.DeleteInboxMessageResponse
18, // 271: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:output_type -> hyapp.activity.v1.CreateInboxMessageResponse
20, // 272: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:output_type -> hyapp.activity.v1.CreateFanoutJobResponse
22, // 273: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 274: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 275: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 276: hyapp.activity.v1.ActivityCronService.ProcessRoomTurnoverRewardSettlementBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 277: hyapp.activity.v1.ActivityCronService.ProcessWeeklyStarSettlementBatch:output_type -> hyapp.activity.v1.CronBatchResponse
26, // 278: hyapp.activity.v1.TaskService.ListUserTasks:output_type -> hyapp.activity.v1.ListUserTasksResponse
28, // 279: hyapp.activity.v1.TaskService.ClaimTaskReward:output_type -> hyapp.activity.v1.ClaimTaskRewardResponse
30, // 280: hyapp.activity.v1.TaskService.ConsumeTaskEvent:output_type -> hyapp.activity.v1.ConsumeTaskEventResponse
124, // 281: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:output_type -> hyapp.activity.v1.GetMyLevelOverviewResponse
126, // 282: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:output_type -> hyapp.activity.v1.GetLevelTrackResponse
130, // 283: hyapp.activity.v1.GrowthLevelService.BatchGetUserLevelDisplayProfiles:output_type -> hyapp.activity.v1.BatchGetUserLevelDisplayProfilesResponse
133, // 284: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:output_type -> hyapp.activity.v1.ListLevelRewardsResponse
135, // 285: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:output_type -> hyapp.activity.v1.ConsumeLevelEventResponse
137, // 286: hyapp.activity.v1.GrowthLevelService.SetUserLevel:output_type -> hyapp.activity.v1.SetUserLevelResponse
140, // 287: hyapp.activity.v1.GrowthLevelService.IssueRegistrationLevelBadges:output_type -> hyapp.activity.v1.IssueRegistrationLevelBadgesResponse
153, // 288: hyapp.activity.v1.AchievementService.ListAchievements:output_type -> hyapp.activity.v1.ListAchievementsResponse
155, // 289: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:output_type -> hyapp.activity.v1.ConsumeAchievementEventResponse
158, // 290: hyapp.activity.v1.AchievementService.ListMyBadges:output_type -> hyapp.activity.v1.ListMyBadgesResponse
160, // 291: hyapp.activity.v1.AchievementService.SetBadgeDisplay:output_type -> hyapp.activity.v1.SetBadgeDisplayResponse
170, // 292: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.activity.v1.CheckLuckyGiftResponse
173, // 293: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawResponse
96, // 294: hyapp.activity.v1.RoomTurnoverRewardService.GetRoomTurnoverRewardStatus:output_type -> hyapp.activity.v1.GetRoomTurnoverRewardStatusResponse
203, // 295: hyapp.activity.v1.WeeklyStarService.GetWeeklyStarCurrent:output_type -> hyapp.activity.v1.GetWeeklyStarCurrentResponse
199, // 296: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarLeaderboard:output_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardResponse
206, // 297: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarHistory:output_type -> hyapp.activity.v1.ListWeeklyStarHistoryResponse
33, // 298: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:output_type -> hyapp.activity.v1.EnsureBroadcastGroupsResponse
36, // 299: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse
36, // 300: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse
38, // 301: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:output_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberResponse
39, // 302: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:output_type -> hyapp.activity.v1.ProcessBroadcastOutboxBatchResponse
41, // 303: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:output_type -> hyapp.activity.v1.ConsumeRoomEventResponse
44, // 304: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:output_type -> hyapp.activity.v1.ListTaskDefinitionsResponse
46, // 305: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:output_type -> hyapp.activity.v1.UpsertTaskDefinitionResponse
48, // 306: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:output_type -> hyapp.activity.v1.SetTaskDefinitionStatusResponse
53, // 307: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:output_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityResponse
55, // 308: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:output_type -> hyapp.activity.v1.IssueRegistrationRewardResponse
57, // 309: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:output_type -> hyapp.activity.v1.GetRegistrationRewardConfigResponse
59, // 310: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:output_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigResponse
61, // 311: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:output_type -> hyapp.activity.v1.ListRegistrationRewardClaimsResponse
67, // 312: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:output_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusResponse
69, // 313: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:output_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardResponse
71, // 314: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigResponse
73, // 315: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse
75, // 316: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:output_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse
82, // 317: hyapp.activity.v1.CumulativeRechargeRewardService.GetCumulativeRechargeRewardStatus:output_type -> hyapp.activity.v1.GetCumulativeRechargeRewardStatusResponse
84, // 318: hyapp.activity.v1.CumulativeRechargeRewardService.ConsumeCumulativeRechargeReward:output_type -> hyapp.activity.v1.ConsumeCumulativeRechargeRewardResponse
86, // 319: hyapp.activity.v1.AdminCumulativeRechargeRewardService.GetCumulativeRechargeRewardConfig:output_type -> hyapp.activity.v1.GetCumulativeRechargeRewardConfigResponse
88, // 320: hyapp.activity.v1.AdminCumulativeRechargeRewardService.UpdateCumulativeRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigResponse
90, // 321: hyapp.activity.v1.AdminCumulativeRechargeRewardService.ListCumulativeRechargeRewardGrants:output_type -> hyapp.activity.v1.ListCumulativeRechargeRewardGrantsResponse
98, // 322: hyapp.activity.v1.AdminRoomTurnoverRewardService.GetRoomTurnoverRewardConfig:output_type -> hyapp.activity.v1.GetRoomTurnoverRewardConfigResponse
100, // 323: hyapp.activity.v1.AdminRoomTurnoverRewardService.UpdateRoomTurnoverRewardConfig:output_type -> hyapp.activity.v1.UpdateRoomTurnoverRewardConfigResponse
102, // 324: hyapp.activity.v1.AdminRoomTurnoverRewardService.ListRoomTurnoverRewardSettlements:output_type -> hyapp.activity.v1.ListRoomTurnoverRewardSettlementsResponse
104, // 325: hyapp.activity.v1.AdminRoomTurnoverRewardService.RetryRoomTurnoverRewardSettlement:output_type -> hyapp.activity.v1.RetryRoomTurnoverRewardSettlementResponse
191, // 326: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarCycles:output_type -> hyapp.activity.v1.ListWeeklyStarCyclesResponse
195, // 327: hyapp.activity.v1.AdminWeeklyStarService.CreateWeeklyStarCycle:output_type -> hyapp.activity.v1.UpsertWeeklyStarCycleResponse
193, // 328: hyapp.activity.v1.AdminWeeklyStarService.GetWeeklyStarCycle:output_type -> hyapp.activity.v1.GetWeeklyStarCycleResponse
195, // 329: hyapp.activity.v1.AdminWeeklyStarService.UpdateWeeklyStarCycle:output_type -> hyapp.activity.v1.UpsertWeeklyStarCycleResponse
197, // 330: hyapp.activity.v1.AdminWeeklyStarService.SetWeeklyStarCycleStatus:output_type -> hyapp.activity.v1.SetWeeklyStarCycleStatusResponse
199, // 331: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarLeaderboard:output_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardResponse
201, // 332: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarSettlements:output_type -> hyapp.activity.v1.ListWeeklyStarSettlementsResponse
109, // 333: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:output_type -> hyapp.activity.v1.GetSevenDayCheckInStatusResponse
111, // 334: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:output_type -> hyapp.activity.v1.SignSevenDayCheckInResponse
113, // 335: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:output_type -> hyapp.activity.v1.GetSevenDayCheckInConfigResponse
115, // 336: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:output_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse
118, // 337: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:output_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsResponse
148, // 338: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:output_type -> hyapp.activity.v1.ListLevelConfigResponse
142, // 339: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:output_type -> hyapp.activity.v1.UpsertLevelTrackResponse
144, // 340: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:output_type -> hyapp.activity.v1.UpsertLevelRuleResponse
146, // 341: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:output_type -> hyapp.activity.v1.UpsertLevelTierResponse
153, // 342: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:output_type -> hyapp.activity.v1.ListAchievementsResponse
162, // 343: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:output_type -> hyapp.activity.v1.UpsertAchievementDefinitionResponse
175, // 344: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.activity.v1.GetLuckyGiftConfigResponse
177, // 345: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.activity.v1.UpsertLuckyGiftConfigResponse
179, // 346: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.activity.v1.ListLuckyGiftConfigsResponse
181, // 347: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.activity.v1.ListLuckyGiftDrawsResponse
184, // 348: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse
264, // [264:349] is the sub-list for method output_type
179, // [179:264] is the sub-list for method input_type
179, // [179:179] is the sub-list for extension type_name
179, // [179:179] is the sub-list for extension extendee
0, // [0:179] is the sub-list for field type_name
64, // 43: hyapp.activity.v1.FirstRechargeRewardStatus.claims:type_name -> hyapp.activity.v1.FirstRechargeRewardClaim
0, // 44: hyapp.activity.v1.GetFirstRechargeRewardStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
65, // 45: hyapp.activity.v1.GetFirstRechargeRewardStatusResponse.status:type_name -> hyapp.activity.v1.FirstRechargeRewardStatus
0, // 46: hyapp.activity.v1.ConsumeFirstRechargeRewardRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
64, // 47: hyapp.activity.v1.ConsumeFirstRechargeRewardResponse.claim:type_name -> hyapp.activity.v1.FirstRechargeRewardClaim
0, // 48: hyapp.activity.v1.GetFirstRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
63, // 49: hyapp.activity.v1.GetFirstRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.FirstRechargeRewardConfig
0, // 50: hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
62, // 51: hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest.tiers:type_name -> hyapp.activity.v1.FirstRechargeRewardTier
63, // 52: hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.FirstRechargeRewardConfig
0, // 53: hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
64, // 54: hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse.claims:type_name -> hyapp.activity.v1.FirstRechargeRewardClaim
76, // 55: hyapp.activity.v1.CumulativeRechargeRewardConfig.tiers:type_name -> hyapp.activity.v1.CumulativeRechargeRewardTier
77, // 56: hyapp.activity.v1.CumulativeRechargeRewardStatus.config:type_name -> hyapp.activity.v1.CumulativeRechargeRewardConfig
79, // 57: hyapp.activity.v1.CumulativeRechargeRewardStatus.progress:type_name -> hyapp.activity.v1.CumulativeRechargeRewardProgress
78, // 58: hyapp.activity.v1.CumulativeRechargeRewardStatus.grants:type_name -> hyapp.activity.v1.CumulativeRechargeRewardGrant
0, // 59: hyapp.activity.v1.GetCumulativeRechargeRewardStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
80, // 60: hyapp.activity.v1.GetCumulativeRechargeRewardStatusResponse.status:type_name -> hyapp.activity.v1.CumulativeRechargeRewardStatus
0, // 61: hyapp.activity.v1.ConsumeCumulativeRechargeRewardRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
78, // 62: hyapp.activity.v1.ConsumeCumulativeRechargeRewardResponse.grants:type_name -> hyapp.activity.v1.CumulativeRechargeRewardGrant
0, // 63: hyapp.activity.v1.GetCumulativeRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
77, // 64: hyapp.activity.v1.GetCumulativeRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.CumulativeRechargeRewardConfig
0, // 65: hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
76, // 66: hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigRequest.tiers:type_name -> hyapp.activity.v1.CumulativeRechargeRewardTier
77, // 67: hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigResponse.config:type_name -> hyapp.activity.v1.CumulativeRechargeRewardConfig
0, // 68: hyapp.activity.v1.ListCumulativeRechargeRewardGrantsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
78, // 69: hyapp.activity.v1.ListCumulativeRechargeRewardGrantsResponse.grants:type_name -> hyapp.activity.v1.CumulativeRechargeRewardGrant
91, // 70: hyapp.activity.v1.RoomTurnoverRewardConfig.tiers:type_name -> hyapp.activity.v1.RoomTurnoverRewardTier
92, // 71: hyapp.activity.v1.RoomTurnoverRewardStatus.config:type_name -> hyapp.activity.v1.RoomTurnoverRewardConfig
91, // 72: hyapp.activity.v1.RoomTurnoverRewardStatus.matched_tier:type_name -> hyapp.activity.v1.RoomTurnoverRewardTier
93, // 73: hyapp.activity.v1.RoomTurnoverRewardStatus.latest_settlement:type_name -> hyapp.activity.v1.RoomTurnoverRewardSettlement
0, // 74: hyapp.activity.v1.GetRoomTurnoverRewardStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
94, // 75: hyapp.activity.v1.GetRoomTurnoverRewardStatusResponse.status:type_name -> hyapp.activity.v1.RoomTurnoverRewardStatus
0, // 76: hyapp.activity.v1.GetRoomTurnoverRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
92, // 77: hyapp.activity.v1.GetRoomTurnoverRewardConfigResponse.config:type_name -> hyapp.activity.v1.RoomTurnoverRewardConfig
0, // 78: hyapp.activity.v1.UpdateRoomTurnoverRewardConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
91, // 79: hyapp.activity.v1.UpdateRoomTurnoverRewardConfigRequest.tiers:type_name -> hyapp.activity.v1.RoomTurnoverRewardTier
92, // 80: hyapp.activity.v1.UpdateRoomTurnoverRewardConfigResponse.config:type_name -> hyapp.activity.v1.RoomTurnoverRewardConfig
0, // 81: hyapp.activity.v1.ListRoomTurnoverRewardSettlementsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
93, // 82: hyapp.activity.v1.ListRoomTurnoverRewardSettlementsResponse.settlements:type_name -> hyapp.activity.v1.RoomTurnoverRewardSettlement
0, // 83: hyapp.activity.v1.RetryRoomTurnoverRewardSettlementRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
93, // 84: hyapp.activity.v1.RetryRoomTurnoverRewardSettlementResponse.settlement:type_name -> hyapp.activity.v1.RoomTurnoverRewardSettlement
105, // 85: hyapp.activity.v1.SevenDayCheckInConfig.rewards:type_name -> hyapp.activity.v1.SevenDayCheckInReward
0, // 86: hyapp.activity.v1.GetSevenDayCheckInStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
107, // 87: hyapp.activity.v1.GetSevenDayCheckInStatusResponse.rewards:type_name -> hyapp.activity.v1.SevenDayCheckInRewardStatus
0, // 88: hyapp.activity.v1.SignSevenDayCheckInRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 89: hyapp.activity.v1.GetSevenDayCheckInConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
106, // 90: hyapp.activity.v1.GetSevenDayCheckInConfigResponse.config:type_name -> hyapp.activity.v1.SevenDayCheckInConfig
0, // 91: hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
105, // 92: hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest.rewards:type_name -> hyapp.activity.v1.SevenDayCheckInReward
106, // 93: hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse.config:type_name -> hyapp.activity.v1.SevenDayCheckInConfig
0, // 94: hyapp.activity.v1.ListSevenDayCheckInClaimsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
116, // 95: hyapp.activity.v1.ListSevenDayCheckInClaimsResponse.claims:type_name -> hyapp.activity.v1.SevenDayCheckInClaim
0, // 96: hyapp.activity.v1.GetMyLevelOverviewRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
122, // 97: hyapp.activity.v1.GetMyLevelOverviewResponse.tracks:type_name -> hyapp.activity.v1.LevelTrackOverview
0, // 98: hyapp.activity.v1.GetLevelTrackRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
122, // 99: hyapp.activity.v1.GetLevelTrackResponse.overview:type_name -> hyapp.activity.v1.LevelTrackOverview
120, // 100: hyapp.activity.v1.GetLevelTrackResponse.rules:type_name -> hyapp.activity.v1.LevelRule
121, // 101: hyapp.activity.v1.GetLevelTrackResponse.tiers:type_name -> hyapp.activity.v1.LevelTier
127, // 102: hyapp.activity.v1.UserLevelDisplayProfile.wealth:type_name -> hyapp.activity.v1.LevelDisplayTrackProfile
127, // 103: hyapp.activity.v1.UserLevelDisplayProfile.game:type_name -> hyapp.activity.v1.LevelDisplayTrackProfile
127, // 104: hyapp.activity.v1.UserLevelDisplayProfile.charm:type_name -> hyapp.activity.v1.LevelDisplayTrackProfile
0, // 105: hyapp.activity.v1.BatchGetUserLevelDisplayProfilesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
128, // 106: hyapp.activity.v1.BatchGetUserLevelDisplayProfilesResponse.profiles:type_name -> hyapp.activity.v1.UserLevelDisplayProfile
0, // 107: hyapp.activity.v1.ListLevelRewardsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
131, // 108: hyapp.activity.v1.ListLevelRewardsResponse.rewards:type_name -> hyapp.activity.v1.LevelRewardJob
0, // 109: hyapp.activity.v1.ConsumeLevelEventRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 110: hyapp.activity.v1.SetUserLevelRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 111: hyapp.activity.v1.IssueRegistrationLevelBadgesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
138, // 112: hyapp.activity.v1.IssueRegistrationLevelBadgesResponse.grants:type_name -> hyapp.activity.v1.RegistrationLevelBadgeGrant
0, // 113: hyapp.activity.v1.UpsertLevelTrackRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
119, // 114: hyapp.activity.v1.UpsertLevelTrackResponse.track:type_name -> hyapp.activity.v1.LevelTrack
0, // 115: hyapp.activity.v1.UpsertLevelRuleRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
120, // 116: hyapp.activity.v1.UpsertLevelRuleResponse.rule:type_name -> hyapp.activity.v1.LevelRule
0, // 117: hyapp.activity.v1.UpsertLevelTierRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
121, // 118: hyapp.activity.v1.UpsertLevelTierResponse.tier:type_name -> hyapp.activity.v1.LevelTier
0, // 119: hyapp.activity.v1.ListLevelConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
119, // 120: hyapp.activity.v1.ListLevelConfigResponse.tracks:type_name -> hyapp.activity.v1.LevelTrack
120, // 121: hyapp.activity.v1.ListLevelConfigResponse.rules:type_name -> hyapp.activity.v1.LevelRule
121, // 122: hyapp.activity.v1.ListLevelConfigResponse.tiers:type_name -> hyapp.activity.v1.LevelTier
149, // 123: hyapp.activity.v1.AchievementDefinition.conditions:type_name -> hyapp.activity.v1.AchievementCondition
150, // 124: hyapp.activity.v1.UserAchievement.definition:type_name -> hyapp.activity.v1.AchievementDefinition
0, // 125: hyapp.activity.v1.ListAchievementsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
151, // 126: hyapp.activity.v1.ListAchievementsResponse.achievements:type_name -> hyapp.activity.v1.UserAchievement
0, // 127: hyapp.activity.v1.ConsumeAchievementEventRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
0, // 128: hyapp.activity.v1.ListMyBadgesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
156, // 129: hyapp.activity.v1.ListMyBadgesResponse.strip_badges:type_name -> hyapp.activity.v1.BadgeDisplayItem
156, // 130: hyapp.activity.v1.ListMyBadgesResponse.profile_tile_badges:type_name -> hyapp.activity.v1.BadgeDisplayItem
156, // 131: hyapp.activity.v1.ListMyBadgesResponse.honor_badges:type_name -> hyapp.activity.v1.BadgeDisplayItem
0, // 132: hyapp.activity.v1.SetBadgeDisplayRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
156, // 133: hyapp.activity.v1.SetBadgeDisplayRequest.items:type_name -> hyapp.activity.v1.BadgeDisplayItem
158, // 134: hyapp.activity.v1.SetBadgeDisplayResponse.profile:type_name -> hyapp.activity.v1.ListMyBadgesResponse
0, // 135: hyapp.activity.v1.UpsertAchievementDefinitionRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
149, // 136: hyapp.activity.v1.UpsertAchievementDefinitionRequest.conditions:type_name -> hyapp.activity.v1.AchievementCondition
150, // 137: hyapp.activity.v1.UpsertAchievementDefinitionResponse.achievement:type_name -> hyapp.activity.v1.AchievementDefinition
0, // 138: hyapp.activity.v1.LuckyGiftMeta.meta:type_name -> hyapp.activity.v1.RequestMeta
164, // 139: hyapp.activity.v1.LuckyGiftConfig.tiers:type_name -> hyapp.activity.v1.LuckyGiftTier
166, // 140: hyapp.activity.v1.LuckyGiftRuleStage.tiers:type_name -> hyapp.activity.v1.LuckyGiftRuleTier
167, // 141: hyapp.activity.v1.LuckyGiftRuleConfig.stages:type_name -> hyapp.activity.v1.LuckyGiftRuleStage
0, // 142: hyapp.activity.v1.CheckLuckyGiftRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
163, // 143: hyapp.activity.v1.ExecuteLuckyGiftDrawRequest.lucky_gift:type_name -> hyapp.activity.v1.LuckyGiftMeta
171, // 144: hyapp.activity.v1.ExecuteLuckyGiftDrawResponse.result:type_name -> hyapp.activity.v1.LuckyGiftDrawResult
0, // 145: hyapp.activity.v1.GetLuckyGiftConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
168, // 146: hyapp.activity.v1.GetLuckyGiftConfigResponse.config:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
0, // 147: hyapp.activity.v1.UpsertLuckyGiftConfigRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
168, // 148: hyapp.activity.v1.UpsertLuckyGiftConfigRequest.config:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
168, // 149: hyapp.activity.v1.UpsertLuckyGiftConfigResponse.config:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
0, // 150: hyapp.activity.v1.ListLuckyGiftConfigsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
168, // 151: hyapp.activity.v1.ListLuckyGiftConfigsResponse.configs:type_name -> hyapp.activity.v1.LuckyGiftRuleConfig
0, // 152: hyapp.activity.v1.ListLuckyGiftDrawsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
171, // 153: hyapp.activity.v1.ListLuckyGiftDrawsResponse.draws:type_name -> hyapp.activity.v1.LuckyGiftDrawResult
0, // 154: hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
182, // 155: hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.activity.v1.LuckyGiftDrawSummary
185, // 156: hyapp.activity.v1.WeeklyStarCycle.gifts:type_name -> hyapp.activity.v1.WeeklyStarGift
186, // 157: hyapp.activity.v1.WeeklyStarCycle.rewards:type_name -> hyapp.activity.v1.WeeklyStarReward
0, // 158: hyapp.activity.v1.ListWeeklyStarCyclesRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 159: hyapp.activity.v1.ListWeeklyStarCyclesResponse.cycles:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 160: hyapp.activity.v1.GetWeeklyStarCycleRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 161: hyapp.activity.v1.GetWeeklyStarCycleResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 162: hyapp.activity.v1.UpsertWeeklyStarCycleRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 163: hyapp.activity.v1.UpsertWeeklyStarCycleRequest.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
187, // 164: hyapp.activity.v1.UpsertWeeklyStarCycleResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 165: hyapp.activity.v1.SetWeeklyStarCycleStatusRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 166: hyapp.activity.v1.SetWeeklyStarCycleStatusResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
0, // 167: hyapp.activity.v1.ListWeeklyStarLeaderboardRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 168: hyapp.activity.v1.ListWeeklyStarLeaderboardResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
188, // 169: hyapp.activity.v1.ListWeeklyStarLeaderboardResponse.entries:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
0, // 170: hyapp.activity.v1.ListWeeklyStarSettlementsRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
189, // 171: hyapp.activity.v1.ListWeeklyStarSettlementsResponse.settlements:type_name -> hyapp.activity.v1.WeeklyStarSettlement
0, // 172: hyapp.activity.v1.GetWeeklyStarCurrentRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 173: hyapp.activity.v1.GetWeeklyStarCurrentResponse.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
188, // 174: hyapp.activity.v1.GetWeeklyStarCurrentResponse.top_entries:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
188, // 175: hyapp.activity.v1.GetWeeklyStarCurrentResponse.my_entry:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
0, // 176: hyapp.activity.v1.ListWeeklyStarHistoryRequest.meta:type_name -> hyapp.activity.v1.RequestMeta
187, // 177: hyapp.activity.v1.WeeklyStarHistoryCycle.cycle:type_name -> hyapp.activity.v1.WeeklyStarCycle
188, // 178: hyapp.activity.v1.WeeklyStarHistoryCycle.top_entries:type_name -> hyapp.activity.v1.WeeklyStarLeaderboardEntry
205, // 179: hyapp.activity.v1.ListWeeklyStarHistoryResponse.cycles:type_name -> hyapp.activity.v1.WeeklyStarHistoryCycle
1, // 180: hyapp.activity.v1.ActivityService.PingActivity:input_type -> hyapp.activity.v1.PingActivityRequest
3, // 181: hyapp.activity.v1.ActivityService.GetActivityStatus:input_type -> hyapp.activity.v1.GetActivityStatusRequest
6, // 182: hyapp.activity.v1.MessageInboxService.ListMessageTabs:input_type -> hyapp.activity.v1.ListMessageTabsRequest
9, // 183: hyapp.activity.v1.MessageInboxService.ListInboxMessages:input_type -> hyapp.activity.v1.ListInboxMessagesRequest
11, // 184: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:input_type -> hyapp.activity.v1.MarkInboxMessageReadRequest
13, // 185: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:input_type -> hyapp.activity.v1.MarkInboxSectionReadRequest
15, // 186: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:input_type -> hyapp.activity.v1.DeleteInboxMessageRequest
17, // 187: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:input_type -> hyapp.activity.v1.CreateInboxMessageRequest
19, // 188: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:input_type -> hyapp.activity.v1.CreateFanoutJobRequest
21, // 189: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 190: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 191: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 192: hyapp.activity.v1.ActivityCronService.ProcessRoomTurnoverRewardSettlementBatch:input_type -> hyapp.activity.v1.CronBatchRequest
21, // 193: hyapp.activity.v1.ActivityCronService.ProcessWeeklyStarSettlementBatch:input_type -> hyapp.activity.v1.CronBatchRequest
25, // 194: hyapp.activity.v1.TaskService.ListUserTasks:input_type -> hyapp.activity.v1.ListUserTasksRequest
27, // 195: hyapp.activity.v1.TaskService.ClaimTaskReward:input_type -> hyapp.activity.v1.ClaimTaskRewardRequest
29, // 196: hyapp.activity.v1.TaskService.ConsumeTaskEvent:input_type -> hyapp.activity.v1.ConsumeTaskEventRequest
123, // 197: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:input_type -> hyapp.activity.v1.GetMyLevelOverviewRequest
125, // 198: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:input_type -> hyapp.activity.v1.GetLevelTrackRequest
129, // 199: hyapp.activity.v1.GrowthLevelService.BatchGetUserLevelDisplayProfiles:input_type -> hyapp.activity.v1.BatchGetUserLevelDisplayProfilesRequest
132, // 200: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:input_type -> hyapp.activity.v1.ListLevelRewardsRequest
134, // 201: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:input_type -> hyapp.activity.v1.ConsumeLevelEventRequest
136, // 202: hyapp.activity.v1.GrowthLevelService.SetUserLevel:input_type -> hyapp.activity.v1.SetUserLevelRequest
139, // 203: hyapp.activity.v1.GrowthLevelService.IssueRegistrationLevelBadges:input_type -> hyapp.activity.v1.IssueRegistrationLevelBadgesRequest
152, // 204: hyapp.activity.v1.AchievementService.ListAchievements:input_type -> hyapp.activity.v1.ListAchievementsRequest
154, // 205: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:input_type -> hyapp.activity.v1.ConsumeAchievementEventRequest
157, // 206: hyapp.activity.v1.AchievementService.ListMyBadges:input_type -> hyapp.activity.v1.ListMyBadgesRequest
159, // 207: hyapp.activity.v1.AchievementService.SetBadgeDisplay:input_type -> hyapp.activity.v1.SetBadgeDisplayRequest
169, // 208: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.activity.v1.CheckLuckyGiftRequest
172, // 209: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawRequest
95, // 210: hyapp.activity.v1.RoomTurnoverRewardService.GetRoomTurnoverRewardStatus:input_type -> hyapp.activity.v1.GetRoomTurnoverRewardStatusRequest
202, // 211: hyapp.activity.v1.WeeklyStarService.GetWeeklyStarCurrent:input_type -> hyapp.activity.v1.GetWeeklyStarCurrentRequest
198, // 212: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarLeaderboard:input_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardRequest
204, // 213: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarHistory:input_type -> hyapp.activity.v1.ListWeeklyStarHistoryRequest
32, // 214: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:input_type -> hyapp.activity.v1.EnsureBroadcastGroupsRequest
34, // 215: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:input_type -> hyapp.activity.v1.PublishRegionBroadcastRequest
35, // 216: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:input_type -> hyapp.activity.v1.PublishGlobalBroadcastRequest
37, // 217: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:input_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberRequest
21, // 218: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:input_type -> hyapp.activity.v1.CronBatchRequest
40, // 219: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:input_type -> hyapp.activity.v1.ConsumeRoomEventRequest
43, // 220: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:input_type -> hyapp.activity.v1.ListTaskDefinitionsRequest
45, // 221: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:input_type -> hyapp.activity.v1.UpsertTaskDefinitionRequest
47, // 222: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:input_type -> hyapp.activity.v1.SetTaskDefinitionStatusRequest
52, // 223: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:input_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityRequest
54, // 224: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:input_type -> hyapp.activity.v1.IssueRegistrationRewardRequest
56, // 225: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:input_type -> hyapp.activity.v1.GetRegistrationRewardConfigRequest
58, // 226: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:input_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigRequest
60, // 227: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:input_type -> hyapp.activity.v1.ListRegistrationRewardClaimsRequest
66, // 228: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:input_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusRequest
68, // 229: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:input_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardRequest
70, // 230: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigRequest
72, // 231: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigRequest
74, // 232: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:input_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsRequest
81, // 233: hyapp.activity.v1.CumulativeRechargeRewardService.GetCumulativeRechargeRewardStatus:input_type -> hyapp.activity.v1.GetCumulativeRechargeRewardStatusRequest
83, // 234: hyapp.activity.v1.CumulativeRechargeRewardService.ConsumeCumulativeRechargeReward:input_type -> hyapp.activity.v1.ConsumeCumulativeRechargeRewardRequest
85, // 235: hyapp.activity.v1.AdminCumulativeRechargeRewardService.GetCumulativeRechargeRewardConfig:input_type -> hyapp.activity.v1.GetCumulativeRechargeRewardConfigRequest
87, // 236: hyapp.activity.v1.AdminCumulativeRechargeRewardService.UpdateCumulativeRechargeRewardConfig:input_type -> hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigRequest
89, // 237: hyapp.activity.v1.AdminCumulativeRechargeRewardService.ListCumulativeRechargeRewardGrants:input_type -> hyapp.activity.v1.ListCumulativeRechargeRewardGrantsRequest
97, // 238: hyapp.activity.v1.AdminRoomTurnoverRewardService.GetRoomTurnoverRewardConfig:input_type -> hyapp.activity.v1.GetRoomTurnoverRewardConfigRequest
99, // 239: hyapp.activity.v1.AdminRoomTurnoverRewardService.UpdateRoomTurnoverRewardConfig:input_type -> hyapp.activity.v1.UpdateRoomTurnoverRewardConfigRequest
101, // 240: hyapp.activity.v1.AdminRoomTurnoverRewardService.ListRoomTurnoverRewardSettlements:input_type -> hyapp.activity.v1.ListRoomTurnoverRewardSettlementsRequest
103, // 241: hyapp.activity.v1.AdminRoomTurnoverRewardService.RetryRoomTurnoverRewardSettlement:input_type -> hyapp.activity.v1.RetryRoomTurnoverRewardSettlementRequest
190, // 242: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarCycles:input_type -> hyapp.activity.v1.ListWeeklyStarCyclesRequest
194, // 243: hyapp.activity.v1.AdminWeeklyStarService.CreateWeeklyStarCycle:input_type -> hyapp.activity.v1.UpsertWeeklyStarCycleRequest
192, // 244: hyapp.activity.v1.AdminWeeklyStarService.GetWeeklyStarCycle:input_type -> hyapp.activity.v1.GetWeeklyStarCycleRequest
194, // 245: hyapp.activity.v1.AdminWeeklyStarService.UpdateWeeklyStarCycle:input_type -> hyapp.activity.v1.UpsertWeeklyStarCycleRequest
196, // 246: hyapp.activity.v1.AdminWeeklyStarService.SetWeeklyStarCycleStatus:input_type -> hyapp.activity.v1.SetWeeklyStarCycleStatusRequest
198, // 247: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarLeaderboard:input_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardRequest
200, // 248: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarSettlements:input_type -> hyapp.activity.v1.ListWeeklyStarSettlementsRequest
108, // 249: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:input_type -> hyapp.activity.v1.GetSevenDayCheckInStatusRequest
110, // 250: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:input_type -> hyapp.activity.v1.SignSevenDayCheckInRequest
112, // 251: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:input_type -> hyapp.activity.v1.GetSevenDayCheckInConfigRequest
114, // 252: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:input_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigRequest
117, // 253: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:input_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsRequest
147, // 254: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:input_type -> hyapp.activity.v1.ListLevelConfigRequest
141, // 255: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:input_type -> hyapp.activity.v1.UpsertLevelTrackRequest
143, // 256: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:input_type -> hyapp.activity.v1.UpsertLevelRuleRequest
145, // 257: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:input_type -> hyapp.activity.v1.UpsertLevelTierRequest
152, // 258: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:input_type -> hyapp.activity.v1.ListAchievementsRequest
161, // 259: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:input_type -> hyapp.activity.v1.UpsertAchievementDefinitionRequest
174, // 260: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.activity.v1.GetLuckyGiftConfigRequest
176, // 261: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.activity.v1.UpsertLuckyGiftConfigRequest
178, // 262: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.activity.v1.ListLuckyGiftConfigsRequest
180, // 263: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.activity.v1.ListLuckyGiftDrawsRequest
183, // 264: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryRequest
2, // 265: hyapp.activity.v1.ActivityService.PingActivity:output_type -> hyapp.activity.v1.PingActivityResponse
4, // 266: hyapp.activity.v1.ActivityService.GetActivityStatus:output_type -> hyapp.activity.v1.GetActivityStatusResponse
7, // 267: hyapp.activity.v1.MessageInboxService.ListMessageTabs:output_type -> hyapp.activity.v1.ListMessageTabsResponse
10, // 268: hyapp.activity.v1.MessageInboxService.ListInboxMessages:output_type -> hyapp.activity.v1.ListInboxMessagesResponse
12, // 269: hyapp.activity.v1.MessageInboxService.MarkInboxMessageRead:output_type -> hyapp.activity.v1.MarkInboxMessageReadResponse
14, // 270: hyapp.activity.v1.MessageInboxService.MarkInboxSectionRead:output_type -> hyapp.activity.v1.MarkInboxSectionReadResponse
16, // 271: hyapp.activity.v1.MessageInboxService.DeleteInboxMessage:output_type -> hyapp.activity.v1.DeleteInboxMessageResponse
18, // 272: hyapp.activity.v1.MessageInboxService.CreateInboxMessage:output_type -> hyapp.activity.v1.CreateInboxMessageResponse
20, // 273: hyapp.activity.v1.MessageInboxService.CreateFanoutJob:output_type -> hyapp.activity.v1.CreateFanoutJobResponse
22, // 274: hyapp.activity.v1.ActivityCronService.ProcessMessageFanoutBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 275: hyapp.activity.v1.ActivityCronService.ProcessLevelRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 276: hyapp.activity.v1.ActivityCronService.ProcessAchievementRewardBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 277: hyapp.activity.v1.ActivityCronService.ProcessRoomTurnoverRewardSettlementBatch:output_type -> hyapp.activity.v1.CronBatchResponse
22, // 278: hyapp.activity.v1.ActivityCronService.ProcessWeeklyStarSettlementBatch:output_type -> hyapp.activity.v1.CronBatchResponse
26, // 279: hyapp.activity.v1.TaskService.ListUserTasks:output_type -> hyapp.activity.v1.ListUserTasksResponse
28, // 280: hyapp.activity.v1.TaskService.ClaimTaskReward:output_type -> hyapp.activity.v1.ClaimTaskRewardResponse
30, // 281: hyapp.activity.v1.TaskService.ConsumeTaskEvent:output_type -> hyapp.activity.v1.ConsumeTaskEventResponse
124, // 282: hyapp.activity.v1.GrowthLevelService.GetMyLevelOverview:output_type -> hyapp.activity.v1.GetMyLevelOverviewResponse
126, // 283: hyapp.activity.v1.GrowthLevelService.GetLevelTrack:output_type -> hyapp.activity.v1.GetLevelTrackResponse
130, // 284: hyapp.activity.v1.GrowthLevelService.BatchGetUserLevelDisplayProfiles:output_type -> hyapp.activity.v1.BatchGetUserLevelDisplayProfilesResponse
133, // 285: hyapp.activity.v1.GrowthLevelService.ListLevelRewards:output_type -> hyapp.activity.v1.ListLevelRewardsResponse
135, // 286: hyapp.activity.v1.GrowthLevelService.ConsumeLevelEvent:output_type -> hyapp.activity.v1.ConsumeLevelEventResponse
137, // 287: hyapp.activity.v1.GrowthLevelService.SetUserLevel:output_type -> hyapp.activity.v1.SetUserLevelResponse
140, // 288: hyapp.activity.v1.GrowthLevelService.IssueRegistrationLevelBadges:output_type -> hyapp.activity.v1.IssueRegistrationLevelBadgesResponse
153, // 289: hyapp.activity.v1.AchievementService.ListAchievements:output_type -> hyapp.activity.v1.ListAchievementsResponse
155, // 290: hyapp.activity.v1.AchievementService.ConsumeAchievementEvent:output_type -> hyapp.activity.v1.ConsumeAchievementEventResponse
158, // 291: hyapp.activity.v1.AchievementService.ListMyBadges:output_type -> hyapp.activity.v1.ListMyBadgesResponse
160, // 292: hyapp.activity.v1.AchievementService.SetBadgeDisplay:output_type -> hyapp.activity.v1.SetBadgeDisplayResponse
170, // 293: hyapp.activity.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.activity.v1.CheckLuckyGiftResponse
173, // 294: hyapp.activity.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.activity.v1.ExecuteLuckyGiftDrawResponse
96, // 295: hyapp.activity.v1.RoomTurnoverRewardService.GetRoomTurnoverRewardStatus:output_type -> hyapp.activity.v1.GetRoomTurnoverRewardStatusResponse
203, // 296: hyapp.activity.v1.WeeklyStarService.GetWeeklyStarCurrent:output_type -> hyapp.activity.v1.GetWeeklyStarCurrentResponse
199, // 297: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarLeaderboard:output_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardResponse
206, // 298: hyapp.activity.v1.WeeklyStarService.ListWeeklyStarHistory:output_type -> hyapp.activity.v1.ListWeeklyStarHistoryResponse
33, // 299: hyapp.activity.v1.BroadcastService.EnsureBroadcastGroups:output_type -> hyapp.activity.v1.EnsureBroadcastGroupsResponse
36, // 300: hyapp.activity.v1.BroadcastService.PublishRegionBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse
36, // 301: hyapp.activity.v1.BroadcastService.PublishGlobalBroadcast:output_type -> hyapp.activity.v1.PublishBroadcastResponse
38, // 302: hyapp.activity.v1.BroadcastService.RemoveRegionBroadcastMember:output_type -> hyapp.activity.v1.RemoveRegionBroadcastMemberResponse
39, // 303: hyapp.activity.v1.BroadcastService.ProcessBroadcastOutboxBatch:output_type -> hyapp.activity.v1.ProcessBroadcastOutboxBatchResponse
41, // 304: hyapp.activity.v1.RoomEventConsumerService.ConsumeRoomEvent:output_type -> hyapp.activity.v1.ConsumeRoomEventResponse
44, // 305: hyapp.activity.v1.AdminTaskService.ListTaskDefinitions:output_type -> hyapp.activity.v1.ListTaskDefinitionsResponse
46, // 306: hyapp.activity.v1.AdminTaskService.UpsertTaskDefinition:output_type -> hyapp.activity.v1.UpsertTaskDefinitionResponse
48, // 307: hyapp.activity.v1.AdminTaskService.SetTaskDefinitionStatus:output_type -> hyapp.activity.v1.SetTaskDefinitionStatusResponse
53, // 308: hyapp.activity.v1.RegistrationRewardService.GetRegistrationRewardEligibility:output_type -> hyapp.activity.v1.GetRegistrationRewardEligibilityResponse
55, // 309: hyapp.activity.v1.RegistrationRewardService.IssueRegistrationReward:output_type -> hyapp.activity.v1.IssueRegistrationRewardResponse
57, // 310: hyapp.activity.v1.AdminRegistrationRewardService.GetRegistrationRewardConfig:output_type -> hyapp.activity.v1.GetRegistrationRewardConfigResponse
59, // 311: hyapp.activity.v1.AdminRegistrationRewardService.UpdateRegistrationRewardConfig:output_type -> hyapp.activity.v1.UpdateRegistrationRewardConfigResponse
61, // 312: hyapp.activity.v1.AdminRegistrationRewardService.ListRegistrationRewardClaims:output_type -> hyapp.activity.v1.ListRegistrationRewardClaimsResponse
67, // 313: hyapp.activity.v1.FirstRechargeRewardService.GetFirstRechargeRewardStatus:output_type -> hyapp.activity.v1.GetFirstRechargeRewardStatusResponse
69, // 314: hyapp.activity.v1.FirstRechargeRewardService.ConsumeFirstRechargeReward:output_type -> hyapp.activity.v1.ConsumeFirstRechargeRewardResponse
71, // 315: hyapp.activity.v1.AdminFirstRechargeRewardService.GetFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.GetFirstRechargeRewardConfigResponse
73, // 316: hyapp.activity.v1.AdminFirstRechargeRewardService.UpdateFirstRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateFirstRechargeRewardConfigResponse
75, // 317: hyapp.activity.v1.AdminFirstRechargeRewardService.ListFirstRechargeRewardClaims:output_type -> hyapp.activity.v1.ListFirstRechargeRewardClaimsResponse
82, // 318: hyapp.activity.v1.CumulativeRechargeRewardService.GetCumulativeRechargeRewardStatus:output_type -> hyapp.activity.v1.GetCumulativeRechargeRewardStatusResponse
84, // 319: hyapp.activity.v1.CumulativeRechargeRewardService.ConsumeCumulativeRechargeReward:output_type -> hyapp.activity.v1.ConsumeCumulativeRechargeRewardResponse
86, // 320: hyapp.activity.v1.AdminCumulativeRechargeRewardService.GetCumulativeRechargeRewardConfig:output_type -> hyapp.activity.v1.GetCumulativeRechargeRewardConfigResponse
88, // 321: hyapp.activity.v1.AdminCumulativeRechargeRewardService.UpdateCumulativeRechargeRewardConfig:output_type -> hyapp.activity.v1.UpdateCumulativeRechargeRewardConfigResponse
90, // 322: hyapp.activity.v1.AdminCumulativeRechargeRewardService.ListCumulativeRechargeRewardGrants:output_type -> hyapp.activity.v1.ListCumulativeRechargeRewardGrantsResponse
98, // 323: hyapp.activity.v1.AdminRoomTurnoverRewardService.GetRoomTurnoverRewardConfig:output_type -> hyapp.activity.v1.GetRoomTurnoverRewardConfigResponse
100, // 324: hyapp.activity.v1.AdminRoomTurnoverRewardService.UpdateRoomTurnoverRewardConfig:output_type -> hyapp.activity.v1.UpdateRoomTurnoverRewardConfigResponse
102, // 325: hyapp.activity.v1.AdminRoomTurnoverRewardService.ListRoomTurnoverRewardSettlements:output_type -> hyapp.activity.v1.ListRoomTurnoverRewardSettlementsResponse
104, // 326: hyapp.activity.v1.AdminRoomTurnoverRewardService.RetryRoomTurnoverRewardSettlement:output_type -> hyapp.activity.v1.RetryRoomTurnoverRewardSettlementResponse
191, // 327: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarCycles:output_type -> hyapp.activity.v1.ListWeeklyStarCyclesResponse
195, // 328: hyapp.activity.v1.AdminWeeklyStarService.CreateWeeklyStarCycle:output_type -> hyapp.activity.v1.UpsertWeeklyStarCycleResponse
193, // 329: hyapp.activity.v1.AdminWeeklyStarService.GetWeeklyStarCycle:output_type -> hyapp.activity.v1.GetWeeklyStarCycleResponse
195, // 330: hyapp.activity.v1.AdminWeeklyStarService.UpdateWeeklyStarCycle:output_type -> hyapp.activity.v1.UpsertWeeklyStarCycleResponse
197, // 331: hyapp.activity.v1.AdminWeeklyStarService.SetWeeklyStarCycleStatus:output_type -> hyapp.activity.v1.SetWeeklyStarCycleStatusResponse
199, // 332: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarLeaderboard:output_type -> hyapp.activity.v1.ListWeeklyStarLeaderboardResponse
201, // 333: hyapp.activity.v1.AdminWeeklyStarService.ListWeeklyStarSettlements:output_type -> hyapp.activity.v1.ListWeeklyStarSettlementsResponse
109, // 334: hyapp.activity.v1.SevenDayCheckInService.GetSevenDayCheckInStatus:output_type -> hyapp.activity.v1.GetSevenDayCheckInStatusResponse
111, // 335: hyapp.activity.v1.SevenDayCheckInService.SignSevenDayCheckIn:output_type -> hyapp.activity.v1.SignSevenDayCheckInResponse
113, // 336: hyapp.activity.v1.AdminSevenDayCheckInService.GetSevenDayCheckInConfig:output_type -> hyapp.activity.v1.GetSevenDayCheckInConfigResponse
115, // 337: hyapp.activity.v1.AdminSevenDayCheckInService.UpdateSevenDayCheckInConfig:output_type -> hyapp.activity.v1.UpdateSevenDayCheckInConfigResponse
118, // 338: hyapp.activity.v1.AdminSevenDayCheckInService.ListSevenDayCheckInClaims:output_type -> hyapp.activity.v1.ListSevenDayCheckInClaimsResponse
148, // 339: hyapp.activity.v1.AdminGrowthLevelService.ListLevelConfig:output_type -> hyapp.activity.v1.ListLevelConfigResponse
142, // 340: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTrack:output_type -> hyapp.activity.v1.UpsertLevelTrackResponse
144, // 341: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelRule:output_type -> hyapp.activity.v1.UpsertLevelRuleResponse
146, // 342: hyapp.activity.v1.AdminGrowthLevelService.UpsertLevelTier:output_type -> hyapp.activity.v1.UpsertLevelTierResponse
153, // 343: hyapp.activity.v1.AdminAchievementService.ListAchievementDefinitions:output_type -> hyapp.activity.v1.ListAchievementsResponse
162, // 344: hyapp.activity.v1.AdminAchievementService.UpsertAchievementDefinition:output_type -> hyapp.activity.v1.UpsertAchievementDefinitionResponse
175, // 345: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.activity.v1.GetLuckyGiftConfigResponse
177, // 346: hyapp.activity.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.activity.v1.UpsertLuckyGiftConfigResponse
179, // 347: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.activity.v1.ListLuckyGiftConfigsResponse
181, // 348: hyapp.activity.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.activity.v1.ListLuckyGiftDrawsResponse
184, // 349: hyapp.activity.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.activity.v1.GetLuckyGiftDrawSummaryResponse
265, // [265:350] is the sub-list for method output_type
180, // [180:265] is the sub-list for method input_type
180, // [180:180] is the sub-list for extension type_name
180, // [180:180] is the sub-list for extension extendee
0, // [0:180] is the sub-list for field type_name
}
func init() { file_proto_activity_v1_activity_proto_init() }

View File

@ -534,7 +534,7 @@ message ListRegistrationRewardClaimsResponse {
int64 today_claimed_count = 3;
}
// FirstRechargeRewardTier max_coin_amount=0
// FirstRechargeRewardTier Google
message FirstRechargeRewardTier {
int64 tier_id = 1;
string tier_code = 2;
@ -546,6 +546,9 @@ message FirstRechargeRewardTier {
int32 sort_order = 8;
int64 created_at_ms = 9;
int64 updated_at_ms = 10;
int64 usd_minor_amount = 11;
string google_product_id = 12;
int32 discount_percent = 13;
}
// FirstRechargeRewardConfig App activity-service
@ -558,7 +561,7 @@ message FirstRechargeRewardConfig {
int64 updated_at_ms = 6;
}
// FirstRechargeRewardClaim user_id
// FirstRechargeRewardClaim
message FirstRechargeRewardClaim {
string claim_id = 1;
string event_id = 2;
@ -579,6 +582,9 @@ message FirstRechargeRewardClaim {
int64 granted_at_ms = 17;
int64 created_at_ms = 18;
int64 updated_at_ms = 19;
int64 tier_usd_minor_amount = 20;
string google_product_id = 21;
int64 recharge_usd_minor = 22;
}
// FirstRechargeRewardStatus App
@ -588,6 +594,7 @@ message FirstRechargeRewardStatus {
bool rewarded = 3;
FirstRechargeRewardClaim claim = 4;
int64 server_time_ms = 5;
repeated FirstRechargeRewardClaim claims = 6;
}
message GetFirstRechargeRewardStatusRequest {
@ -599,7 +606,7 @@ message GetFirstRechargeRewardStatusResponse {
FirstRechargeRewardStatus status = 1;
}
// ConsumeFirstRechargeRewardRequest wallet recharge_sequence=1
// ConsumeFirstRechargeRewardRequest wallet Google ID
message ConsumeFirstRechargeRewardRequest {
RequestMeta meta = 1;
string event_id = 2;
@ -610,6 +617,8 @@ message ConsumeFirstRechargeRewardRequest {
int64 recharge_sequence = 7;
string recharge_type = 8;
int64 occurred_at_ms = 9;
int64 recharge_usd_minor = 10;
string google_product_id = 11;
}
message ConsumeFirstRechargeRewardResponse {

View File

@ -1208,6 +1208,7 @@ type RoomGiftSent struct {
GiftIconUrl string `protobuf:"bytes,16,opt,name=gift_icon_url,json=giftIconUrl,proto3" json:"gift_icon_url,omitempty"`
GiftAnimationUrl string `protobuf:"bytes,17,opt,name=gift_animation_url,json=giftAnimationUrl,proto3" json:"gift_animation_url,omitempty"`
TargetGiftValue int64 `protobuf:"varint,18,opt,name=target_gift_value,json=targetGiftValue,proto3" json:"target_gift_value,omitempty"`
GiftEffectTypes []string `protobuf:"bytes,19,rep,name=gift_effect_types,json=giftEffectTypes,proto3" json:"gift_effect_types,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1368,6 +1369,13 @@ func (x *RoomGiftSent) GetTargetGiftValue() int64 {
return 0
}
func (x *RoomGiftSent) GetGiftEffectTypes() []string {
if x != nil {
return x.GiftEffectTypes
}
return nil
}
// RoomHeatChanged 表达热度变化结果。
type RoomHeatChanged struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -2152,7 +2160,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
"\x10RoomUserUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x89\x05\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xb5\x05\n" +
"\fRoomGiftSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -2177,7 +2185,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\tgift_name\x18\x0f \x01(\tR\bgiftName\x12\"\n" +
"\rgift_icon_url\x18\x10 \x01(\tR\vgiftIconUrl\x12,\n" +
"\x12gift_animation_url\x18\x11 \x01(\tR\x10giftAnimationUrl\x12*\n" +
"\x11target_gift_value\x18\x12 \x01(\x03R\x0ftargetGiftValue\"J\n" +
"\x11target_gift_value\x18\x12 \x01(\x03R\x0ftargetGiftValue\x12*\n" +
"\x11gift_effect_types\x18\x13 \x03(\tR\x0fgiftEffectTypes\"J\n" +
"\x0fRoomHeatChanged\x12\x14\n" +
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +

View File

@ -165,6 +165,7 @@ message RoomGiftSent {
string gift_icon_url = 16;
string gift_animation_url = 17;
int64 target_gift_value = 18;
repeated string gift_effect_types = 19;
}
// RoomHeatChanged

File diff suppressed because it is too large Load Diff

View File

@ -130,6 +130,131 @@ message LaunchGameResponse {
int32 safe_height = 6;
}
message DiceParticipant {
int64 user_id = 1;
int32 seat_no = 2;
string status = 3;
int64 stake_coin = 4;
repeated int32 dice_points = 5;
string result = 6;
int64 payout_coin = 7;
int64 balance_after = 8;
int64 joined_at_ms = 9;
int64 updated_at_ms = 10;
string participant_type = 11;
bool is_robot = 12;
}
message DiceMatch {
string app_code = 1;
string match_id = 2;
string game_id = 3;
string room_id = 4;
int64 region_id = 5;
int32 min_players = 6;
int32 max_players = 7;
int32 current_players = 8;
int64 stake_coin = 9;
int32 round_no = 10;
string status = 11;
string result = 12;
repeated DiceParticipant participants = 13;
int64 join_deadline_ms = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
int64 settled_at_ms = 17;
string phase = 18;
int64 phase_deadline_ms = 19;
int32 fee_bps = 20;
int32 pool_bps = 21;
string match_mode = 22;
string forced_result = 23;
int64 ready_at_ms = 24;
int64 canceled_at_ms = 25;
int64 pool_delta_coin = 26;
}
message DiceStakeOption {
int64 stake_coin = 1;
bool enabled = 2;
int32 sort_order = 3;
}
message DiceConfig {
string app_code = 1;
string game_id = 2;
string status = 3;
repeated DiceStakeOption stake_options = 4;
int32 fee_bps = 5;
int32 pool_bps = 6;
int32 min_players = 7;
int32 max_players = 8;
bool robot_enabled = 9;
int64 robot_match_wait_ms = 10;
int64 pool_balance_coin = 11;
int64 created_at_ms = 12;
int64 updated_at_ms = 13;
}
message GetDiceConfigRequest {
RequestMeta meta = 1;
string game_id = 2;
}
message DiceConfigResponse {
DiceConfig config = 1;
int64 server_time_ms = 2;
}
message CreateDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string game_id = 3;
string room_id = 4;
int64 region_id = 5;
int64 stake_coin = 6;
int32 min_players = 7;
int32 max_players = 8;
}
message JoinDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
}
message GetDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
}
message RollDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
}
message MatchDiceRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string game_id = 3;
string room_id = 4;
int64 region_id = 5;
int64 stake_coin = 6;
}
message CancelDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
}
message DiceMatchResponse {
DiceMatch match = 1;
int64 server_time_ms = 2;
}
message CallbackRequest {
RequestMeta meta = 1;
string platform_code = 2;
@ -220,11 +345,114 @@ message DeleteCatalogResponse {
int64 server_time_ms = 1;
}
message ListSelfGamesRequest {
RequestMeta meta = 1;
}
message ListSelfGamesResponse {
repeated DiceConfig games = 1;
int64 server_time_ms = 2;
}
message UpdateDiceConfigRequest {
RequestMeta meta = 1;
DiceConfig config = 2;
}
message DicePoolAdjustment {
string adjustment_id = 1;
string game_id = 2;
int64 amount_coin = 3;
string direction = 4;
string reason = 5;
int64 balance_after = 6;
int64 created_at_ms = 7;
}
message AdjustDicePoolRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 amount_coin = 3;
string direction = 4;
string reason = 5;
}
message AdjustDicePoolResponse {
DicePoolAdjustment adjustment = 1;
DiceConfig config = 2;
int64 server_time_ms = 3;
}
message DiceRobot {
string app_code = 1;
string game_id = 2;
int64 user_id = 3;
string status = 4;
int64 created_by_admin_id = 5;
int64 created_at_ms = 6;
int64 updated_at_ms = 7;
}
message ListDiceRobotsRequest {
RequestMeta meta = 1;
string game_id = 2;
string status = 3;
int32 page_size = 4;
string cursor = 5;
}
message ListDiceRobotsResponse {
repeated DiceRobot robots = 1;
string next_cursor = 2;
int64 server_time_ms = 3;
}
message RegisterDiceRobotsRequest {
RequestMeta meta = 1;
string game_id = 2;
repeated int64 user_ids = 3;
}
message RegisterDiceRobotsResponse {
repeated DiceRobot robots = 1;
int64 server_time_ms = 2;
}
message SetDiceRobotStatusRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
string status = 4;
}
message DeleteDiceRobotRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message DeleteDiceRobotResponse {
bool deleted = 1;
int64 server_time_ms = 2;
}
message DiceRobotResponse {
DiceRobot robot = 1;
int64 server_time_ms = 2;
}
service GameAppService {
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
rpc ListRecentGames(ListRecentGamesRequest) returns (ListGamesResponse);
rpc GetBridgeScript(GetBridgeScriptRequest) returns (GetBridgeScriptResponse);
rpc LaunchGame(LaunchGameRequest) returns (LaunchGameResponse);
rpc GetDiceConfig(GetDiceConfigRequest) returns (DiceConfigResponse);
rpc MatchDice(MatchDiceRequest) returns (DiceMatchResponse);
rpc CreateDiceMatch(CreateDiceMatchRequest) returns (DiceMatchResponse);
rpc JoinDiceMatch(JoinDiceMatchRequest) returns (DiceMatchResponse);
rpc GetDiceMatch(GetDiceMatchRequest) returns (DiceMatchResponse);
rpc RollDiceMatch(RollDiceMatchRequest) returns (DiceMatchResponse);
rpc CancelDiceMatch(CancelDiceMatchRequest) returns (DiceMatchResponse);
}
service GameCallbackService {
@ -243,4 +471,11 @@ service GameAdminService {
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
rpc ListSelfGames(ListSelfGamesRequest) returns (ListSelfGamesResponse);
rpc UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse);
rpc AdjustDicePool(AdjustDicePoolRequest) returns (AdjustDicePoolResponse);
rpc ListDiceRobots(ListDiceRobotsRequest) returns (ListDiceRobotsResponse);
rpc RegisterDiceRobots(RegisterDiceRobotsRequest) returns (RegisterDiceRobotsResponse);
rpc SetDiceRobotStatus(SetDiceRobotStatusRequest) returns (DiceRobotResponse);
rpc DeleteDiceRobot(DeleteDiceRobotRequest) returns (DeleteDiceRobotResponse);
}

View File

@ -23,6 +23,13 @@ const (
GameAppService_ListRecentGames_FullMethodName = "/hyapp.game.v1.GameAppService/ListRecentGames"
GameAppService_GetBridgeScript_FullMethodName = "/hyapp.game.v1.GameAppService/GetBridgeScript"
GameAppService_LaunchGame_FullMethodName = "/hyapp.game.v1.GameAppService/LaunchGame"
GameAppService_GetDiceConfig_FullMethodName = "/hyapp.game.v1.GameAppService/GetDiceConfig"
GameAppService_MatchDice_FullMethodName = "/hyapp.game.v1.GameAppService/MatchDice"
GameAppService_CreateDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/CreateDiceMatch"
GameAppService_JoinDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/JoinDiceMatch"
GameAppService_GetDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/GetDiceMatch"
GameAppService_RollDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/RollDiceMatch"
GameAppService_CancelDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/CancelDiceMatch"
)
// GameAppServiceClient is the client API for GameAppService service.
@ -33,6 +40,13 @@ type GameAppServiceClient interface {
ListRecentGames(ctx context.Context, in *ListRecentGamesRequest, opts ...grpc.CallOption) (*ListGamesResponse, error)
GetBridgeScript(ctx context.Context, in *GetBridgeScriptRequest, opts ...grpc.CallOption) (*GetBridgeScriptResponse, error)
LaunchGame(ctx context.Context, in *LaunchGameRequest, opts ...grpc.CallOption) (*LaunchGameResponse, error)
GetDiceConfig(ctx context.Context, in *GetDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error)
MatchDice(ctx context.Context, in *MatchDiceRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
CreateDiceMatch(ctx context.Context, in *CreateDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
JoinDiceMatch(ctx context.Context, in *JoinDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
GetDiceMatch(ctx context.Context, in *GetDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
RollDiceMatch(ctx context.Context, in *RollDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
CancelDiceMatch(ctx context.Context, in *CancelDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
}
type gameAppServiceClient struct {
@ -83,6 +97,76 @@ func (c *gameAppServiceClient) LaunchGame(ctx context.Context, in *LaunchGameReq
return out, nil
}
func (c *gameAppServiceClient) GetDiceConfig(ctx context.Context, in *GetDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceConfigResponse)
err := c.cc.Invoke(ctx, GameAppService_GetDiceConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) MatchDice(ctx context.Context, in *MatchDiceRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceMatchResponse)
err := c.cc.Invoke(ctx, GameAppService_MatchDice_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) CreateDiceMatch(ctx context.Context, in *CreateDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceMatchResponse)
err := c.cc.Invoke(ctx, GameAppService_CreateDiceMatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) JoinDiceMatch(ctx context.Context, in *JoinDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceMatchResponse)
err := c.cc.Invoke(ctx, GameAppService_JoinDiceMatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) GetDiceMatch(ctx context.Context, in *GetDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceMatchResponse)
err := c.cc.Invoke(ctx, GameAppService_GetDiceMatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) RollDiceMatch(ctx context.Context, in *RollDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceMatchResponse)
err := c.cc.Invoke(ctx, GameAppService_RollDiceMatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) CancelDiceMatch(ctx context.Context, in *CancelDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceMatchResponse)
err := c.cc.Invoke(ctx, GameAppService_CancelDiceMatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameAppServiceServer is the server API for GameAppService service.
// All implementations must embed UnimplementedGameAppServiceServer
// for forward compatibility.
@ -91,6 +175,13 @@ type GameAppServiceServer interface {
ListRecentGames(context.Context, *ListRecentGamesRequest) (*ListGamesResponse, error)
GetBridgeScript(context.Context, *GetBridgeScriptRequest) (*GetBridgeScriptResponse, error)
LaunchGame(context.Context, *LaunchGameRequest) (*LaunchGameResponse, error)
GetDiceConfig(context.Context, *GetDiceConfigRequest) (*DiceConfigResponse, error)
MatchDice(context.Context, *MatchDiceRequest) (*DiceMatchResponse, error)
CreateDiceMatch(context.Context, *CreateDiceMatchRequest) (*DiceMatchResponse, error)
JoinDiceMatch(context.Context, *JoinDiceMatchRequest) (*DiceMatchResponse, error)
GetDiceMatch(context.Context, *GetDiceMatchRequest) (*DiceMatchResponse, error)
RollDiceMatch(context.Context, *RollDiceMatchRequest) (*DiceMatchResponse, error)
CancelDiceMatch(context.Context, *CancelDiceMatchRequest) (*DiceMatchResponse, error)
mustEmbedUnimplementedGameAppServiceServer()
}
@ -113,6 +204,27 @@ func (UnimplementedGameAppServiceServer) GetBridgeScript(context.Context, *GetBr
func (UnimplementedGameAppServiceServer) LaunchGame(context.Context, *LaunchGameRequest) (*LaunchGameResponse, error) {
return nil, status.Error(codes.Unimplemented, "method LaunchGame not implemented")
}
func (UnimplementedGameAppServiceServer) GetDiceConfig(context.Context, *GetDiceConfigRequest) (*DiceConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetDiceConfig not implemented")
}
func (UnimplementedGameAppServiceServer) MatchDice(context.Context, *MatchDiceRequest) (*DiceMatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method MatchDice not implemented")
}
func (UnimplementedGameAppServiceServer) CreateDiceMatch(context.Context, *CreateDiceMatchRequest) (*DiceMatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateDiceMatch not implemented")
}
func (UnimplementedGameAppServiceServer) JoinDiceMatch(context.Context, *JoinDiceMatchRequest) (*DiceMatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method JoinDiceMatch not implemented")
}
func (UnimplementedGameAppServiceServer) GetDiceMatch(context.Context, *GetDiceMatchRequest) (*DiceMatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetDiceMatch not implemented")
}
func (UnimplementedGameAppServiceServer) RollDiceMatch(context.Context, *RollDiceMatchRequest) (*DiceMatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RollDiceMatch not implemented")
}
func (UnimplementedGameAppServiceServer) CancelDiceMatch(context.Context, *CancelDiceMatchRequest) (*DiceMatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CancelDiceMatch not implemented")
}
func (UnimplementedGameAppServiceServer) mustEmbedUnimplementedGameAppServiceServer() {}
func (UnimplementedGameAppServiceServer) testEmbeddedByValue() {}
@ -206,6 +318,132 @@ func _GameAppService_LaunchGame_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _GameAppService_GetDiceConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiceConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).GetDiceConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_GetDiceConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).GetDiceConfig(ctx, req.(*GetDiceConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_MatchDice_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MatchDiceRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).MatchDice(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_MatchDice_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).MatchDice(ctx, req.(*MatchDiceRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_CreateDiceMatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateDiceMatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).CreateDiceMatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_CreateDiceMatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).CreateDiceMatch(ctx, req.(*CreateDiceMatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_JoinDiceMatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JoinDiceMatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).JoinDiceMatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_JoinDiceMatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).JoinDiceMatch(ctx, req.(*JoinDiceMatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_GetDiceMatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiceMatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).GetDiceMatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_GetDiceMatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).GetDiceMatch(ctx, req.(*GetDiceMatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_RollDiceMatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RollDiceMatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).RollDiceMatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_RollDiceMatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).RollDiceMatch(ctx, req.(*RollDiceMatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_CancelDiceMatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CancelDiceMatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).CancelDiceMatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_CancelDiceMatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).CancelDiceMatch(ctx, req.(*CancelDiceMatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameAppService_ServiceDesc is the grpc.ServiceDesc for GameAppService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -229,6 +467,34 @@ var GameAppService_ServiceDesc = grpc.ServiceDesc{
MethodName: "LaunchGame",
Handler: _GameAppService_LaunchGame_Handler,
},
{
MethodName: "GetDiceConfig",
Handler: _GameAppService_GetDiceConfig_Handler,
},
{
MethodName: "MatchDice",
Handler: _GameAppService_MatchDice_Handler,
},
{
MethodName: "CreateDiceMatch",
Handler: _GameAppService_CreateDiceMatch_Handler,
},
{
MethodName: "JoinDiceMatch",
Handler: _GameAppService_JoinDiceMatch_Handler,
},
{
MethodName: "GetDiceMatch",
Handler: _GameAppService_GetDiceMatch_Handler,
},
{
MethodName: "RollDiceMatch",
Handler: _GameAppService_RollDiceMatch_Handler,
},
{
MethodName: "CancelDiceMatch",
Handler: _GameAppService_CancelDiceMatch_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/game/v1/game.proto",
@ -443,12 +709,19 @@ var GameCronService_ServiceDesc = grpc.ServiceDesc{
}
const (
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
GameAdminService_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
GameAdminService_AdjustDicePool_FullMethodName = "/hyapp.game.v1.GameAdminService/AdjustDicePool"
GameAdminService_ListDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/ListDiceRobots"
GameAdminService_RegisterDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/RegisterDiceRobots"
GameAdminService_SetDiceRobotStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetDiceRobotStatus"
GameAdminService_DeleteDiceRobot_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteDiceRobot"
)
// GameAdminServiceClient is the client API for GameAdminService service.
@ -461,6 +734,13 @@ type GameAdminServiceClient interface {
UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error)
ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error)
AdjustDicePool(ctx context.Context, in *AdjustDicePoolRequest, opts ...grpc.CallOption) (*AdjustDicePoolResponse, error)
ListDiceRobots(ctx context.Context, in *ListDiceRobotsRequest, opts ...grpc.CallOption) (*ListDiceRobotsResponse, error)
RegisterDiceRobots(ctx context.Context, in *RegisterDiceRobotsRequest, opts ...grpc.CallOption) (*RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(ctx context.Context, in *SetDiceRobotStatusRequest, opts ...grpc.CallOption) (*DiceRobotResponse, error)
DeleteDiceRobot(ctx context.Context, in *DeleteDiceRobotRequest, opts ...grpc.CallOption) (*DeleteDiceRobotResponse, error)
}
type gameAdminServiceClient struct {
@ -531,6 +811,76 @@ func (c *gameAdminServiceClient) DeleteCatalog(ctx context.Context, in *DeleteCa
return out, nil
}
func (c *gameAdminServiceClient) ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSelfGamesResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListSelfGames_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceConfigResponse)
err := c.cc.Invoke(ctx, GameAdminService_UpdateDiceConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) AdjustDicePool(ctx context.Context, in *AdjustDicePoolRequest, opts ...grpc.CallOption) (*AdjustDicePoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdjustDicePoolResponse)
err := c.cc.Invoke(ctx, GameAdminService_AdjustDicePool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListDiceRobots(ctx context.Context, in *ListDiceRobotsRequest, opts ...grpc.CallOption) (*ListDiceRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListDiceRobotsResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListDiceRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) RegisterDiceRobots(ctx context.Context, in *RegisterDiceRobotsRequest, opts ...grpc.CallOption) (*RegisterDiceRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RegisterDiceRobotsResponse)
err := c.cc.Invoke(ctx, GameAdminService_RegisterDiceRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) SetDiceRobotStatus(ctx context.Context, in *SetDiceRobotStatusRequest, opts ...grpc.CallOption) (*DiceRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiceRobotResponse)
err := c.cc.Invoke(ctx, GameAdminService_SetDiceRobotStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) DeleteDiceRobot(ctx context.Context, in *DeleteDiceRobotRequest, opts ...grpc.CallOption) (*DeleteDiceRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteDiceRobotResponse)
err := c.cc.Invoke(ctx, GameAdminService_DeleteDiceRobot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameAdminServiceServer is the server API for GameAdminService service.
// All implementations must embed UnimplementedGameAdminServiceServer
// for forward compatibility.
@ -541,6 +891,13 @@ type GameAdminServiceServer interface {
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error)
DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error)
ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error)
UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error)
AdjustDicePool(context.Context, *AdjustDicePoolRequest) (*AdjustDicePoolResponse, error)
ListDiceRobots(context.Context, *ListDiceRobotsRequest) (*ListDiceRobotsResponse, error)
RegisterDiceRobots(context.Context, *RegisterDiceRobotsRequest) (*RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(context.Context, *SetDiceRobotStatusRequest) (*DiceRobotResponse, error)
DeleteDiceRobot(context.Context, *DeleteDiceRobotRequest) (*DeleteDiceRobotResponse, error)
mustEmbedUnimplementedGameAdminServiceServer()
}
@ -569,6 +926,27 @@ func (UnimplementedGameAdminServiceServer) SetGameStatus(context.Context, *SetGa
func (UnimplementedGameAdminServiceServer) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteCatalog not implemented")
}
func (UnimplementedGameAdminServiceServer) ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListSelfGames not implemented")
}
func (UnimplementedGameAdminServiceServer) UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateDiceConfig not implemented")
}
func (UnimplementedGameAdminServiceServer) AdjustDicePool(context.Context, *AdjustDicePoolRequest) (*AdjustDicePoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdjustDicePool not implemented")
}
func (UnimplementedGameAdminServiceServer) ListDiceRobots(context.Context, *ListDiceRobotsRequest) (*ListDiceRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListDiceRobots not implemented")
}
func (UnimplementedGameAdminServiceServer) RegisterDiceRobots(context.Context, *RegisterDiceRobotsRequest) (*RegisterDiceRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RegisterDiceRobots not implemented")
}
func (UnimplementedGameAdminServiceServer) SetDiceRobotStatus(context.Context, *SetDiceRobotStatusRequest) (*DiceRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetDiceRobotStatus not implemented")
}
func (UnimplementedGameAdminServiceServer) DeleteDiceRobot(context.Context, *DeleteDiceRobotRequest) (*DeleteDiceRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteDiceRobot not implemented")
}
func (UnimplementedGameAdminServiceServer) mustEmbedUnimplementedGameAdminServiceServer() {}
func (UnimplementedGameAdminServiceServer) testEmbeddedByValue() {}
@ -698,6 +1076,132 @@ func _GameAdminService_DeleteCatalog_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListSelfGames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSelfGamesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListSelfGames(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListSelfGames_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListSelfGames(ctx, req.(*ListSelfGamesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_UpdateDiceConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateDiceConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).UpdateDiceConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_UpdateDiceConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).UpdateDiceConfig(ctx, req.(*UpdateDiceConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_AdjustDicePool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdjustDicePoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).AdjustDicePool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_AdjustDicePool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).AdjustDicePool(ctx, req.(*AdjustDicePoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListDiceRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListDiceRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListDiceRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListDiceRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListDiceRobots(ctx, req.(*ListDiceRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_RegisterDiceRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterDiceRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).RegisterDiceRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_RegisterDiceRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).RegisterDiceRobots(ctx, req.(*RegisterDiceRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_SetDiceRobotStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetDiceRobotStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).SetDiceRobotStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_SetDiceRobotStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).SetDiceRobotStatus(ctx, req.(*SetDiceRobotStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_DeleteDiceRobot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteDiceRobotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).DeleteDiceRobot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_DeleteDiceRobot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).DeleteDiceRobot(ctx, req.(*DeleteDiceRobotRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameAdminService_ServiceDesc is the grpc.ServiceDesc for GameAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -729,6 +1233,34 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteCatalog",
Handler: _GameAdminService_DeleteCatalog_Handler,
},
{
MethodName: "ListSelfGames",
Handler: _GameAdminService_ListSelfGames_Handler,
},
{
MethodName: "UpdateDiceConfig",
Handler: _GameAdminService_UpdateDiceConfig_Handler,
},
{
MethodName: "AdjustDicePool",
Handler: _GameAdminService_AdjustDicePool_Handler,
},
{
MethodName: "ListDiceRobots",
Handler: _GameAdminService_ListDiceRobots_Handler,
},
{
MethodName: "RegisterDiceRobots",
Handler: _GameAdminService_RegisterDiceRobots_Handler,
},
{
MethodName: "SetDiceRobotStatus",
Handler: _GameAdminService_SetDiceRobotStatus_Handler,
},
{
MethodName: "DeleteDiceRobot",
Handler: _GameAdminService_DeleteDiceRobot_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/game/v1/game.proto",

View File

@ -1091,12 +1091,13 @@ func (x *AppHeartbeatRequest) GetSessionId() string {
return ""
}
// AppHeartbeatResponse 返回本次会话心跳写入时间
// AppHeartbeatResponse 返回本次会话心跳写入时间,并在当前会话仍有效时顺手重签 access token
type AppHeartbeatResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
HeartbeatAtMs int64 `protobuf:"varint,2,opt,name=heartbeat_at_ms,json=heartbeatAtMs,proto3" json:"heartbeat_at_ms,omitempty"`
ServerTimeMs int64 `protobuf:"varint,3,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
Token *AuthToken `protobuf:"bytes,4,opt,name=token,proto3" json:"token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1152,6 +1153,13 @@ func (x *AppHeartbeatResponse) GetServerTimeMs() int64 {
return 0
}
func (x *AppHeartbeatResponse) GetToken() *AuthToken {
if x != nil {
return x.Token
}
return nil
}
var File_proto_user_v1_auth_proto protoreflect.FileDescriptor
const file_proto_user_v1_auth_proto_rawDesc = "" +
@ -1251,11 +1259,12 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1d\n" +
"\n" +
"session_id\x18\x03 \x01(\tR\tsessionId\"\x80\x01\n" +
"session_id\x18\x03 \x01(\tR\tsessionId\"\xb0\x01\n" +
"\x14AppHeartbeatResponse\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12&\n" +
"\x0fheartbeat_at_ms\x18\x02 \x01(\x03R\rheartbeatAtMs\x12$\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs2\xdc\x05\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\x12.\n" +
"\x05token\x18\x04 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token2\xdc\x05\n" +
"\vAuthService\x12Q\n" +
"\rLoginPassword\x12#.hyapp.user.v1.LoginPasswordRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12U\n" +
"\x0fLoginThirdParty\x12%.hyapp.user.v1.LoginThirdPartyRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12T\n" +
@ -1310,27 +1319,28 @@ var file_proto_user_v1_auth_proto_depIdxs = []int32{
15, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 10: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 11: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 12: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 13: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 14: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 15: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 16: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 17: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 18: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 19: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 20: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 21: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 22: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 23: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 24: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 25: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 26: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
19, // [19:27] is the sub-list for method output_type
11, // [11:19] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
16, // 11: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 12: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 13: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 14: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 15: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 16: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 17: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 18: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 19: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 20: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 21: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 22: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 23: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 24: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 25: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 26: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 27: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
20, // [20:28] is the sub-list for method output_type
12, // [12:20] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }

View File

@ -131,11 +131,12 @@ message AppHeartbeatRequest {
string session_id = 3;
}
// AppHeartbeatResponse
// AppHeartbeatResponse access token
message AppHeartbeatResponse {
bool accepted = 1;
int64 heartbeat_at_ms = 2;
int64 server_time_ms = 3;
AuthToken token = 4;
}
// AuthService token

File diff suppressed because it is too large Load Diff

View File

@ -83,6 +83,8 @@ message User {
bool country_enabled = 26;
string app_code = 27;
InviteOverview invite = 28;
string pretty_id = 29;
string pretty_display_user_id = 30;
}
// InviteOverview read model
@ -903,6 +905,8 @@ message UserIdentity {
string display_user_id_kind = 5;
int64 display_user_id_expires_at_ms = 6;
string app_code = 7;
string pretty_id = 8;
string pretty_display_user_id = 9;
}
// GetUserIdentityRequest user_id
@ -968,6 +972,184 @@ message ExpirePrettyDisplayUserIDResponse {
UserIdentity identity = 1;
}
// PrettyDisplayIDPool
message PrettyDisplayIDPool {
string app_code = 1;
string pool_id = 2;
string name = 3;
string level_track = 4;
int32 min_level = 5;
int32 max_level = 6;
string rule_type = 7;
string rule_config_json = 8;
string status = 9;
int32 sort_order = 10;
int64 created_by_admin_id = 11;
int64 updated_by_admin_id = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
}
// PrettyDisplayID
message PrettyDisplayID {
string app_code = 1;
string pretty_id = 2;
string pool_id = 3;
string source = 4;
string display_user_id = 5;
string status = 6;
int64 assigned_user_id = 7;
string assigned_lease_id = 8;
int64 assigned_at_ms = 9;
int64 released_at_ms = 10;
string release_reason = 11;
string generated_batch_id = 12;
int64 created_by_admin_id = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
PrettyDisplayIDPool pool = 16;
}
message PrettyDisplayIDGenerationBatch {
string app_code = 1;
string batch_id = 2;
string pool_id = 3;
string rule_type = 4;
string rule_config_json = 5;
int32 requested_count = 6;
int32 generated_count = 7;
int32 skipped_conflict_count = 8;
string status = 9;
int64 operator_admin_id = 10;
string request_id = 11;
int64 created_at_ms = 12;
int64 updated_at_ms = 13;
}
message ListAvailablePrettyDisplayIDsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListAvailablePrettyDisplayIDsResponse {
repeated PrettyDisplayID items = 1;
int64 total = 2;
}
message ApplyPrettyDisplayIDFromPoolRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string pretty_id = 3;
}
message ApplyPrettyDisplayIDFromPoolResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
message ListPrettyDisplayIDPoolsRequest {
RequestMeta meta = 1;
string status = 2;
string level_track = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListPrettyDisplayIDPoolsResponse {
repeated PrettyDisplayIDPool items = 1;
int64 total = 2;
}
message CreatePrettyDisplayIDPoolRequest {
RequestMeta meta = 1;
string name = 2;
string level_track = 3;
int32 min_level = 4;
int32 max_level = 5;
string rule_type = 6;
string rule_config_json = 7;
string status = 8;
int32 sort_order = 9;
int64 operator_admin_id = 10;
}
message UpdatePrettyDisplayIDPoolRequest {
RequestMeta meta = 1;
string pool_id = 2;
string name = 3;
string level_track = 4;
int32 min_level = 5;
int32 max_level = 6;
string rule_type = 7;
string rule_config_json = 8;
string status = 9;
int32 sort_order = 10;
int64 operator_admin_id = 11;
}
message PrettyDisplayIDPoolResponse {
PrettyDisplayIDPool pool = 1;
}
message GeneratePrettyDisplayIDsRequest {
RequestMeta meta = 1;
string pool_id = 2;
string rule_type = 3;
string rule_config_json = 4;
int32 count = 5;
int64 operator_admin_id = 6;
}
message GeneratePrettyDisplayIDsResponse {
PrettyDisplayIDGenerationBatch batch = 1;
}
message ListPrettyDisplayIDsRequest {
RequestMeta meta = 1;
string pool_id = 2;
string source = 3;
string status = 4;
string keyword = 5;
int64 assigned_user_id = 6;
int32 page = 7;
int32 page_size = 8;
}
message ListPrettyDisplayIDsResponse {
repeated PrettyDisplayID items = 1;
int64 total = 2;
}
message SetPrettyDisplayIDStatusRequest {
RequestMeta meta = 1;
string pretty_id = 2;
string status = 3;
int64 operator_admin_id = 4;
string reason = 5;
}
message PrettyDisplayIDResponse {
PrettyDisplayID item = 1;
}
message AdminGrantPrettyDisplayIDRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string display_user_id = 3;
int64 duration_ms = 4;
string reason = 5;
int64 operator_admin_id = 6;
}
message AdminGrantPrettyDisplayIDResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
// UserService
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
@ -1064,5 +1246,18 @@ service UserIdentityService {
rpc ResolveDisplayUserID(ResolveDisplayUserIDRequest) returns (ResolveDisplayUserIDResponse);
rpc ChangeDisplayUserID(ChangeDisplayUserIDRequest) returns (ChangeDisplayUserIDResponse);
rpc ApplyPrettyDisplayUserID(ApplyPrettyDisplayUserIDRequest) returns (ApplyPrettyDisplayUserIDResponse);
rpc ListAvailablePrettyDisplayIDs(ListAvailablePrettyDisplayIDsRequest) returns (ListAvailablePrettyDisplayIDsResponse);
rpc ApplyPrettyDisplayIDFromPool(ApplyPrettyDisplayIDFromPoolRequest) returns (ApplyPrettyDisplayIDFromPoolResponse);
rpc ExpirePrettyDisplayUserID(ExpirePrettyDisplayUserIDRequest) returns (ExpirePrettyDisplayUserIDResponse);
}
// UserPrettyDisplayIDAdminService
service UserPrettyDisplayIDAdminService {
rpc ListPrettyDisplayIDPools(ListPrettyDisplayIDPoolsRequest) returns (ListPrettyDisplayIDPoolsResponse);
rpc CreatePrettyDisplayIDPool(CreatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc UpdatePrettyDisplayIDPool(UpdatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc GeneratePrettyDisplayIDs(GeneratePrettyDisplayIDsRequest) returns (GeneratePrettyDisplayIDsResponse);
rpc ListPrettyDisplayIDs(ListPrettyDisplayIDsRequest) returns (ListPrettyDisplayIDsResponse);
rpc SetPrettyDisplayIDStatus(SetPrettyDisplayIDStatusRequest) returns (PrettyDisplayIDResponse);
rpc AdminGrantPrettyDisplayID(AdminGrantPrettyDisplayIDRequest) returns (AdminGrantPrettyDisplayIDResponse);
}

View File

@ -2527,11 +2527,13 @@ var RegionAdminService_ServiceDesc = grpc.ServiceDesc{
}
const (
UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity"
UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID"
UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID"
UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID"
UserIdentityService_ExpirePrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ExpirePrettyDisplayUserID"
UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity"
UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID"
UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID"
UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID"
UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserIdentityService/ListAvailablePrettyDisplayIDs"
UserIdentityService_ApplyPrettyDisplayIDFromPool_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayIDFromPool"
UserIdentityService_ExpirePrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ExpirePrettyDisplayUserID"
)
// UserIdentityServiceClient is the client API for UserIdentityService service.
@ -2544,6 +2546,8 @@ type UserIdentityServiceClient interface {
ResolveDisplayUserID(ctx context.Context, in *ResolveDisplayUserIDRequest, opts ...grpc.CallOption) (*ResolveDisplayUserIDResponse, error)
ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(ctx context.Context, in *ApplyPrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error)
ApplyPrettyDisplayIDFromPool(ctx context.Context, in *ApplyPrettyDisplayIDFromPoolRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayIDFromPoolResponse, error)
ExpirePrettyDisplayUserID(ctx context.Context, in *ExpirePrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ExpirePrettyDisplayUserIDResponse, error)
}
@ -2595,6 +2599,26 @@ func (c *userIdentityServiceClient) ApplyPrettyDisplayUserID(ctx context.Context
return out, nil
}
func (c *userIdentityServiceClient) ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAvailablePrettyDisplayIDsResponse)
err := c.cc.Invoke(ctx, UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userIdentityServiceClient) ApplyPrettyDisplayIDFromPool(ctx context.Context, in *ApplyPrettyDisplayIDFromPoolRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayIDFromPoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyPrettyDisplayIDFromPoolResponse)
err := c.cc.Invoke(ctx, UserIdentityService_ApplyPrettyDisplayIDFromPool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userIdentityServiceClient) ExpirePrettyDisplayUserID(ctx context.Context, in *ExpirePrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ExpirePrettyDisplayUserIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExpirePrettyDisplayUserIDResponse)
@ -2615,6 +2639,8 @@ type UserIdentityServiceServer interface {
ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error)
ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error)
ApplyPrettyDisplayIDFromPool(context.Context, *ApplyPrettyDisplayIDFromPoolRequest) (*ApplyPrettyDisplayIDFromPoolResponse, error)
ExpirePrettyDisplayUserID(context.Context, *ExpirePrettyDisplayUserIDRequest) (*ExpirePrettyDisplayUserIDResponse, error)
mustEmbedUnimplementedUserIdentityServiceServer()
}
@ -2638,6 +2664,12 @@ func (UnimplementedUserIdentityServiceServer) ChangeDisplayUserID(context.Contex
func (UnimplementedUserIdentityServiceServer) ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyPrettyDisplayUserID not implemented")
}
func (UnimplementedUserIdentityServiceServer) ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAvailablePrettyDisplayIDs not implemented")
}
func (UnimplementedUserIdentityServiceServer) ApplyPrettyDisplayIDFromPool(context.Context, *ApplyPrettyDisplayIDFromPoolRequest) (*ApplyPrettyDisplayIDFromPoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyPrettyDisplayIDFromPool not implemented")
}
func (UnimplementedUserIdentityServiceServer) ExpirePrettyDisplayUserID(context.Context, *ExpirePrettyDisplayUserIDRequest) (*ExpirePrettyDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpirePrettyDisplayUserID not implemented")
}
@ -2734,6 +2766,42 @@ func _UserIdentityService_ApplyPrettyDisplayUserID_Handler(srv interface{}, ctx
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ListAvailablePrettyDisplayIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAvailablePrettyDisplayIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserIdentityServiceServer).ListAvailablePrettyDisplayIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserIdentityServiceServer).ListAvailablePrettyDisplayIDs(ctx, req.(*ListAvailablePrettyDisplayIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ApplyPrettyDisplayIDFromPool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyPrettyDisplayIDFromPoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserIdentityServiceServer).ApplyPrettyDisplayIDFromPool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserIdentityService_ApplyPrettyDisplayIDFromPool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserIdentityServiceServer).ApplyPrettyDisplayIDFromPool(ctx, req.(*ApplyPrettyDisplayIDFromPoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ExpirePrettyDisplayUserID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpirePrettyDisplayUserIDRequest)
if err := dec(in); err != nil {
@ -2775,6 +2843,14 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ApplyPrettyDisplayUserID",
Handler: _UserIdentityService_ApplyPrettyDisplayUserID_Handler,
},
{
MethodName: "ListAvailablePrettyDisplayIDs",
Handler: _UserIdentityService_ListAvailablePrettyDisplayIDs_Handler,
},
{
MethodName: "ApplyPrettyDisplayIDFromPool",
Handler: _UserIdentityService_ApplyPrettyDisplayIDFromPool_Handler,
},
{
MethodName: "ExpirePrettyDisplayUserID",
Handler: _UserIdentityService_ExpirePrettyDisplayUserID_Handler,
@ -2783,3 +2859,338 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}
const (
UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/ListPrettyDisplayIDPools"
UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/CreatePrettyDisplayIDPool"
UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/UpdatePrettyDisplayIDPool"
UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/GeneratePrettyDisplayIDs"
UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/ListPrettyDisplayIDs"
UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/SetPrettyDisplayIDStatus"
UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/AdminGrantPrettyDisplayID"
)
// UserPrettyDisplayIDAdminServiceClient is the client API for UserPrettyDisplayIDAdminService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// UserPrettyDisplayIDAdminService 承载后台靓号池和后台发放能力。
type UserPrettyDisplayIDAdminServiceClient interface {
ListPrettyDisplayIDPools(ctx context.Context, in *ListPrettyDisplayIDPoolsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDPoolsResponse, error)
CreatePrettyDisplayIDPool(ctx context.Context, in *CreatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error)
UpdatePrettyDisplayIDPool(ctx context.Context, in *UpdatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error)
GeneratePrettyDisplayIDs(ctx context.Context, in *GeneratePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(ctx context.Context, in *ListPrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(ctx context.Context, in *SetPrettyDisplayIDStatusRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error)
}
type userPrettyDisplayIDAdminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewUserPrettyDisplayIDAdminServiceClient(cc grpc.ClientConnInterface) UserPrettyDisplayIDAdminServiceClient {
return &userPrettyDisplayIDAdminServiceClient{cc}
}
func (c *userPrettyDisplayIDAdminServiceClient) ListPrettyDisplayIDPools(ctx context.Context, in *ListPrettyDisplayIDPoolsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDPoolsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListPrettyDisplayIDPoolsResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) CreatePrettyDisplayIDPool(ctx context.Context, in *CreatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDPoolResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) UpdatePrettyDisplayIDPool(ctx context.Context, in *UpdatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDPoolResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) GeneratePrettyDisplayIDs(ctx context.Context, in *GeneratePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*GeneratePrettyDisplayIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GeneratePrettyDisplayIDsResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) ListPrettyDisplayIDs(ctx context.Context, in *ListPrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListPrettyDisplayIDsResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) SetPrettyDisplayIDStatus(ctx context.Context, in *SetPrettyDisplayIDStatusRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGrantPrettyDisplayIDResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserPrettyDisplayIDAdminServiceServer is the server API for UserPrettyDisplayIDAdminService service.
// All implementations must embed UnimplementedUserPrettyDisplayIDAdminServiceServer
// for forward compatibility.
//
// UserPrettyDisplayIDAdminService 承载后台靓号池和后台发放能力。
type UserPrettyDisplayIDAdminServiceServer interface {
ListPrettyDisplayIDPools(context.Context, *ListPrettyDisplayIDPoolsRequest) (*ListPrettyDisplayIDPoolsResponse, error)
CreatePrettyDisplayIDPool(context.Context, *CreatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error)
UpdatePrettyDisplayIDPool(context.Context, *UpdatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error)
GeneratePrettyDisplayIDs(context.Context, *GeneratePrettyDisplayIDsRequest) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(context.Context, *ListPrettyDisplayIDsRequest) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error)
mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer()
}
// UnimplementedUserPrettyDisplayIDAdminServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedUserPrettyDisplayIDAdminServiceServer struct{}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) ListPrettyDisplayIDPools(context.Context, *ListPrettyDisplayIDPoolsRequest) (*ListPrettyDisplayIDPoolsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListPrettyDisplayIDPools not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) CreatePrettyDisplayIDPool(context.Context, *CreatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreatePrettyDisplayIDPool not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) UpdatePrettyDisplayIDPool(context.Context, *UpdatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdatePrettyDisplayIDPool not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) GeneratePrettyDisplayIDs(context.Context, *GeneratePrettyDisplayIDsRequest) (*GeneratePrettyDisplayIDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GeneratePrettyDisplayIDs not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) ListPrettyDisplayIDs(context.Context, *ListPrettyDisplayIDsRequest) (*ListPrettyDisplayIDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListPrettyDisplayIDs not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetPrettyDisplayIDStatus not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminGrantPrettyDisplayID not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer() {
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) testEmbeddedByValue() {}
// UnsafeUserPrettyDisplayIDAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to UserPrettyDisplayIDAdminServiceServer will
// result in compilation errors.
type UnsafeUserPrettyDisplayIDAdminServiceServer interface {
mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer()
}
func RegisterUserPrettyDisplayIDAdminServiceServer(s grpc.ServiceRegistrar, srv UserPrettyDisplayIDAdminServiceServer) {
// If the following call panics, it indicates UnimplementedUserPrettyDisplayIDAdminServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&UserPrettyDisplayIDAdminService_ServiceDesc, srv)
}
func _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPrettyDisplayIDPoolsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDPools(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDPools(ctx, req.(*ListPrettyDisplayIDPoolsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreatePrettyDisplayIDPoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).CreatePrettyDisplayIDPool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).CreatePrettyDisplayIDPool(ctx, req.(*CreatePrettyDisplayIDPoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdatePrettyDisplayIDPoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).UpdatePrettyDisplayIDPool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).UpdatePrettyDisplayIDPool(ctx, req.(*UpdatePrettyDisplayIDPoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GeneratePrettyDisplayIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).GeneratePrettyDisplayIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).GeneratePrettyDisplayIDs(ctx, req.(*GeneratePrettyDisplayIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPrettyDisplayIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDs(ctx, req.(*ListPrettyDisplayIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPrettyDisplayIDStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).SetPrettyDisplayIDStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).SetPrettyDisplayIDStatus(ctx, req.(*SetPrettyDisplayIDStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGrantPrettyDisplayIDRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).AdminGrantPrettyDisplayID(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).AdminGrantPrettyDisplayID(ctx, req.(*AdminGrantPrettyDisplayIDRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserPrettyDisplayIDAdminService_ServiceDesc is the grpc.ServiceDesc for UserPrettyDisplayIDAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var UserPrettyDisplayIDAdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.user.v1.UserPrettyDisplayIDAdminService",
HandlerType: (*UserPrettyDisplayIDAdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListPrettyDisplayIDPools",
Handler: _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_Handler,
},
{
MethodName: "CreatePrettyDisplayIDPool",
Handler: _UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_Handler,
},
{
MethodName: "UpdatePrettyDisplayIDPool",
Handler: _UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_Handler,
},
{
MethodName: "GeneratePrettyDisplayIDs",
Handler: _UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_Handler,
},
{
MethodName: "ListPrettyDisplayIDs",
Handler: _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_Handler,
},
{
MethodName: "SetPrettyDisplayIDStatus",
Handler: _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler,
},
{
MethodName: "AdminGrantPrettyDisplayID",
Handler: _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,7 @@ message DebitGiftResponse {
string gift_name = 14;
string gift_icon_url = 15;
string gift_animation_url = 16;
repeated string gift_effect_types = 17;
}
// DebitGiftTarget
@ -1037,6 +1038,8 @@ message RechargeProduct {
int64 updated_at_ms = 19;
string app_code = 20;
string status = 21;
// audience_type H5 App normal
string audience_type = 22;
}
message ListRechargeProductsRequest {
@ -1046,6 +1049,8 @@ message ListRechargeProductsRequest {
int64 region_id = 4;
// platform gateway App
string platform = 5;
// audience_type H5 App normal
string audience_type = 6;
}
message ListRechargeProductsResponse {
@ -1089,6 +1094,7 @@ message ListAdminRechargeProductsRequest {
string keyword = 6;
int32 page = 7;
int32 page_size = 8;
string audience_type = 9;
}
message ListAdminRechargeProductsResponse {
@ -1108,6 +1114,7 @@ message CreateRechargeProductRequest {
repeated int64 region_ids = 8;
bool enabled = 9;
int64 operator_user_id = 10;
string audience_type = 11;
}
message UpdateRechargeProductRequest {
@ -1122,6 +1129,7 @@ message UpdateRechargeProductRequest {
repeated int64 region_ids = 9;
bool enabled = 10;
int64 operator_user_id = 11;
string audience_type = 12;
}
message DeleteRechargeProductRequest {
@ -1139,6 +1147,163 @@ message DeleteRechargeProductResponse {
bool deleted = 1;
}
// ThirdPartyPaymentMethod H5
message ThirdPartyPaymentMethod {
int64 method_id = 1;
string app_code = 2;
string provider_code = 3;
string provider_name = 4;
string country_code = 5;
string country_name = 6;
string currency_code = 7;
string pay_way = 8;
string pay_type = 9;
string method_name = 10;
string logo_url = 11;
string status = 12;
string usd_to_currency_rate = 13;
int32 sort_order = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
}
message ThirdPartyPaymentChannel {
string app_code = 1;
string provider_code = 2;
string provider_name = 3;
string status = 4;
int32 sort_order = 5;
repeated ThirdPartyPaymentMethod methods = 6;
}
message ListThirdPartyPaymentChannelsRequest {
string request_id = 1;
string app_code = 2;
string provider_code = 3;
string status = 4;
bool include_disabled_methods = 5;
}
message ListThirdPartyPaymentChannelsResponse {
repeated ThirdPartyPaymentChannel channels = 1;
}
message SetThirdPartyPaymentMethodStatusRequest {
string request_id = 1;
string app_code = 2;
int64 method_id = 3;
bool enabled = 4;
int64 operator_user_id = 5;
}
message ThirdPartyPaymentMethodResponse {
ThirdPartyPaymentMethod method = 1;
}
message UpdateThirdPartyPaymentRateRequest {
string request_id = 1;
string app_code = 2;
int64 method_id = 3;
string usd_to_currency_rate = 4;
int64 operator_user_id = 5;
}
message H5RechargeOptionsRequest {
string request_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 target_region_id = 4;
string target_country_code = 5;
string audience_type = 6;
}
message H5RechargeOptionsResponse {
repeated RechargeProduct products = 1;
repeated ThirdPartyPaymentMethod payment_methods = 2;
bool usdt_trc20_enabled = 3;
string usdt_trc20_address = 4;
}
message ExternalRechargeOrder {
string order_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 target_region_id = 4;
string target_country_code = 5;
string audience_type = 6;
int64 product_id = 7;
string product_name = 8;
int64 coin_amount = 9;
int64 usd_minor_amount = 10;
string provider_code = 11;
int64 payment_method_id = 12;
string country_code = 13;
string currency_code = 14;
int64 provider_amount_minor = 15;
string pay_way = 16;
string pay_type = 17;
string pay_url = 18;
string provider_order_id = 19;
string tx_hash = 20;
string receive_address = 21;
string status = 22;
string failure_reason = 23;
string transaction_id = 24;
int64 created_at_ms = 25;
int64 updated_at_ms = 26;
bool idempotent_replay = 27;
}
message CreateH5RechargeOrderRequest {
string request_id = 1;
string app_code = 2;
string command_id = 3;
int64 target_user_id = 4;
int64 target_region_id = 5;
string target_country_code = 6;
string audience_type = 7;
int64 product_id = 8;
string provider_code = 9;
int64 payment_method_id = 10;
string return_url = 11;
string notify_url = 12;
string client_ip = 13;
string language = 14;
}
message SubmitH5RechargeTxRequest {
string request_id = 1;
string app_code = 2;
string order_id = 3;
int64 target_user_id = 4;
string tx_hash = 5;
}
message GetH5RechargeOrderRequest {
string request_id = 1;
string app_code = 2;
string order_id = 3;
int64 target_user_id = 4;
}
message H5RechargeOrderResponse {
ExternalRechargeOrder order = 1;
}
message HandleMifapayNotifyRequest {
string request_id = 1;
string app_code = 2;
string mer_account = 3;
string data = 4;
string sign = 5;
}
message HandleMifapayNotifyResponse {
bool accepted = 1;
string response_text = 2;
string order_id = 3;
}
message DiamondExchangeRule {
string exchange_type = 1;
string from_asset_type = 2;
@ -1668,6 +1833,14 @@ service WalletService {
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);
rpc DeleteRechargeProduct(DeleteRechargeProductRequest) returns (DeleteRechargeProductResponse);
rpc ListThirdPartyPaymentChannels(ListThirdPartyPaymentChannelsRequest) returns (ListThirdPartyPaymentChannelsResponse);
rpc SetThirdPartyPaymentMethodStatus(SetThirdPartyPaymentMethodStatusRequest) returns (ThirdPartyPaymentMethodResponse);
rpc UpdateThirdPartyPaymentRate(UpdateThirdPartyPaymentRateRequest) returns (ThirdPartyPaymentMethodResponse);
rpc ListH5RechargeOptions(H5RechargeOptionsRequest) returns (H5RechargeOptionsResponse);
rpc CreateH5RechargeOrder(CreateH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);

View File

@ -249,6 +249,14 @@ const (
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
WalletService_DeleteRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteRechargeProduct"
WalletService_ListThirdPartyPaymentChannels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListThirdPartyPaymentChannels"
WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetThirdPartyPaymentMethodStatus"
WalletService_UpdateThirdPartyPaymentRate_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateThirdPartyPaymentRate"
WalletService_ListH5RechargeOptions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListH5RechargeOptions"
WalletService_CreateH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateH5RechargeOrder"
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
WalletService_GetH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/GetH5RechargeOrder"
WalletService_HandleMifapayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleMifapayNotify"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
@ -326,6 +334,14 @@ type WalletServiceClient interface {
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
DeleteRechargeProduct(ctx context.Context, in *DeleteRechargeProductRequest, opts ...grpc.CallOption) (*DeleteRechargeProductResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, in *ListThirdPartyPaymentChannelsRequest, opts ...grpc.CallOption) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, in *SetThirdPartyPaymentMethodStatusRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, in *UpdateThirdPartyPaymentRateRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(ctx context.Context, in *CreateH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
@ -837,6 +853,86 @@ func (c *walletServiceClient) DeleteRechargeProduct(ctx context.Context, in *Del
return out, nil
}
func (c *walletServiceClient) ListThirdPartyPaymentChannels(ctx context.Context, in *ListThirdPartyPaymentChannelsRequest, opts ...grpc.CallOption) (*ListThirdPartyPaymentChannelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListThirdPartyPaymentChannelsResponse)
err := c.cc.Invoke(ctx, WalletService_ListThirdPartyPaymentChannels_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SetThirdPartyPaymentMethodStatus(ctx context.Context, in *SetThirdPartyPaymentMethodStatusRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ThirdPartyPaymentMethodResponse)
err := c.cc.Invoke(ctx, WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateThirdPartyPaymentRate(ctx context.Context, in *UpdateThirdPartyPaymentRateRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ThirdPartyPaymentMethodResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateThirdPartyPaymentRate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOptionsResponse)
err := c.cc.Invoke(ctx, WalletService_ListH5RechargeOptions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreateH5RechargeOrder(ctx context.Context, in *CreateH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOrderResponse)
err := c.cc.Invoke(ctx, WalletService_CreateH5RechargeOrder_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOrderResponse)
err := c.cc.Invoke(ctx, WalletService_SubmitH5RechargeTx_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOrderResponse)
err := c.cc.Invoke(ctx, WalletService_GetH5RechargeOrder_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HandleMifapayNotifyResponse)
err := c.cc.Invoke(ctx, WalletService_HandleMifapayNotify_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDiamondExchangeConfigResponse)
@ -1101,6 +1197,14 @@ type WalletServiceServer interface {
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*RechargeProductResponse, error)
DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error)
ListThirdPartyPaymentChannels(context.Context, *ListThirdPartyPaymentChannelsRequest) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(context.Context, *SetThirdPartyPaymentMethodStatusRequest) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error)
ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(context.Context, *CreateH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
@ -1276,6 +1380,30 @@ func (UnimplementedWalletServiceServer) UpdateRechargeProduct(context.Context, *
func (UnimplementedWalletServiceServer) DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteRechargeProduct not implemented")
}
func (UnimplementedWalletServiceServer) ListThirdPartyPaymentChannels(context.Context, *ListThirdPartyPaymentChannelsRequest) (*ListThirdPartyPaymentChannelsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListThirdPartyPaymentChannels not implemented")
}
func (UnimplementedWalletServiceServer) SetThirdPartyPaymentMethodStatus(context.Context, *SetThirdPartyPaymentMethodStatusRequest) (*ThirdPartyPaymentMethodResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetThirdPartyPaymentMethodStatus not implemented")
}
func (UnimplementedWalletServiceServer) UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateThirdPartyPaymentRate not implemented")
}
func (UnimplementedWalletServiceServer) ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListH5RechargeOptions not implemented")
}
func (UnimplementedWalletServiceServer) CreateH5RechargeOrder(context.Context, *CreateH5RechargeOrderRequest) (*H5RechargeOrderResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateH5RechargeOrder not implemented")
}
func (UnimplementedWalletServiceServer) SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SubmitH5RechargeTx not implemented")
}
func (UnimplementedWalletServiceServer) GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetH5RechargeOrder not implemented")
}
func (UnimplementedWalletServiceServer) HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method HandleMifapayNotify not implemented")
}
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
}
@ -2224,6 +2352,150 @@ func _WalletService_DeleteRechargeProduct_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListThirdPartyPaymentChannels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListThirdPartyPaymentChannelsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListThirdPartyPaymentChannels(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListThirdPartyPaymentChannels_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListThirdPartyPaymentChannels(ctx, req.(*ListThirdPartyPaymentChannelsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SetThirdPartyPaymentMethodStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetThirdPartyPaymentMethodStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SetThirdPartyPaymentMethodStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SetThirdPartyPaymentMethodStatus(ctx, req.(*SetThirdPartyPaymentMethodStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateThirdPartyPaymentRate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateThirdPartyPaymentRateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateThirdPartyPaymentRate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateThirdPartyPaymentRate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateThirdPartyPaymentRate(ctx, req.(*UpdateThirdPartyPaymentRateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListH5RechargeOptions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(H5RechargeOptionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListH5RechargeOptions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListH5RechargeOptions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListH5RechargeOptions(ctx, req.(*H5RechargeOptionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreateH5RechargeOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateH5RechargeOrderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreateH5RechargeOrder(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreateH5RechargeOrder_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreateH5RechargeOrder(ctx, req.(*CreateH5RechargeOrderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SubmitH5RechargeTx_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitH5RechargeTxRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SubmitH5RechargeTx(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SubmitH5RechargeTx_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SubmitH5RechargeTx(ctx, req.(*SubmitH5RechargeTxRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetH5RechargeOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetH5RechargeOrderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetH5RechargeOrder(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetH5RechargeOrder_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetH5RechargeOrder(ctx, req.(*GetH5RechargeOrderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_HandleMifapayNotify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HandleMifapayNotifyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).HandleMifapayNotify(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_HandleMifapayNotify_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).HandleMifapayNotify(ctx, req.(*HandleMifapayNotifyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiamondExchangeConfigRequest)
if err := dec(in); err != nil {
@ -2801,6 +3073,38 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteRechargeProduct",
Handler: _WalletService_DeleteRechargeProduct_Handler,
},
{
MethodName: "ListThirdPartyPaymentChannels",
Handler: _WalletService_ListThirdPartyPaymentChannels_Handler,
},
{
MethodName: "SetThirdPartyPaymentMethodStatus",
Handler: _WalletService_SetThirdPartyPaymentMethodStatus_Handler,
},
{
MethodName: "UpdateThirdPartyPaymentRate",
Handler: _WalletService_UpdateThirdPartyPaymentRate_Handler,
},
{
MethodName: "ListH5RechargeOptions",
Handler: _WalletService_ListH5RechargeOptions_Handler,
},
{
MethodName: "CreateH5RechargeOrder",
Handler: _WalletService_CreateH5RechargeOrder_Handler,
},
{
MethodName: "SubmitH5RechargeTx",
Handler: _WalletService_SubmitH5RechargeTx_Handler,
},
{
MethodName: "GetH5RechargeOrder",
Handler: _WalletService_GetH5RechargeOrder_Handler,
},
{
MethodName: "HandleMifapayNotify",
Handler: _WalletService_HandleMifapayNotify_Handler,
},
{
MethodName: "GetDiamondExchangeConfig",
Handler: _WalletService_GetDiamondExchangeConfig_Handler,

View File

@ -287,7 +287,6 @@ services:
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro

View File

@ -0,0 +1,474 @@
# game-service 游戏结构设计
本文定义 `game-service` 后续承载游戏的结构边界。当前仓库已经有第三方 H5 游戏接入能力:游戏列表、启动会话、厂商回调、钱包改账、统计 outbox 和等级事件 outbox 都在 `game-service` 内。本文补的是自研小游戏的结构,避免把骰子、猜拳这类玩法塞进第三方平台 adapter。
## 结论
- `game-service` 是游戏事实 owner。游戏列表、启动会话、匹配、局内状态、结算、补偿和游戏事件都归它管理。
- `gateway-service` 只做 App HTTP 入口、鉴权、参数转换和 gRPC 转发,不保存游戏状态。
- `wallet-service` 仍然是金币 owner。`game-service` 只能通过钱包 gRPC 扣款、派奖、退款,不能直接改余额。
- `room-service` 不拥有游戏状态。房间只提供入口、房间上下文和必要的展示事件。
- 第三方 H5 游戏继续走现有 `platform/catalog/session/callback/order` 结构。
- 自研小游戏单独按 `game_code` 建领域模块、服务模块和表,不提前抽一个复杂通用引擎。
- 只有当两个以上自研游戏出现相同匹配、计时、结算、IM 投递逻辑时,再把公共能力抽到 `internal/domain/minigame``internal/service/minigame`
## 当前事实
现有入口:
- App 游戏列表:`GET /api/v1/games`
- 最近游戏:`GET /api/v1/games/recent`
- 桥接脚本:`GET /api/v1/games/bridge-script`
- 启动游戏:`POST /api/v1/games/{game_id}/launch`
- 厂商回调:`POST /api/v1/game-callbacks/{platform_code}/{operation}`
现有内部 RPC
- `GameAppService.ListGames`
- `GameAppService.ListRecentGames`
- `GameAppService.GetBridgeScript`
- `GameAppService.LaunchGame`
- `GameCallbackService.HandleCallback`
- `GameAdminService` 管理平台和目录
- `GameCronService.ProcessLevelEventOutboxBatch`
现有核心表:
- `game_platforms`:第三方平台配置
- `game_catalog`App 看到的游戏目录
- `game_display_rules`:按场景、区域、语言和客户端平台控制展示
- `game_launch_sessions`H5 启动会话
- `game_orders`:第三方或游戏结算引起的钱包订单
- `game_callback_logs`:第三方回调审计
- `game_level_event_outbox`:游戏消耗投递 activity-service
- `game_outbox`:游戏订单事实投递 RocketMQ给 statistics-service 等下游消费
## 分层结构
```text
gateway-service
internal/transport/http/gameapi
只接 App HTTP拿 user_id、request_id、app_code 后转 game-service gRPC
api/proto/game/v1
game.proto
所有跨服务契约,新增 App 接口或后台接口先改这里
services/game-service
internal/domain/game
已有通用游戏目录、平台、会话、订单、回调、outbox 领域对象
internal/service/game
已有第三方 H5 平台接入和游戏列表、启动、回调主流程
internal/domain/<game_code>
自研游戏纯规则不能依赖数据库、gRPC、HTTP、钱包 client
internal/service/<game_code>
自研游戏用例层,负责匹配、状态推进、调用钱包、写 outbox
internal/storage/mysql/<game_code>_repository.go
自研游戏持久化,行锁、幂等、状态转移都在这里收口
internal/transport/grpc
把 game.proto 的 RPC 转成 service 调用,不写业务规则
```
目录原则:
- `internal/service/game` 保留给已有平台接入,不继续堆自研玩法。
- 每个自研玩法用独立包,例如 `internal/domain/dice``internal/service/dice`
- 自研玩法可以复用 `game_catalog``game_display_rules` 做列表展示。
- 自研玩法不走第三方 `PlatformAdapter`,也不走 `HandleCallback`
- 自研玩法的 HTTP 入口仍在 gateway跨服务契约仍在 `api/proto/game/v1/game.proto`
## 游戏类型
| 类型 | 例子 | 列表和启动 | 状态 owner | 结算方式 |
| ---------------- | ---------------------- | ------------------------------------------------- | --------------------------------------- | --------------------------------------------- |
| 第三方 H5 | 百顺、VivaGames、Reyou | 现有 `/games``/launch` | 第三方平台game-service 保存会话和订单 | 厂商回调到 game-service再调 wallet-service |
| 自研 H5 多人结算 | 骰子首版 | 复用 `/games``/launch`,局内操作走新增 HTTP | game-service | game-service 直接调 wallet-service |
| 自研实时对战 | 猜拳、后续双人骰子 | 复用 `/games``/launch`,匹配和出招走新增 HTTP | game-service | game-service 调 wallet-serviceIM 只下发事件 |
## 自研游戏统一链路
```mermaid
sequenceDiagram
participant App
participant Gateway as gateway-service
participant Game as game-service
participant Wallet as wallet-service
participant Notice as notice-service/Tencent IM
participant DB as hyapp_game
App->>Gateway: POST /api/v1/games/{game_id}/launch
Gateway->>Game: LaunchGame
Game->>DB: create game_launch_sessions
Game-->>Gateway: launch_url/session_id
Gateway-->>App: H5 url
App->>Gateway: POST /api/v1/games/dice/matches
Gateway->>Game: Create self-game match
Game->>DB: create match + first participant
App->>Gateway: POST /api/v1/games/dice/matches/{match_id}/join
Gateway->>Game: JoinDiceMatch
Game->>DB: lock match, insert participant
App->>Gateway: POST /api/v1/games/dice/matches/{match_id}/roll
Gateway->>Game: RollDiceMatch
Game->>DB: claim match for settling
Game->>Wallet: ApplyGameCoinChange debit/credit/refund
Wallet-->>Game: wallet_transaction_id/balance_after
Game->>DB: save dice points, orders, result and outbox
Game-->>Gateway: match/result state
Gateway-->>App: business response
Game-->>Notice: optional realtime outbox
Notice-->>App: optional IM custom event
```
关键约束:
- 用户身份只信任 gateway 从 JWT 得到的 `user_id`
- `request_id` 只做链路追踪,不做业务幂等键。
- 业务幂等键使用 `command_id``match_id``round_id``provider_order_id` 或唯一索引。
- 所有时间字段使用 UTC epoch milliseconds字段名统一 `*_ms`
- 金币金额必须是正数,扣款、派奖、退款用 `op_type` 表达方向。
- 同一局的投注、派奖、退款必须能通过 `match_id/round_id/order_id` 追踪。
## 自研游戏表结构原则
不要用一个大表兼容所有玩法。每个玩法先建自己的事实表,公共 outbox 可以复用。
建议公共表:
### game_realtime_outbox
用于自研游戏的 IM 实时事件。不要复用 `game_outbox`,因为 `game_outbox` 是订单和统计事实,字段强依赖 `order_id/op_type/coin_amount`
关键字段:
- `app_code`
- `event_id`
- `event_type`
- `game_code`
- `match_id`
- `target_user_id`
- `payload_json`
- `status`
- `worker_id`
- `lock_until_ms`
- `retry_count`
- `next_retry_at_ms`
- `last_error`
- `created_at_ms`
- `updated_at_ms`
推荐索引:
- `PRIMARY KEY(app_code, event_id)`
- `idx_game_realtime_claim(app_code, status, next_retry_at_ms, created_at_ms, event_id)`
- `idx_game_realtime_target(app_code, target_user_id, created_at_ms)`
## Redis 和 MySQL 边界
局信息可以同时出现在 MySQL 和 Redis但不能是“双重事实”。
- MySQL 是唯一事实。参与人、扣款、派奖、结果、状态流转和补偿都必须能只靠 MySQL 恢复。
- Redis 只保存热状态。可以存等待队列、房间当前局快照、倒计时、座位临时锁和防重复点击标记。
- Redis 数据必须可丢。Redis 重启、Key 过期或缓存击穿时,服务必须从 MySQL 查回当前局。
- 金币相关状态只以 MySQL 和 wallet-service 返回为准,不以 Redis 为准。
- 关键状态推进用 MySQL 事务、唯一索引和行锁收口Redis 锁只能减少并发压力,不能作为最终防线。
推荐 Key
- `game:dice:room:{app_code}:{room_id}:active_match`房间当前局快照TTL 按局时长加缓冲。
- `game:dice:match:{app_code}:{match_id}`局详情快照TTL 按局时长加缓冲。
- `game:dice:join_lock:{app_code}:{match_id}`:短 TTL 加入锁,用来减少并发抢座。
- `game:dice:action:{app_code}:{match_id}:{user_id}:{action}`:短 TTL 动作防抖,最终幂等仍看 MySQL。
写入顺序:
1. 先在 MySQL 事务里锁定 `game_dice_matches`
2. 创建/加入只写 `game_dice_participants` 和局状态。
3. 开奖阶段用钱包幂等键写 `game_orders`,再保存点数、派奖和局结果。
4. MySQL 事实提交后刷新 Redis 快照。
5. Redis 刷新失败只记录日志和重试,不回滚已提交的 MySQL 事实。
## 骰子游戏首版结构
首版按多人可扩展结构设计。当前实现要求至少 2 人,默认最多 2 人,硬上限 6 人3 人局只增加参与者行不改表。H5 只负责展示和发起操作,随机、扣款、派奖和结果都由 `game-service` 完成。
当前骰子规则是单局胜场制。每个参与者每轮只扔 1 颗骰子,服务端把这个点数写入 `dice_points[0]`点数高的一方获胜。同点不是终局不退本金、不派奖H5 展示点数 2 秒后继续调用 `roll`,同一个 `match_id` 会一直重投到分出胜负。
### 模块
```text
services/game-service/internal/domain/dice
rule.go 赔率、可选押注、结果计算
match.go match 状态、参与者、局次、结果
services/game-service/internal/service/dice
service.go 创建局、加入局、扣款、开奖、派奖、查询
services/game-service/internal/storage/mysql/dice_repository.go
创建局、参与人行锁读取、状态推进、订单关联、查询
```
### 状态机
```mermaid
stateDiagram-v2
[*] --> created
created --> joining
joining --> settling
created --> settling
settling --> payout_applying
payout_applying --> settled
settling --> failed
payout_applying --> failed
```
状态说明:
- `created`:收到创建请求,已生成 `match_id`
- `joining`:等待参与者加入,人数达到 `min_players` 后可以开奖。
- `settling`:已抢占结算权,正在扣押注、生成骰子结果。
- `payout_applying`:骰子点数已经固定,正在派奖或退款;重试必须复用旧点数。
- `settled`:局已结束,结果固定。
- `failed`:扣款、派奖或退款失败,后续由补偿任务继续处理。
### 表
#### game_dice_matches
关键字段:
- `app_code`
- `match_id`
- `game_id`
- `platform_code`
- `provider_game_id`
- `room_id`
- `region_id`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `round_no`
- `result`
- `status`
- `join_deadline_ms`
- `created_at_ms`
- `updated_at_ms`
- `settled_at_ms`
索引:
- `PRIMARY KEY(app_code, match_id)`
- `idx_game_dice_room_active(app_code, room_id, status, created_at_ms)`
- `idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)`
#### game_dice_participants
一局一个参与者一行。2 人、3 人或 6 人只改变参与者数量,不改主表结构。
关键字段:
- `app_code`
- `match_id`
- `user_id`
- `seat_no`
- `status`
- `stake_coin`
- `dice_points_json`
- `result`
- `payout_coin`
- `debit_order_id`
- `payout_order_id`
- `refund_order_id`
- `balance_after`
- `joined_at_ms`
- `updated_at_ms`
索引:
- `PRIMARY KEY(app_code, match_id, user_id)`
- `UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no)`
- `idx_game_dice_participant_user(app_code, user_id, updated_at_ms)`
- `idx_game_dice_participant_status(app_code, match_id, status, seat_no)`
#### game_dice_round_logs
如果首版只有一局,可以先不建。若后续做多轮、连胜、多人奖池或房间内广播,再补小局日志表。
关键字段:
- `app_code`
- `match_id`
- `round_no`
- `result_json`
- `created_at_ms`
## 骰子接口草案
接口文档只保留地址、参数、返回值和相关 IM。
### 1. 创建骰子局
地址:`POST /api/v1/games/dice/matches`
参数:
- `game_id`
- `room_id`
- `stake_coin`
- `min_players`
- `max_players`
返回值:
- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
### 2. 加入骰子局
地址:`POST /api/v1/games/dice/matches/{match_id}/join`
参数:
- `match_id`
返回值:
- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
### 3. 查询骰子局
地址:`GET /api/v1/games/dice/matches/{match_id}`
参数:
- `match_id`
返回值:
- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `result`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
### 4. 开奖
地址:`POST /api/v1/games/dice/matches/{match_id}/roll`
参数:
- `match_id`
返回值:
- `match_id`
- `status`
- `stake_coin`
- `result`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
- 如果后续做房间公屏展示,再增加 `dice_match_settled`
## 钱包和订单
自研游戏也要落 `game_orders`,不能直接只写玩法表。
骰子推荐订单:
- 押注扣款:`op_type=debit``provider_order_id=dice:{match_id}:{user_id}:debit`
- 派奖加款:`op_type=credit``provider_order_id=dice:{match_id}:{user_id}:credit`
- 异常退款:`op_type=refund``provider_order_id=dice:{match_id}:{user_id}:refund`
`wallet-service``CommandId`
- `game:dice:{match_id}:{user_id}:debit`
- `game:dice:{match_id}:{user_id}:credit`
- `game:dice:{match_id}:{user_id}:refund`
扣款成功后:
- 写 `game_orders`
- 写 `game_outbox`,供统计使用
- 写 `game_level_event_outbox`,供 activity-service 游戏等级使用
派奖和退款是否进等级轨道由产品规则决定,默认只有 `debit` 进游戏等级。
## IM 事件边界
首版骰子不强依赖 IM。客户端发起 HTTPHTTP 返回结果。
需要 IM 的场景:
- 双人匹配成功
- 倒计时开始
- 双方状态变化
- 结果揭晓
- 对方离开或超时
IM 规则:
- 客户端不能通过 IM 提交游戏动作。
- 客户端动作必须走 HTTP 到 gateway再转 game-service。
- IM 只承载服务端事实事件。
- IM payload 必须带 `event_id``game_code``match_id``server_time_ms`
- 客户端按 `event_id` 去重。
## 上线顺序
1. 先补 `api/proto/game/v1/game.proto` 的骰子 RPC。
2. 生成 proto`make proto`
3. 在 `gateway-service/internal/transport/http/gameapi` 加 HTTP handler。
4. 在 `game-service``domain/dice``service/dice``storage/mysql/dice_repository.go`
5. 更新 `services/game-service/deploy/mysql/initdb/001_game_service.sql`
6. 更新 runtime `Migrate`,保证线上库自动补表和索引。
7. 后台把骰子写入 `game_catalog` 和默认 `game_display_rules`
8. H5 `game/touzi` 通过启动页拿 `game_id/session_id`,局内操作走新增骰子接口。
9. 跑测试:`make proto``go test ./services/game-service/... ./services/gateway-service/...`
10. 本地拉起:`docker compose config`,必要时 `docker compose up -d game-service gateway-service` 验证。
## 不做的事
- 不把骰子局状态放进 `room-service`
- 不让 H5 直接调 `wallet-service`
- 不把骰子动作放进第三方 `HandleCallback`
- 不把所有玩法硬塞进一个通用 `game_matches` 大表。
- 不用 Redis 做唯一事实。Redis 后续只能做短期匹配队列、限流或缓存MySQL 仍是恢复来源。

View File

@ -19,7 +19,6 @@ SQL_FILES=(
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/006_admin_database.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/008_remove_taiwan_country.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"

View File

@ -1,22 +1,19 @@
CREATE TABLE IF NOT EXISTS bd_leader_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_leader_profiles_region_status (app_code, region_id, status)
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表';
INSERT INTO bd_leader_profiles (
app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms
)
SELECT app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
SELECT app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms
FROM bd_profiles
WHERE role = 'bd_leader'
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
created_by_user_id = VALUES(created_by_user_id),
created_at_ms = VALUES(created_at_ms),

View File

@ -0,0 +1,92 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 靓号管理把展示号从纯数字短号扩展为可发放的展示字符串,默认短号生成规则仍由业务代码保持纯数字。
ALTER TABLE users
MODIFY COLUMN default_display_user_id VARCHAR(64) NOT NULL COMMENT '默认展示用户 ID',
MODIFY COLUMN current_display_user_id VARCHAR(64) NOT NULL COMMENT '当前展示用户 ID';
ALTER TABLE user_display_user_ids
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL COMMENT '展示用户 ID',
MODIFY COLUMN live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE
WHEN status IN ('active', 'held', 'reserved', 'blocked') THEN display_user_id
ELSE NULL
END
) STORED COMMENT '实时展示用户 ID';
ALTER TABLE pretty_display_user_id_leases
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL COMMENT '展示用户 ID',
MODIFY COLUMN active_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN display_user_id ELSE NULL END
) STORED COMMENT '启用展示用户 ID',
ADD COLUMN released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '释放时间UTC epoch ms' AFTER expires_at_ms,
ADD COLUMN release_reason VARCHAR(64) NOT NULL DEFAULT '' COMMENT '释放原因' AFTER released_at_ms;
ALTER TABLE display_user_id_change_logs
MODIFY COLUMN old_display_user_id VARCHAR(64) NOT NULL COMMENT '原展示用户 ID',
MODIFY COLUMN new_display_user_id VARCHAR(64) NOT NULL COMMENT '新展示用户 ID';
CREATE TABLE IF NOT EXISTS pretty_display_id_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
pool_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '靓号池 ID',
name VARCHAR(128) NOT NULL COMMENT '靓号池名称',
level_track VARCHAR(32) NOT NULL COMMENT '等级轨道wealth/game/charm',
min_level INT NOT NULL COMMENT '最小等级,包含',
max_level INT NOT NULL COMMENT '最大等级,包含',
rule_type VARCHAR(32) NOT NULL COMMENT '生成规则',
rule_config_json JSON NULL COMMENT '生成规则配置',
status VARCHAR(32) NOT NULL COMMENT '业务状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_pretty_pool_range_rule (app_code, level_track, min_level, max_level, name),
KEY idx_pretty_pool_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池配置表';
CREATE TABLE IF NOT EXISTS pretty_display_ids (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
pretty_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '靓号记录 ID',
pool_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '所属靓号池 ID后台发放为空',
source VARCHAR(32) NOT NULL DEFAULT 'pool' COMMENT '来源pool/admin_grant',
display_user_id VARCHAR(64) NOT NULL COMMENT '靓号展示内容',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
assigned_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前占用用户 ID',
assigned_lease_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当前占用租约 ID',
assigned_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '占用时间UTC epoch ms',
released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '释放时间UTC epoch ms',
release_reason VARCHAR(64) NOT NULL DEFAULT '' COMMENT '释放原因',
generated_batch_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '生成批次 ID',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status IN ('available', 'assigned', 'disabled', 'reserved') THEN display_user_id ELSE NULL END
) STORED COMMENT '未删除且未释放的靓号内容',
UNIQUE KEY uk_pretty_display_ids_live (app_code, live_display_user_id),
KEY idx_pretty_display_ids_pool_status (app_code, pool_id, status),
KEY idx_pretty_display_ids_assigned_user (app_code, assigned_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池号码表';
CREATE TABLE IF NOT EXISTS pretty_display_id_generation_batches (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
batch_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '生成批次 ID',
pool_id VARCHAR(64) NOT NULL COMMENT '靓号池 ID',
rule_type VARCHAR(32) NOT NULL COMMENT '生成规则',
rule_config_json JSON NULL COMMENT '生成规则配置',
requested_count INT NOT NULL COMMENT '请求生成数量',
generated_count INT NOT NULL COMMENT '实际生成数量',
skipped_conflict_count INT NOT NULL COMMENT '冲突跳过数量',
status VARCHAR(32) NOT NULL COMMENT '批次状态',
operator_admin_id BIGINT NOT NULL COMMENT '操作管理员 ID',
request_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '链路请求 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_pretty_batch_pool (app_code, pool_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号批量生成记录表';

View File

@ -0,0 +1,129 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- H5 外部充值:商品区分普通用户/币商,三方支付配置默认启用,外部订单独立于 Google payment_orders。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_products' AND COLUMN_NAME = 'audience_type') = 0,
'ALTER TABLE wallet_recharge_products ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT ''normal'' COMMENT ''购买对象normal 普通用户coin_seller 币商'' AFTER description',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS third_party_payment_channels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码',
provider_name VARCHAR(64) NOT NULL COMMENT '三方支付渠道名称',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '渠道状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, provider_code),
KEY idx_third_party_payment_channels_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付渠道表';
CREATE TABLE IF NOT EXISTS third_party_payment_methods (
method_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '支付方式 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码',
country_code VARCHAR(8) NOT NULL COMMENT '国家码GLOBAL 表示全球方式',
country_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家名称',
currency_code VARCHAR(8) NOT NULL COMMENT '下单币种',
pay_way VARCHAR(64) NOT NULL COMMENT 'MiFaPay payWay',
pay_type VARCHAR(64) NOT NULL COMMENT 'MiFaPay payType',
method_name VARCHAR(128) NOT NULL COMMENT '支付方式名称',
logo_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '支付方式图标',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '方式状态',
usd_to_currency_rate DECIMAL(20,8) NOT NULL DEFAULT 1 COMMENT 'USD 到支付币种汇率',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_third_party_payment_methods_scope (app_code, provider_code, country_code, pay_way, pay_type),
KEY idx_third_party_payment_methods_h5 (app_code, provider_code, country_code, status, sort_order),
CONSTRAINT fk_third_party_payment_methods_channel FOREIGN KEY (app_code, provider_code) REFERENCES third_party_payment_channels(app_code, provider_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付国家方式配置表';
CREATE TABLE IF NOT EXISTS external_recharge_orders (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
order_id VARCHAR(96) NOT NULL COMMENT '外部充值订单号',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 幂等命令 ID',
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
target_region_id BIGINT NOT NULL COMMENT '目标区域 ID',
target_country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '目标国家码',
audience_type VARCHAR(32) NOT NULL COMMENT '购买对象',
product_id BIGINT NOT NULL COMMENT '商品 ID',
product_code VARCHAR(128) NOT NULL COMMENT '商品编码快照',
product_name VARCHAR(128) NOT NULL COMMENT '商品名称快照',
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
usd_minor_amount BIGINT NOT NULL COMMENT 'USD cent 金额快照',
provider_code VARCHAR(32) NOT NULL COMMENT '支付渠道',
payment_method_id BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付方式 ID',
country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付方式国家码',
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付币种',
provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付币种最小单位金额',
pay_way VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payWay',
pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payType',
pay_url VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '三方支付跳转 URL',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方支付订单号',
tx_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT tx hash',
receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址',
status VARCHAR(32) NOT NULL COMMENT '订单状态',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
provider_payload JSON NULL COMMENT '下单、回调或链上校验原始数据',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, order_id),
UNIQUE KEY uk_external_recharge_orders_command (app_code, command_id),
KEY idx_external_recharge_orders_user_time (app_code, target_user_id, created_at_ms),
KEY idx_external_recharge_orders_provider_order (app_code, provider_code, provider_order_id),
KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表';
INSERT IGNORE INTO third_party_payment_channels (app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms)
VALUES ('lalu', 'mifapay', 'MiFaPay', 'active', 10, @now_ms, @now_ms);
-- MiFaPay 是 H5 统一三方支付入口;真实商户号、商户标识、商户私钥、平台公钥只放运行环境配置。
-- 国家和支付方式默认打开,运营可在后台逐项关闭;默认汇率为 1避免新环境缺配置时 H5 完全不可见。
INSERT IGNORE INTO third_party_payment_methods (
app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type,
method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BCA', 'BCA Bank', '', 'active', 1, 110, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI', 'BNI Bank', '', 'active', 1, 120, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI', 'BRI Bank', '', 'active', 1, 130, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Mandiri', 'Mandiri Bank', '', 'active', 1, 140, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Permata', 'Permata Bank', '', 'active', 1, 150, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'DANA', 'DANA', '', 'active', 1, 160, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'LinkAja', 'LinkAja', '', 'active', 1, 170, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 180, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ShopeePay', 'ShopeePay', '', 'active', 1, 190, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'QR', 'QRIS', 'QRIS', '', 'active', 1, 200, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'BankTransfer', 'VN_BankTransfer', 'Vietnam Bank Transfer', '', 'active', 1, 210, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'QR', 'VietQR', 'VietQR', '', 'active', 1, 220, @now_ms, @now_ms),
('lalu', 'mifapay', 'BH', 'Bahrain', 'BHD', 'Card', 'CreditCard', 'Bahrain Credit Card', '', 'active', 1, 310, @now_ms, @now_ms),
('lalu', 'mifapay', 'QA', 'Qatar', 'QAR', 'Card', 'CreditCard', 'Qatar Credit Card', '', 'active', 1, 410, @now_ms, @now_ms),
('lalu', 'mifapay', 'OM', 'Oman', 'OMR', 'Card', 'CreditCard', 'Oman Credit Card', '', 'active', 1, 510, @now_ms, @now_ms),
('lalu', 'mifapay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CreditCard', 'UAE Credit Card', '', 'active', 1, 610, @now_ms, @now_ms),
('lalu', 'mifapay', 'KW', 'Kuwait', 'KWD', 'Card', 'Knet', 'KNET', '', 'active', 1, 710, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 810, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'ApplePay', 'ApplePay', '', 'active', 1, 820, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPay', 'STCPay', '', 'active', 1, 830, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'BHIM', 'BHIM', '', 'active', 1, 910, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'GooglePay', 'GooglePay', '', 'active', 1, 920, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Mobikwik', 'Mobikwik', '', 'active', 1, 930, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Paytm', 'Paytm', '', 'active', 1, 940, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'PhonePe', 'PhonePe', '', 'active', 1, 950, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'UPI', 'UPI', '', 'active', 1, 960, @now_ms, @now_ms),
('lalu', 'mifapay', 'BD', 'Bangladesh', 'BDT', 'Ewallet', 'bKash', 'bKash', '', 'active', 1, 1010, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'Easypaisa', 'Easypaisa', '', 'active', 1, 1110, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JazzCash', 'JazzCash', '', 'active', 1, 1120, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'FawryPay', 'FawryPay', '', 'active', 1, 1210, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Meeza', 'Meeza', '', 'active', 1, 1220, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Orange', 'Orange', '', 'active', 1, 1230, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Vodafone', 'Vodafone', '', 'active', 1, 1240, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'Gcash', 'GCash', '', 'active', 1, 1310, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'PayMaya', 'PayMaya', '', 'active', 1, 1320, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH', 'QRPH', '', 'active', 1, 1330, @now_ms, @now_ms);

View File

@ -0,0 +1,93 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 首充奖励从“首笔金币区间”改为“Google 商品档位”:一个用户每个商品档位只能领取一次。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND COLUMN_NAME = 'usd_minor_amount') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD COLUMN usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''Google 商品美元美分档位'' AFTER max_coin_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND COLUMN_NAME = 'google_product_id') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD COLUMN google_product_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''Google Play 商品 ID'' AFTER usd_minor_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND COLUMN_NAME = 'discount_percent') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD COLUMN discount_percent INT NOT NULL DEFAULT 0 COMMENT ''展示优惠百分比,不参与结算'' AFTER google_product_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE first_recharge_reward_tiers
SET google_product_id = CONCAT('legacy_', tier_id)
WHERE google_product_id = '';
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND INDEX_NAME = 'uk_first_recharge_reward_google_product') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD UNIQUE KEY uk_first_recharge_reward_google_product (app_code, google_product_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND COLUMN_NAME = 'tier_usd_minor_amount') = 0,
'ALTER TABLE first_recharge_reward_claims ADD COLUMN tier_usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''命中档位美元美分'' AFTER resource_group_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND COLUMN_NAME = 'google_product_id') = 0,
'ALTER TABLE first_recharge_reward_claims ADD COLUMN google_product_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''命中 Google Play 商品 ID'' AFTER tier_usd_minor_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND COLUMN_NAME = 'recharge_usd_minor') = 0,
'ALTER TABLE first_recharge_reward_claims ADD COLUMN recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''本次充值美元美分'' AFTER recharge_coin_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND INDEX_NAME = 'uk_first_recharge_reward_user_once') > 0,
'ALTER TABLE first_recharge_reward_claims DROP INDEX uk_first_recharge_reward_user_once',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND INDEX_NAME = 'uk_first_recharge_reward_user_tier') = 0,
'ALTER TABLE first_recharge_reward_claims ADD UNIQUE KEY uk_first_recharge_reward_user_tier (app_code, user_id, tier_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT
COUNT(*) AS first_recharge_google_tier_count
FROM first_recharge_reward_tiers
WHERE google_product_id <> '';

View File

@ -0,0 +1,107 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- MiFaPay 是 H5 统一三方支付入口;迁移只写国家/支付方式清单,真实商户号、商户标识、商户私钥、平台公钥必须放运行环境配置。
-- 所有截图内方式默认打开;已有正数汇率继续保留,避免迁移覆盖运营已经维护过的 USD->本币汇率。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO third_party_payment_channels (
app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms
) VALUES ('lalu', 'mifapay', 'MiFaPay', 'active', 10, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
provider_name = VALUES(provider_name),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);
-- 存量库可能已经写入过早期测试方式;先关闭不在 MiFaPay 当前支持清单内的方式H5 才不会展示未开通入口。
UPDATE third_party_payment_methods
SET status = 'disabled', updated_at_ms = @now_ms
WHERE app_code = 'lalu'
AND provider_code = 'mifapay'
AND NOT (
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'BCA') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'BNI') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'BRI') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'Mandiri') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'Permata') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'DANA') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'LinkAja') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'OVO') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'ShopeePay') OR
(country_code = 'ID' AND pay_way = 'QR' AND pay_type = 'QRIS') OR
(country_code = 'VN' AND pay_way = 'BankTransfer' AND pay_type = 'VN_BankTransfer') OR
(country_code = 'VN' AND pay_way = 'QR' AND pay_type = 'VietQR') OR
(country_code = 'BH' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'QA' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'OM' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'AE' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'KW' AND pay_way = 'Card' AND pay_type = 'Knet') OR
(country_code = 'SA' AND pay_way = 'Card' AND pay_type = 'MADA') OR
(country_code = 'SA' AND pay_way = 'Ewallet' AND pay_type = 'ApplePay') OR
(country_code = 'SA' AND pay_way = 'Ewallet' AND pay_type = 'STCPay') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'BHIM') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'GooglePay') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'Mobikwik') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'Paytm') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'PhonePe') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'UPI') OR
(country_code = 'BD' AND pay_way = 'Ewallet' AND pay_type = 'bKash') OR
(country_code = 'PK' AND pay_way = 'Ewallet' AND pay_type = 'Easypaisa') OR
(country_code = 'PK' AND pay_way = 'Ewallet' AND pay_type = 'JazzCash') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'FawryPay') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'Meeza') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'Orange') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'Vodafone') OR
(country_code = 'PH' AND pay_way = 'Ewallet' AND pay_type = 'Gcash') OR
(country_code = 'PH' AND pay_way = 'Ewallet' AND pay_type = 'PayMaya') OR
(country_code = 'PH' AND pay_way = 'QR' AND pay_type = 'QRPH')
);
INSERT INTO third_party_payment_methods (
app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type,
method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BCA', 'BCA Bank', '', 'active', 1, 110, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI', 'BNI Bank', '', 'active', 1, 120, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI', 'BRI Bank', '', 'active', 1, 130, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Mandiri', 'Mandiri Bank', '', 'active', 1, 140, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Permata', 'Permata Bank', '', 'active', 1, 150, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'DANA', 'DANA', '', 'active', 1, 160, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'LinkAja', 'LinkAja', '', 'active', 1, 170, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 180, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ShopeePay', 'ShopeePay', '', 'active', 1, 190, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'QR', 'QRIS', 'QRIS', '', 'active', 1, 200, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'BankTransfer', 'VN_BankTransfer', 'Vietnam Bank Transfer', '', 'active', 1, 210, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'QR', 'VietQR', 'VietQR', '', 'active', 1, 220, @now_ms, @now_ms),
('lalu', 'mifapay', 'BH', 'Bahrain', 'BHD', 'Card', 'CreditCard', 'Bahrain Credit Card', '', 'active', 1, 310, @now_ms, @now_ms),
('lalu', 'mifapay', 'QA', 'Qatar', 'QAR', 'Card', 'CreditCard', 'Qatar Credit Card', '', 'active', 1, 410, @now_ms, @now_ms),
('lalu', 'mifapay', 'OM', 'Oman', 'OMR', 'Card', 'CreditCard', 'Oman Credit Card', '', 'active', 1, 510, @now_ms, @now_ms),
('lalu', 'mifapay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CreditCard', 'UAE Credit Card', '', 'active', 1, 610, @now_ms, @now_ms),
('lalu', 'mifapay', 'KW', 'Kuwait', 'KWD', 'Card', 'Knet', 'KNET', '', 'active', 1, 710, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 810, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'ApplePay', 'ApplePay', '', 'active', 1, 820, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPay', 'STCPay', '', 'active', 1, 830, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'BHIM', 'BHIM', '', 'active', 1, 910, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'GooglePay', 'GooglePay', '', 'active', 1, 920, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Mobikwik', 'Mobikwik', '', 'active', 1, 930, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Paytm', 'Paytm', '', 'active', 1, 940, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'PhonePe', 'PhonePe', '', 'active', 1, 950, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'UPI', 'UPI', '', 'active', 1, 960, @now_ms, @now_ms),
('lalu', 'mifapay', 'BD', 'Bangladesh', 'BDT', 'Ewallet', 'bKash', 'bKash', '', 'active', 1, 1010, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'Easypaisa', 'Easypaisa', '', 'active', 1, 1110, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JazzCash', 'JazzCash', '', 'active', 1, 1120, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'FawryPay', 'FawryPay', '', 'active', 1, 1210, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Meeza', 'Meeza', '', 'active', 1, 1220, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Orange', 'Orange', '', 'active', 1, 1230, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Vodafone', 'Vodafone', '', 'active', 1, 1240, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'Gcash', 'GCash', '', 'active', 1, 1310, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'PayMaya', 'PayMaya', '', 'active', 1, 1320, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH', 'QRPH', '', 'active', 1, 1330, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
currency_code = VALUES(currency_code),
method_name = VALUES(method_name),
status = VALUES(status),
usd_to_currency_rate = IF(usd_to_currency_rate > 0, usd_to_currency_rate, VALUES(usd_to_currency_rate)),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);

View File

@ -0,0 +1,104 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- BD/Agency/Host 的区域统一由 users 当前区域或用户国家映射区域派生。
-- 这些身份表里的 region_id 是旧快照,先删除依赖索引,再删除列,避免后续继续写入或误读。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_profiles' AND INDEX_NAME = 'idx_host_profiles_region_status') > 0,
'ALTER TABLE host_profiles DROP INDEX idx_host_profiles_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND INDEX_NAME = 'idx_bd_profiles_region_role') > 0,
'ALTER TABLE bd_profiles DROP INDEX idx_bd_profiles_region_role',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_leader_profiles' AND INDEX_NAME = 'idx_bd_leader_profiles_region_status') > 0,
'ALTER TABLE bd_leader_profiles DROP INDEX idx_bd_leader_profiles_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agencies' AND INDEX_NAME = 'idx_agencies_region_status') > 0,
'ALTER TABLE agencies DROP INDEX idx_agencies_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE host_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE bd_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_leader_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE bd_leader_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agencies' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agencies DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_memberships' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agency_memberships DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_applications' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agency_applications DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE role_invitations DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -48,6 +48,7 @@ import (
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
menumodule "hyapp-admin-server/internal/modules/menu"
paymentmodule "hyapp-admin-server/internal/modules/payment"
prettyidmodule "hyapp-admin-server/internal/modules/prettyid"
rbacmodule "hyapp-admin-server/internal/modules/rbac"
redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
@ -222,21 +223,26 @@ func main() {
objectUploader = uploader
}
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
CPRelation: cprelationmodule.New(userDB, auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
CPRelation: cprelationmodule.New(userDB, auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),
auditHandler,
gamemanagementmodule.WithRobotAvatarUploader(objectUploader, cfg.TencentCOS.ObjectPrefix),
),
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
@ -247,6 +253,7 @@ func main() {
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, auditHandler),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
Report: reportmodule.New(userDB, roomClient),

View File

@ -0,0 +1,637 @@
# 靓号管理服务端技术文档
## 当前结论
靓号管理不能只做成后台页面配置。当前仓库里 `user-service` 已经是 `user_id``display_user_id` 和临时靓号租约的 owner`server/admin` 只能做后台 HTTP 入口、权限、审计和管理适配App 入口继续由 `gateway-service` 暴露。
本需求新增的是“靓号池申请 + 后台发放靓号 + 资料返回靓号字段”,要在现有 `display_user_id` 体系上扩展,不重新引入一套用户 ID。
## 已有事实
- `users.user_id` 是内部不可变用户主键。
- `users.current_display_user_id` 是当前对外展示短号。
- `users.current_display_user_id_kind` 当前支持 `default` / `pretty`
- `users.current_display_user_id_expires_at_ms``0` 或空时表示当前展示号不自动过期。
- `user_display_user_ids` 已保存默认短号和靓号的实时归属。
- `pretty_display_user_id_leases` 已保存靓号覆盖关系。
- 当前默认短号和旧自助靓号校验是 5 到 11 位、不能 0 开头的纯数字。
- 本需求的靓号池批量生成默认仍可使用数字规则;后台发放靓号不能复用纯数字限制,需要支持阿拉伯语、英文和数字组合。
- 当前 App 端已有 `/api/v1/users/me/display-id/pretty/apply`,但参数是 `pretty_display_user_id + lease_duration_ms + payment_receipt_id`,不是靓号池申请要的 `pretty_id`
## 目标
- 后台在“用户管理”下新增二级菜单“靓号管理”。
- 后台可以配置靓号池的等级轨道、等级区间、生成规则和生成数量。
- 后台批量生成靓号时,不能和当前库里的 `users.user_id`、默认短号、当前短号、历史有效短号、已有靓号池号码冲突。
- 每个靓号池号码都有一个稳定 `pretty_id`App 靓号池申请时只传 `pretty_id`
- 靓号池申请默认长期持有;用户已有靓号时,新靓号直接覆盖旧靓号,旧靓号释放。
- 后台发放靓号可以直接输入靓号内容,可以填写有效期;不填有效期时默认长期持有。
- 后台发放靓号内容不限定纯数字,需要支持阿拉伯语、英文和数字组合。
- App 用户申请靓号池号码时,服务端根据靓号池配置校验用户是否在对应等级区间内,在区间内才允许占用。
- 用户拥有靓号后,个人信息、个人资料卡、房间个人资料卡、房间详情等资料返回都带当前靓号字段。
## 非目标
- 不把靓号作为内部主键使用,所有业务关系仍然使用 `user_id`
- 不让 `room-service` 保存靓号状态。房间只保存房间状态和用户 `user_id`,展示资料由 gateway 汇总用户资料。
- 不让 `server/admin` 直接 import app 后端 `services/*``pkg/*`
- 首版不做等级下降后的自动回收。用户申请时校验等级区间;后续如果需要降级回收,再新增独立回收规则和定时任务。
- 后台发放不需要等级校验,管理员只受后台权限和审计约束。
- 首版不对外推送 IM。靓号申请成功后客户端以接口返回和下次资料刷新为准。
## 服务边界
| 模块 | 责任 |
| --- | --- |
| `user-service` | 靓号池、靓号项、后台发放、替换释放事务、短号唯一性、当前展示号覆盖、资料字段 |
| `activity-service` | 提供用户等级展示投影,至少能按 `user_id` 返回 `wealth/game/charm` 当前等级 |
| `gateway-service` | App HTTP 入口,按 token 用户申请靓号,返回统一 envelope |
| `server/admin` | 后台 HTTP API、菜单、权限、审计调用 `hyapp.local/api` protobuf client |
| `hyapp-admin-platform` | 用户管理下新增“靓号管理”页面,消费后台 API |
## 等级区间规则
当前等级体系有三条轨道:
| track | 含义 |
| --- | --- |
| `wealth` | 财富等级 |
| `game` | 游戏等级 |
| `charm` | 魅力等级 |
靓号池配置必须保存 `level_track`,不能在服务端猜默认轨道。后台页面可以默认选中 `wealth`,但请求和数据库必须明确保存轨道。
靓号池申请时:
1. 根据 `pretty_id` 锁定靓号项和所属靓号池。
2. 读取用户当前等级,使用池子的 `level_track`
3. 判断 `min_level <= current_level <= max_level`
4. 通过后进入靓号占用事务。
## 数据库设计
新增表归属 `hyapp_user`,因为靓号和短号唯一性属于 `user-service`
### 现有短号表扩展
后台发放的靓号不一定是数字,现有短号相关字段需要从数字短号能力扩展成“展示号字符串”能力:
```sql
ALTER TABLE users
MODIFY COLUMN current_display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN default_display_user_id VARCHAR(64) NOT NULL;
ALTER TABLE user_display_user_ids
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE
WHEN status IN ('active', 'held', 'reserved', 'blocked') THEN display_user_id
ELSE NULL
END
) STORED;
ALTER TABLE pretty_display_user_id_leases
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN active_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN display_user_id ELSE NULL END
) STORED,
ADD COLUMN released_at_ms BIGINT NOT NULL DEFAULT 0 AFTER expires_at_ms,
ADD COLUMN release_reason VARCHAR(64) NOT NULL DEFAULT '' AFTER released_at_ms;
ALTER TABLE display_user_id_change_logs
MODIFY COLUMN old_display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN new_display_user_id VARCHAR(64) NOT NULL;
```
规则:
- 默认短号仍然按现有数字规则生成。
- 靓号池批量生成默认仍然按数字规则生成。
- 后台发放靓号允许 1 到 64 个字符。
- 后台发放靓号允许 Unicode 字母、Unicode 数字和组合标记,用于支持阿拉伯语、英文和数字。
- 后台发放靓号不允许空白字符、控制字符、emoji 和标点符号。
- 唯一性仍按 `app_code + display_user_id` 判断;如果产品要求大小写敏感,相关唯一列需要改成 `utf8mb4_bin` 或在写入前做明确大小写归一。
### `pretty_display_id_pools`
保存后台配置的等级区间和默认生成规则。
```sql
CREATE TABLE pretty_display_id_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
pool_id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
level_track VARCHAR(32) NOT NULL,
min_level INT NOT NULL,
max_level INT NOT NULL,
rule_type VARCHAR(32) NOT NULL,
rule_config_json JSON NULL,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_pretty_pool_range_rule (app_code, level_track, min_level, max_level, name),
KEY idx_pretty_pool_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池配置表';
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `pool_id` | 靓号池 ID |
| `level_track` | `wealth` / `game` / `charm` |
| `min_level` | 可申请最小等级,包含 |
| `max_level` | 可申请最大等级,包含 |
| `rule_type` | 生成规则,例如 `aabbcc` |
| `rule_config_json` | 长度、号段、排除数字等扩展配置 |
| `status` | `active` / `disabled` |
约束:
- `min_level` 不能大于 `max_level`
- `level_track` 必须是已有等级轨道。
- `active` 池才允许 App 用户申请。
### `pretty_display_ids`
保存每一个可申请靓号。每个靓号都有自己的 `pretty_id`
```sql
CREATE TABLE pretty_display_ids (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
pretty_id VARCHAR(64) NOT NULL PRIMARY KEY,
pool_id VARCHAR(64) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT 'pool',
display_user_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
assigned_user_id BIGINT NOT NULL DEFAULT 0,
assigned_lease_id VARCHAR(64) NOT NULL DEFAULT '',
assigned_at_ms BIGINT NOT NULL DEFAULT 0,
released_at_ms BIGINT NOT NULL DEFAULT 0,
release_reason VARCHAR(64) NOT NULL DEFAULT '',
generated_batch_id VARCHAR(64) NOT NULL DEFAULT '',
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status IN ('available', 'assigned', 'disabled', 'reserved') THEN display_user_id ELSE NULL END
) STORED,
UNIQUE KEY uk_pretty_display_ids_live (app_code, live_display_user_id),
KEY idx_pretty_display_ids_pool_status (app_code, pool_id, status),
KEY idx_pretty_display_ids_assigned_user (app_code, assigned_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池号码表';
```
状态说明:
| status | 说明 |
| --- | --- |
| `available` | 可申请 |
| `assigned` | 已被用户占用 |
| `disabled` | 后台下架,不允许申请 |
| `reserved` | 系统预留,不允许普通用户申请 |
| `released` | 曾经发放或占用,已释放,不参与可申请列表 |
| `deleted` | 软删除,只保留审计 |
`source` 说明:
| source | 说明 |
| --- | --- |
| `pool` | 靓号池生成,释放后可以回到 `available` |
| `admin_grant` | 后台直接发放,释放后默认进入 `released` |
### `pretty_display_id_generation_batches`
保存批量生成记录,方便后台展示和排查。
```sql
CREATE TABLE pretty_display_id_generation_batches (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
batch_id VARCHAR(64) NOT NULL PRIMARY KEY,
pool_id VARCHAR(64) NOT NULL,
rule_type VARCHAR(32) NOT NULL,
rule_config_json JSON NULL,
requested_count INT NOT NULL,
generated_count INT NOT NULL,
skipped_conflict_count INT NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
KEY idx_pretty_batch_pool (app_code, pool_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号批量生成记录表';
```
## 靓号池生成规则
靓号池批量生成支持数字规则、日期规则、寓意号和自定义规则。数字规则允许前导 0号码以字符串保存。
| rule_type | 说明 |
| --- | --- |
| `aa``aaa``aaaa``aaaaa``aaaaaa``aaaaaaa` | 2A 到 7A 连号 |
| `aabb` | 两组对子 |
| `aabbcc` | 三组重复数字 |
| `aabbccdd` | 四组对子 |
| `abab``ababab` | 交替号 |
| `abba``abcba``abccba` | 回文号 |
| `aaab``aaaab``aaaaab` | 拖一号 |
| `aaabbb``aaaabbbb` | 双组号 |
| `abc``abcd``abcde``abcdef` | 顺子号 |
| `cba``dcba``edcba``fedcba` | 倒顺号 |
| `abcabc` | 顺子重复号 |
| `date_yyyymmdd``date_yymmdd` | 日期号 |
| `love` | 520、521、1314 等寓意号 |
| `custom_values` | 使用 `rule_config_json.values` 固定候选 |
| `custom_pattern` | 使用 `rule_config_json.pattern` 自定义模式 |
`rule_config_json` 可选字段:
| 字段 | 说明 |
| --- | --- |
| `digits` | 限制可用数字,例如 `["6","8","9"]` |
| `exclude_digits` | 排除数字,例如 `["4"]` |
| `prefix``suffix` | 给生成候选加前缀或后缀 |
| `values` | `custom_values` 的候选数组 |
| `pattern` | `custom_pattern` 的模式,例如 `AAAABBBB` |
| `year_from``year_to` | 日期号年份范围 |
| `start_date``end_date` | 日期号精确范围,格式 `yyyy-mm-dd` |
后台直接发放靓号不受本节数字生成规则限制,按“现有短号表扩展”里的后台发放字符规则校验。
## 批量生成事务
批量生成时由 `user-service` 完成冲突检查和入库。
冲突范围:
- `CAST(users.user_id AS CHAR)`
- `users.default_display_user_id`
- `users.current_display_user_id`
- `user_display_user_ids``status IN ('active','held','reserved','blocked')` 的号码。
- `pretty_display_ids``status IN ('available','assigned','disabled','reserved')` 的号码。
流程:
1. 后台提交 `pool_id + rule_type + rule_config + count`
2. `user-service` 生成候选号码。
3. 每个候选号码先做数字短号格式校验。
4. 批量查询冲突号码,过滤已冲突项。
5. 插入 `pretty_display_ids`,唯一键兜底处理并发冲突。
6. 写 `pretty_display_id_generation_batches`
7. 返回本次生成数量和跳过冲突数量。
为了避免生成器死循环,服务端需要设置最大尝试次数,例如 `count * 20`,仍不足时返回实际生成数量和 `skipped_conflict_count`
## 通用替换释放事务
靓号池申请和后台发放都复用同一个“替换当前靓号”事务。
如果用户没有 active pretty 展示号:
1. 默认短号从 `active` 改为 `held`
2. 新靓号写入 `pretty_display_user_id_leases`
3. 新靓号写入 `user_display_user_ids``display_user_id_kind='pretty'`
4. 更新 `users.current_display_user_id``current_display_user_id_kind``current_display_user_id_expires_at_ms`
5. 写 `display_user_id_change_logs`
如果用户已有 active pretty 展示号:
1. 锁定当前用户行和旧 active lease。
2. 旧 `pretty_display_user_id_leases.status` 改为 `cancelled``release_reason='replaced_by_new_pretty'`
3. 旧 `user_display_user_ids.status` 改为 `released`,记录 `released_at_ms`
4. 如果旧靓号来自靓号池,旧 `pretty_display_ids.status` 改回 `available`,清空 `assigned_user_id``assigned_lease_id``assigned_at_ms`
5. 如果旧靓号来自后台发放,旧 `pretty_display_ids.status` 改为 `released`,保留历史。
6. 写旧靓号释放日志。
7. 再按新靓号写入当前 active 展示号。
这个规则意味着:用户申请或被后台发放新靓号时,不返回“已有靓号”冲突,而是直接覆盖旧靓号。
## 靓号池申请事务
App 靓号池申请只接受 `pretty_id`,不接受客户端自报号码、等级、用户 ID 或租期。
事务流程:
1. 从 access token 读取当前 `user_id`
2. `SELECT ... FOR UPDATE` 锁定 `pretty_display_ids`
3. 校验靓号项为 `available``source='pool'`
4. 锁定所属 `pretty_display_id_pools`,校验为 `active`
5. 调用 `activity-service` 读取当前用户在 `level_track` 下的等级。
6. 校验用户等级在 `[min_level, max_level]`
7. 进入通用替换释放事务。
8. 写新 `pretty_display_user_id_leases``source='level_pool'``expires_at_ms=0`
9. 更新新 `pretty_display_ids.status='assigned'`,写入 `assigned_user_id``assigned_lease_id`
10. 返回当前身份投影。
靓号池申请默认长期持有:
- `pretty_display_user_id_leases.expires_at_ms = 0`
- `users.current_display_user_id_expires_at_ms = 0`
- 不由定时任务自动过期。
## 后台发放事务
后台发放不依赖靓号池,也不校验用户等级。
输入:
- `target_user_id`:目标 App 用户。
- `display_user_id`:管理员输入的靓号内容。
- `duration_ms`:可选;大于 0 表示限时靓号,不传或传 0 表示长期。
- `reason`:发放原因,写入审计和 change log。
事务流程:
1. 校验管理员有 `pretty-id:grant` 权限。
2. 校验 `display_user_id` 符合后台发放字符规则。
3. 检查 `display_user_id` 不与当前有效 `users.user_id` 字符串、默认短号、当前短号、历史有效短号、已有靓号冲突。
4. 锁定目标用户行。
5. 进入通用替换释放事务。
6. 生成新的 `pretty_id``lease_id`
7. 写 `pretty_display_ids``source='admin_grant'``status='assigned'`
8. 写 `pretty_display_user_id_leases``source='admin_grant'`
9. 如果 `duration_ms > 0``expires_at_ms = now_ms + duration_ms`;否则 `expires_at_ms = 0`
10. 更新 `users.current_display_user_id_expires_at_ms` 为同一个过期时间。
11. 返回当前身份投影。
限时后台靓号:
- `expires_at_ms > 0` 时继续复用现有靓号过期恢复逻辑。
- 到期后恢复默认短号,不回到靓号池。
- 如果到期前又发放或申请了新靓号,旧限时靓号按通用替换释放事务取消。
## Proto 变更
`api/proto/user/v1/user.proto` 中扩展或新增 identity 管理 RPC。
新增 App 申请 RPC
```proto
message ApplyPrettyDisplayIDFromPoolRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string pretty_id = 3;
}
message ApplyPrettyDisplayIDFromPoolResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
```
新增后台管理 RPC
```proto
message AdminGrantPrettyDisplayIDRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string display_user_id = 3;
int64 duration_ms = 4;
string reason = 5;
int64 operator_admin_id = 6;
}
message AdminGrantPrettyDisplayIDResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
service UserPrettyDisplayIDAdminService {
rpc ListPrettyDisplayIDPools(ListPrettyDisplayIDPoolsRequest) returns (ListPrettyDisplayIDPoolsResponse);
rpc CreatePrettyDisplayIDPool(CreatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc UpdatePrettyDisplayIDPool(UpdatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc GeneratePrettyDisplayIDs(GeneratePrettyDisplayIDsRequest) returns (GeneratePrettyDisplayIDsResponse);
rpc ListPrettyDisplayIDs(ListPrettyDisplayIDsRequest) returns (ListPrettyDisplayIDsResponse);
rpc SetPrettyDisplayIDStatus(SetPrettyDisplayIDStatusRequest) returns (PrettyDisplayIDResponse);
rpc AdminGrantPrettyDisplayID(AdminGrantPrettyDisplayIDRequest) returns (AdminGrantPrettyDisplayIDResponse);
}
```
资料投影建议增加字段:
```proto
message User {
// existing fields...
string pretty_id = 24;
string pretty_display_user_id = 25;
}
message UserIdentity {
// existing fields...
string pretty_id = 8;
string pretty_display_user_id = 9;
}
```
字段规则:
| 字段 | 说明 |
| --- | --- |
| `display_user_id` | 当前对外展示号;有靓号时等于靓号 |
| `display_user_id_kind` | `default` / `pretty` |
| `display_user_id_expires_at_ms` | 长期持有为 `0` |
| `pretty_id` | 当前占用的靓号池记录 ID没有则为空 |
| `pretty_display_user_id` | 当前占用的靓号号码,没有则为空 |
## 后台 HTTP 接口
后台接口归属 `server/admin/internal/modules/prettyid`,路径放在用户管理域。
### 靓号池列表
| 项 | 内容 |
| --- | --- |
| 地址 | `GET /api/v1/admin/users/pretty-id-pools` |
| 参数 | `status``level_track``page``page_size` |
| 返回值 | `items[]``total`,每项包含 `pool_id``name``level_track``min_level``max_level``rule_type``status` |
| 相关 IM | 无 |
### 创建靓号池
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-id-pools` |
| 参数 | `name``level_track``min_level``max_level``rule_type``rule_config_json``status` |
| 返回值 | 靓号池详情 |
| 相关 IM | 无 |
### 更新靓号池
| 项 | 内容 |
| --- | --- |
| 地址 | `PUT /api/v1/admin/users/pretty-id-pools/{pool_id}` |
| 参数 | `name``level_track``min_level``max_level``rule_type``rule_config_json``status` |
| 返回值 | 靓号池详情 |
| 相关 IM | 无 |
### 批量生成靓号
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-id-pools/{pool_id}/generate` |
| 参数 | `rule_type``rule_config_json``count` |
| 返回值 | `batch_id``requested_count``generated_count``skipped_conflict_count` |
| 相关 IM | 无 |
### 靓号列表
| 项 | 内容 |
| --- | --- |
| 地址 | `GET /api/v1/admin/users/pretty-ids` |
| 参数 | `pool_id``source``status``keyword``assigned_user_id``page``page_size` |
| 返回值 | `items[]``total`,每项包含 `pretty_id``source``display_user_id``pool``status``assigned_user_id``assigned_lease_id` |
| 相关 IM | 无 |
### 后台发放靓号
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-ids/grant` |
| 参数 | `target_user_id``display_user_id``duration_ms``reason``duration_ms` 不传或为 `0` 表示长期 |
| 返回值 | `user_id``display_user_id``display_user_id_kind``display_user_id_expires_at_ms``pretty_id``pretty_display_user_id``lease_id` |
| 相关 IM | 无 |
### 靓号状态修改
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-ids/{pretty_id}/status` |
| 参数 | `status`,只允许未占用的靓号在 `available``disabled``reserved` 之间切换 |
| 返回值 | 靓号详情 |
| 相关 IM | 无 |
## App HTTP 接口
### 可申请靓号列表
| 项 | 内容 |
| --- | --- |
| 地址 | `GET /api/v1/users/pretty-ids/available` |
| 参数 | `page``page_size` |
| 返回值 | `items[]``total`,每项包含 `pretty_id``display_user_id``level_track``min_level``max_level` |
| 相关 IM | 无 |
返回只包含当前用户等级满足条件的 `available` 靓号。
### 靓号池申请
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/users/me/display-id/pretty/apply` |
| 参数 | `pretty_id` |
| 返回值 | `user_id``display_user_id``display_user_id_kind``display_user_id_expires_at_ms``pretty_id``pretty_display_user_id``lease_id` |
| 相关 IM | 无 |
说明:
- 保留当前地址,避免前端路由重复;请求体改为优先读取 `pretty_id`
- 靓号池申请默认长期持有,`display_user_id_expires_at_ms=0`
- 用户已有靓号时,新靓号直接覆盖旧靓号,旧靓号释放。
- 如果要兼容旧付费参数,服务端可以临时保留 `pretty_display_user_id + lease_duration_ms + payment_receipt_id` 分支,但新 App 只使用 `pretty_id`
## 资料返回改造
所有资料返回统一使用同一套字段。
| 场景 | 当前链路 | 改造点 |
| --- | --- | --- |
| 个人信息 | `GET /api/v1/users/me` -> `user-service.GetUser` | `User` proto 增加 `pretty_id``pretty_display_user_id` |
| 个人资料卡 | 用户资料批量接口或资料详情 | 复用 `User` 投影字段 |
| 房间个人资料卡 | `GET /api/v1/users/room-display-profiles:batch` | `roomDisplayProfileData` 增加靓号字段 |
| 房间详情 | `room detail` 中的 `profiles[]` | 来自同一批 `roomDisplayProfileData` |
| 登录 / 刷新 token | `AuthToken` 已有 display 字段 | 如客户端需要启动后立即知道靓号,补 `pretty_id``pretty_display_user_id` |
响应规则:
- 用户没有靓号:`display_user_id` 返回默认短号,`display_user_id_kind='default'``pretty_id=''``pretty_display_user_id=''`
- 用户有靓号:`display_user_id` 返回靓号,`display_user_id_kind='pretty'``pretty_id` 返回池记录 ID`pretty_display_user_id` 返回靓号号码。
- 长期持有靓号:`display_user_id_expires_at_ms=0`
- 后台限时发放靓号:`display_user_id_expires_at_ms` 返回实际过期时间。
## 后台菜单和权限
新增权限:
| 权限码 | kind | 说明 |
| --- | --- | --- |
| `pretty-id:view` | `menu` | 查看靓号管理 |
| `pretty-id:update` | `button` | 新增、编辑、上下架靓号池和靓号 |
| `pretty-id:generate` | `button` | 批量生成靓号 |
| `pretty-id:grant` | `button` | 后台直接发放靓号 |
新增菜单:
| 字段 | 值 |
| --- | --- |
| parent | `app-users` |
| title | `靓号管理` |
| code | `app-user-pretty-ids` |
| path | `/app/users/pretty-ids` |
| icon | `badge` |
| permission_code | `pretty-id:view` |
| sort | `65` |
需要同步:
- `server/admin/migrations/044_pretty_id_management.sql`
- `server/admin/internal/repository/seed.go` 默认权限和菜单。
- `platform-admin` 默认绑定全部新增权限。
- 只读角色只绑定 `pretty-id:view`
## 代码落点
| 位置 | 改动 |
| --- | --- |
| `api/proto/user/v1/user.proto` | 增加靓号池 RPC、后台发放 RPC、资料字段 |
| `services/user-service/internal/domain/user` | 增加 pool、pretty item、申请命令、后台发放命令和非数字靓号校验 |
| `services/user-service/internal/storage/mysql/identity` | 增加靓号池、生成、申请、后台发放、替换释放事务 |
| `services/user-service/deploy/mysql/initdb/001_user_service.sql` | 增加新表并扩展短号字段长度 |
| `services/user-service/migrations` | 增加线上迁移 |
| `services/gateway-service/internal/transport/http/userapi` | 改造靓号池申请接口、可申请列表、资料字段 |
| `services/gateway-service/internal/transport/http/roomapi` | 房间资料结构补靓号字段 |
| `server/admin/internal/integration/userclient` | 增加后台靓号管理 client |
| `server/admin/internal/modules/prettyid` | 新增后台模块 |
| `server/admin/internal/router/router.go` | 注册后台模块 |
| `server/admin/migrations` | 菜单和权限迁移 |
## 错误码建议
| code | 场景 |
| --- | --- |
| `PRETTY_ID_NOT_FOUND` | `pretty_id` 不存在 |
| `PRETTY_ID_NOT_AVAILABLE` | 靓号不可申请或已被占用 |
| `PRETTY_ID_LEVEL_NOT_MATCH` | 用户等级不在池区间内 |
| `PRETTY_DISPLAY_ID_INVALID` | 后台发放的靓号内容不符合字符规则 |
| `DISPLAY_USER_ID_CONFLICT` | 生成或申请时发现短号冲突 |
## 验证项
必须验证:
- `make proto` 成功,生成 `.pb.go``_grpc.pb.go`
- `go test ./...``hyapp-server` 根目录通过。
- `go test ./...``hyapp-server/server/admin` 通过。
- 批量生成 1000 个 `aabbcc` 靓号时,不插入与 `users.user_id`、默认短号、当前短号、已有靓号冲突的号码。
- 用户等级低于池区间时,申请返回 `PRETTY_ID_LEVEL_NOT_MATCH`
- 用户等级在池区间内时,申请成功并更新:
- `pretty_display_ids.status='assigned'`
- `pretty_display_user_id_leases.source='pool'`
- `user_display_user_ids.display_user_id_kind='pretty'`
- `users.current_display_user_id_kind='pretty'`
- 用户已有靓号时,再申请另一个靓号会覆盖旧靓号,旧靓号释放;旧池靓号回到 `available`,旧后台发放靓号进入 `released`
- 后台发放 `VIP2026``ملك123` 这类英文数字或阿拉伯语数字组合可以成功。
- 后台发放不传 `duration_ms` 时,返回 `display_user_id_expires_at_ms=0`
- 后台发放传 `duration_ms` 时,返回实际 `display_user_id_expires_at_ms`,到期后恢复默认短号。
- `GET /api/v1/users/me` 返回靓号字段。
- `GET /api/v1/users/room-display-profiles:batch` 返回靓号字段。
- 房间详情 `profiles[]` 返回靓号字段。
- 后台 `/api/v1/navigation/menus` 能看到“用户管理 > 靓号管理”。

View File

@ -16,6 +16,13 @@ type Client interface {
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error)
AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error)
ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error)
RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error)
DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error)
}
type GRPCClient struct {
@ -49,3 +56,32 @@ func (c *GRPCClient) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatu
func (c *GRPCClient) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) {
return c.client.DeleteCatalog(ctx, req)
}
func (c *GRPCClient) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
return c.client.ListSelfGames(ctx, req)
}
func (c *GRPCClient) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
return c.client.UpdateDiceConfig(ctx, req)
}
func (c *GRPCClient) AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error) {
return c.client.AdjustDicePool(ctx, req)
}
func (c *GRPCClient) ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error) {
return c.client.ListDiceRobots(ctx, req)
}
func (c *GRPCClient) RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) {
return c.client.RegisterDiceRobots(ctx, req)
}
func (c *GRPCClient) SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error) {
return c.client.SetDiceRobotStatus(ctx, req)
}
// DeleteDiceRobot 透传后台删除机器人登记 RPCadmin-server 不直接操作 game-service 的机器人表。
func (c *GRPCClient) DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error) {
return c.client.DeleteDiceRobot(ctx, req)
}

View File

@ -12,9 +12,17 @@ import (
type Client interface {
GetUser(ctx context.Context, req GetUserRequest) (*User, error)
BatchGetUsers(ctx context.Context, req BatchGetUsersRequest) (map[int64]*User, error)
AdminChangeUserCountry(ctx context.Context, req AdminChangeUserCountryRequest) (*ChangeUserCountryResult, error)
SetUserStatus(ctx context.Context, req SetUserStatusRequest) (*SetUserStatusResult, error)
ResolveDisplayUserID(ctx context.Context, req ResolveDisplayUserIDRequest) (*UserIdentity, error)
ListPrettyDisplayIDPools(ctx context.Context, req ListPrettyDisplayIDPoolsRequest) ([]*PrettyDisplayIDPool, int64, error)
CreatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error)
UpdatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error)
GeneratePrettyDisplayIDs(ctx context.Context, req GeneratePrettyDisplayIDsRequest) (*PrettyDisplayIDGenerationBatch, error)
ListPrettyDisplayIDs(ctx context.Context, req ListPrettyDisplayIDsRequest) ([]*PrettyDisplayID, int64, error)
SetPrettyDisplayIDStatus(ctx context.Context, req SetPrettyDisplayIDStatusRequest) (*PrettyDisplayID, error)
AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error)
ListCountries(ctx context.Context, req ListCountriesRequest) ([]*Country, error)
UpdateCountry(ctx context.Context, req UpdateCountryRequest) (*Country, error)
ListRegions(ctx context.Context, req ListRegionsRequest) ([]*Region, error)
@ -31,6 +39,7 @@ type Client interface {
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
DeleteAgency(ctx context.Context, req DeleteAgencyRequest) (*Agency, error)
SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoinEnabledRequest) (*Agency, error)
QuickCreateAccount(ctx context.Context, req QuickCreateAccountRequest) (*QuickCreateAccountResult, error)
}
type GetUserRequest struct {
@ -39,6 +48,35 @@ type GetUserRequest struct {
UserID int64
}
// BatchGetUsersRequest 只承载后台展示需要的一组真实 user_id不在 admin-server 里回查用户库。
type BatchGetUsersRequest struct {
RequestID string
Caller string
UserIDs []int64
}
type QuickCreateAccountRequest struct {
RequestID string
Caller string
Password string
Username string
Avatar string
Gender string
Country string
DeviceID string
Source string
InstallChannel string
Platform string
Language string
Timezone string
}
type QuickCreateAccountResult struct {
UserID int64
DisplayUserID string
AccessToken string
}
type SetUserStatusRequest struct {
RequestID string
Caller string
@ -92,6 +130,8 @@ type UserIdentity struct {
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserIDKind string `json:"displayUserIdKind"`
DisplayUserIDExpiresAtMs int64 `json:"displayUserIdExpiresAtMs"`
PrettyID string `json:"prettyId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
AppCode string `json:"appCode"`
}
@ -104,6 +144,8 @@ type User struct {
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserIDKind string `json:"displayUserIdKind"`
DisplayUserIDExpiresAtMs int64 `json:"displayUserIdExpiresAtMs"`
PrettyID string `json:"prettyId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
Username string `json:"username"`
Gender string `json:"gender"`
Country string `json:"country"`
@ -126,9 +168,11 @@ type User struct {
type GRPCClient struct {
client userv1.UserServiceClient
authClient userv1.AuthServiceClient
identityClient userv1.UserIdentityServiceClient
countryClient userv1.CountryAdminServiceClient
regionClient userv1.RegionAdminServiceClient
prettyClient userv1.UserPrettyDisplayIDAdminServiceClient
hostClient userv1.UserHostServiceClient
hostAdminClient userv1.UserHostAdminServiceClient
}
@ -136,14 +180,50 @@ type GRPCClient struct {
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{
client: userv1.NewUserServiceClient(conn),
authClient: userv1.NewAuthServiceClient(conn),
identityClient: userv1.NewUserIdentityServiceClient(conn),
countryClient: userv1.NewCountryAdminServiceClient(conn),
regionClient: userv1.NewRegionAdminServiceClient(conn),
prettyClient: userv1.NewUserPrettyDisplayIDAdminServiceClient(conn),
hostClient: userv1.NewUserHostServiceClient(conn),
hostAdminClient: userv1.NewUserHostAdminServiceClient(conn),
}
}
func (c *GRPCClient) QuickCreateAccount(ctx context.Context, req QuickCreateAccountRequest) (*QuickCreateAccountResult, error) {
resp, err := c.authClient.QuickCreateAccount(ctx, &userv1.QuickCreateAccountRequest{
Meta: &userv1.RequestMeta{
RequestId: req.RequestID,
Caller: req.Caller,
SentAtMs: time.Now().UnixMilli(),
AppCode: appctx.FromContext(ctx),
Platform: req.Platform,
Language: req.Language,
Timezone: req.Timezone,
},
Password: req.Password,
Username: req.Username,
Avatar: req.Avatar,
Gender: req.Gender,
Country: req.Country,
DeviceId: req.DeviceID,
Source: req.Source,
InstallChannel: req.InstallChannel,
Platform: req.Platform,
Language: req.Language,
Timezone: req.Timezone,
})
if err != nil {
return nil, err
}
token := resp.GetToken()
return &QuickCreateAccountResult{
UserID: token.GetUserId(),
DisplayUserID: token.GetDisplayUserId(),
AccessToken: token.GetAccessToken(),
}, nil
}
func (c *GRPCClient) GetUser(ctx context.Context, req GetUserRequest) (*User, error) {
resp, err := c.client.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
@ -160,6 +240,22 @@ func (c *GRPCClient) GetUser(ctx context.Context, req GetUserRequest) (*User, er
return fromProtoUser(resp.GetUser()), nil
}
// BatchGetUsers 复用 user-service 主数据 RPC让后台列表只做展示聚合不复制用户资料到游戏库。
func (c *GRPCClient) BatchGetUsers(ctx context.Context, req BatchGetUsersRequest) (map[int64]*User, error) {
resp, err := c.client.BatchGetUsers(ctx, &userv1.BatchGetUsersRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
UserIds: req.UserIDs,
})
if err != nil {
return nil, err
}
users := make(map[int64]*User, len(resp.GetUsers()))
for userID, user := range resp.GetUsers() {
users[userID] = fromProtoUser(user)
}
return users, nil
}
func (c *GRPCClient) AdminChangeUserCountry(ctx context.Context, req AdminChangeUserCountryRequest) (*ChangeUserCountryResult, error) {
resp, err := c.client.AdminChangeUserCountry(ctx, &userv1.AdminChangeUserCountryRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
@ -243,6 +339,8 @@ func fromProtoUser(user *userv1.User) *User {
DefaultDisplayUserID: user.GetDefaultDisplayUserId(),
DisplayUserIDKind: user.GetDisplayUserIdKind(),
DisplayUserIDExpiresAtMs: user.GetDisplayUserIdExpiresAtMs(),
PrettyID: user.GetPrettyId(),
PrettyDisplayUserID: user.GetPrettyDisplayUserId(),
Username: user.GetUsername(),
Gender: user.GetGender(),
Country: user.GetCountry(),
@ -288,6 +386,8 @@ func fromProtoUserIdentity(identity *userv1.UserIdentity) *UserIdentity {
DefaultDisplayUserID: identity.GetDefaultDisplayUserId(),
DisplayUserIDKind: identity.GetDisplayUserIdKind(),
DisplayUserIDExpiresAtMs: identity.GetDisplayUserIdExpiresAtMs(),
PrettyID: identity.GetPrettyId(),
PrettyDisplayUserID: identity.GetPrettyDisplayUserId(),
AppCode: identity.GetAppCode(),
}
}

View File

@ -0,0 +1,328 @@
package userclient
import (
"context"
userv1 "hyapp.local/api/proto/user/v1"
)
type PrettyDisplayIDPool struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
Name string `json:"name"`
LevelTrack string `json:"level_track"`
MinLevel int32 `json:"min_level"`
MaxLevel int32 `json:"max_level"`
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedByAdminID int64 `json:"created_by_admin_id,string"`
UpdatedByAdminID int64 `json:"updated_by_admin_id,string"`
CreatedAtMs int64 `json:"created_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
type PrettyDisplayID struct {
AppCode string `json:"app_code"`
PrettyID string `json:"pretty_id"`
PoolID string `json:"pool_id"`
Source string `json:"source"`
DisplayUserID string `json:"display_user_id"`
Status string `json:"status"`
AssignedUserID int64 `json:"assigned_user_id,string"`
AssignedLeaseID string `json:"assigned_lease_id"`
AssignedAtMs int64 `json:"assigned_at_ms"`
ReleasedAtMs int64 `json:"released_at_ms"`
ReleaseReason string `json:"release_reason"`
GeneratedBatchID string `json:"generated_batch_id"`
CreatedByAdminID int64 `json:"created_by_admin_id,string"`
CreatedAtMs int64 `json:"created_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
Pool *PrettyDisplayIDPool `json:"pool,omitempty"`
}
type PrettyDisplayIDGenerationBatch struct {
AppCode string `json:"app_code"`
BatchID string `json:"batch_id"`
PoolID string `json:"pool_id"`
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
RequestedCount int32 `json:"requested_count"`
GeneratedCount int32 `json:"generated_count"`
SkippedConflictCount int32 `json:"skipped_conflict_count"`
Status string `json:"status"`
OperatorAdminID int64 `json:"operator_admin_id,string"`
RequestID string `json:"request_id"`
CreatedAtMs int64 `json:"created_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
type ListPrettyDisplayIDPoolsRequest struct {
RequestID string
Caller string
Status string
LevelTrack string
Page int32
PageSize int32
}
type PrettyDisplayIDPoolRequest struct {
RequestID string
Caller string
PoolID string
Name string
LevelTrack string
MinLevel int32
MaxLevel int32
RuleType string
RuleConfigJSON string
Status string
SortOrder int32
OperatorAdminID int64
}
type GeneratePrettyDisplayIDsRequest struct {
RequestID string
Caller string
PoolID string
RuleType string
RuleConfigJSON string
Count int32
OperatorAdminID int64
}
type ListPrettyDisplayIDsRequest struct {
RequestID string
Caller string
PoolID string
Source string
Status string
Keyword string
AssignedUserID int64
Page int32
PageSize int32
}
type SetPrettyDisplayIDStatusRequest struct {
RequestID string
Caller string
PrettyID string
Status string
Reason string
OperatorAdminID int64
}
type AdminGrantPrettyDisplayIDRequest struct {
RequestID string
Caller string
TargetUserID int64
DisplayUserID string
DurationMs int64
Reason string
OperatorAdminID int64
}
type AdminGrantPrettyDisplayIDResult struct {
Identity *UserIdentity `json:"identity"`
PrettyID string `json:"pretty_id"`
LeaseID string `json:"lease_id"`
}
// 以下方法只做 admin-server 到 user-service 的传输适配HTTP 层 DTO 保持 snake_casegRPC 层字段按 proto 命名传递。
func (c *GRPCClient) ListPrettyDisplayIDPools(ctx context.Context, req ListPrettyDisplayIDPoolsRequest) ([]*PrettyDisplayIDPool, int64, error) {
resp, err := c.prettyClient.ListPrettyDisplayIDPools(ctx, &userv1.ListPrettyDisplayIDPoolsRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
Status: req.Status,
LevelTrack: req.LevelTrack,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return nil, 0, err
}
items := make([]*PrettyDisplayIDPool, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, fromProtoPrettyDisplayIDPool(item))
}
return items, resp.GetTotal(), nil
}
func (c *GRPCClient) CreatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error) {
resp, err := c.prettyClient.CreatePrettyDisplayIDPool(ctx, &userv1.CreatePrettyDisplayIDPoolRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJson: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayIDPool(resp.GetPool()), nil
}
func (c *GRPCClient) UpdatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error) {
resp, err := c.prettyClient.UpdatePrettyDisplayIDPool(ctx, &userv1.UpdatePrettyDisplayIDPoolRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PoolId: req.PoolID,
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJson: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayIDPool(resp.GetPool()), nil
}
func (c *GRPCClient) GeneratePrettyDisplayIDs(ctx context.Context, req GeneratePrettyDisplayIDsRequest) (*PrettyDisplayIDGenerationBatch, error) {
// 批量生成的冲突过滤、规则默认值和批次落库都在 user-service这里不复制业务判断。
resp, err := c.prettyClient.GeneratePrettyDisplayIDs(ctx, &userv1.GeneratePrettyDisplayIDsRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PoolId: req.PoolID,
RuleType: req.RuleType,
RuleConfigJson: req.RuleConfigJSON,
Count: req.Count,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayIDGenerationBatch(resp.GetBatch()), nil
}
func (c *GRPCClient) ListPrettyDisplayIDs(ctx context.Context, req ListPrettyDisplayIDsRequest) ([]*PrettyDisplayID, int64, error) {
resp, err := c.prettyClient.ListPrettyDisplayIDs(ctx, &userv1.ListPrettyDisplayIDsRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PoolId: req.PoolID,
Source: req.Source,
Status: req.Status,
Keyword: req.Keyword,
AssignedUserId: req.AssignedUserID,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return nil, 0, err
}
items := make([]*PrettyDisplayID, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, fromProtoPrettyDisplayID(item))
}
return items, resp.GetTotal(), nil
}
func (c *GRPCClient) SetPrettyDisplayIDStatus(ctx context.Context, req SetPrettyDisplayIDStatusRequest) (*PrettyDisplayID, error) {
resp, err := c.prettyClient.SetPrettyDisplayIDStatus(ctx, &userv1.SetPrettyDisplayIDStatusRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PrettyId: req.PrettyID,
Status: req.Status,
OperatorAdminId: req.OperatorAdminID,
Reason: req.Reason,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayID(resp.GetItem()), nil
}
func (c *GRPCClient) AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error) {
// 后台发放返回当前用户身份快照admin-server 原样转给前端,方便页面确认最终展示号。
resp, err := c.prettyClient.AdminGrantPrettyDisplayID(ctx, &userv1.AdminGrantPrettyDisplayIDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
TargetUserId: req.TargetUserID,
DisplayUserId: req.DisplayUserID,
DurationMs: req.DurationMs,
Reason: req.Reason,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return &AdminGrantPrettyDisplayIDResult{
Identity: fromProtoUserIdentity(resp.GetIdentity()),
PrettyID: resp.GetPrettyId(),
LeaseID: resp.GetLeaseId(),
}, nil
}
func fromProtoPrettyDisplayIDPool(pool *userv1.PrettyDisplayIDPool) *PrettyDisplayIDPool {
if pool == nil {
return nil
}
// 转换层保留 int64 的 string JSON tag避免后台前端在大 ID 上丢精度。
return &PrettyDisplayIDPool{
AppCode: pool.GetAppCode(),
PoolID: pool.GetPoolId(),
Name: pool.GetName(),
LevelTrack: pool.GetLevelTrack(),
MinLevel: pool.GetMinLevel(),
MaxLevel: pool.GetMaxLevel(),
RuleType: pool.GetRuleType(),
RuleConfigJSON: pool.GetRuleConfigJson(),
Status: pool.GetStatus(),
SortOrder: pool.GetSortOrder(),
CreatedByAdminID: pool.GetCreatedByAdminId(),
UpdatedByAdminID: pool.GetUpdatedByAdminId(),
CreatedAtMs: pool.GetCreatedAtMs(),
UpdatedAtMs: pool.GetUpdatedAtMs(),
}
}
func fromProtoPrettyDisplayID(item *userv1.PrettyDisplayID) *PrettyDisplayID {
if item == nil {
return nil
}
// pool 可能为空:后台发放的自由靓号没有池归属,列表展示时仍要返回靓号本体。
return &PrettyDisplayID{
AppCode: item.GetAppCode(),
PrettyID: item.GetPrettyId(),
PoolID: item.GetPoolId(),
Source: item.GetSource(),
DisplayUserID: item.GetDisplayUserId(),
Status: item.GetStatus(),
AssignedUserID: item.GetAssignedUserId(),
AssignedLeaseID: item.GetAssignedLeaseId(),
AssignedAtMs: item.GetAssignedAtMs(),
ReleasedAtMs: item.GetReleasedAtMs(),
ReleaseReason: item.GetReleaseReason(),
GeneratedBatchID: item.GetGeneratedBatchId(),
CreatedByAdminID: item.GetCreatedByAdminId(),
CreatedAtMs: item.GetCreatedAtMs(),
UpdatedAtMs: item.GetUpdatedAtMs(),
Pool: fromProtoPrettyDisplayIDPool(item.GetPool()),
}
}
func fromProtoPrettyDisplayIDGenerationBatch(batch *userv1.PrettyDisplayIDGenerationBatch) *PrettyDisplayIDGenerationBatch {
if batch == nil {
return nil
}
return &PrettyDisplayIDGenerationBatch{
AppCode: batch.GetAppCode(),
BatchID: batch.GetBatchId(),
PoolID: batch.GetPoolId(),
RuleType: batch.GetRuleType(),
RuleConfigJSON: batch.GetRuleConfigJson(),
RequestedCount: batch.GetRequestedCount(),
GeneratedCount: batch.GetGeneratedCount(),
SkippedConflictCount: batch.GetSkippedConflictCount(),
Status: batch.GetStatus(),
OperatorAdminID: batch.GetOperatorAdminId(),
RequestID: batch.GetRequestId(),
CreatedAtMs: batch.GetCreatedAtMs(),
UpdatedAtMs: batch.GetUpdatedAtMs(),
}
}

View File

@ -35,6 +35,9 @@ type Client interface {
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
@ -157,6 +160,18 @@ func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRe
return c.client.ListRechargeBills(ctx, req)
}
func (c *GRPCClient) ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
return c.client.ListThirdPartyPaymentChannels(ctx, req)
}
func (c *GRPCClient) SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
return c.client.SetThirdPartyPaymentMethodStatus(ctx, req)
}
func (c *GRPCClient) UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
return c.client.UpdateThirdPartyPaymentRate(ctx, req)
}
func (c *GRPCClient) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
return c.client.ListAdminRechargeProducts(ctx, req)
}

View File

@ -116,21 +116,22 @@ func (AppBanner) TableName() string {
}
type AppSplashScreen struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_app_splash_screens_app_sort,not null;default:lalu" json:"appCode"`
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
SplashType string `gorm:"size:16;not null" json:"splashType"`
Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_splash_screens_app_sort,not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_splash_screens_scope,not null" json:"platform"`
SortOrder int `gorm:"index:idx_admin_app_splash_screens_app_sort,not null;default:0" json:"sortOrder"`
RegionID int64 `gorm:"index:idx_admin_app_splash_screens_scope,not null;default:0" json:"regionId"`
CountryCode string `gorm:"size:8;index:idx_admin_app_splash_screens_scope" json:"countryCode"`
Description string `gorm:"size:255" json:"description"`
StartsAtMS int64 `gorm:"column:starts_at_ms;not null;default:0;comment:投放开始时间UTC epoch ms" json:"startsAtMs"`
EndsAtMS int64 `gorm:"column:ends_at_ms;not null;default:0;comment:投放结束时间UTC epoch ms" json:"endsAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_app_splash_screens_app_sort,not null;default:lalu" json:"appCode"`
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
SplashType string `gorm:"size:16;not null" json:"splashType"`
Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_splash_screens_app_sort,not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_splash_screens_scope,not null" json:"platform"`
SortOrder int `gorm:"index:idx_admin_app_splash_screens_app_sort,not null;default:0" json:"sortOrder"`
RegionID int64 `gorm:"index:idx_admin_app_splash_screens_scope,not null;default:0" json:"regionId"`
CountryCode string `gorm:"size:8;index:idx_admin_app_splash_screens_scope" json:"countryCode"`
Description string `gorm:"size:255" json:"description"`
DisplayDurationMS int `gorm:"column:display_duration_ms;not null;default:3000;comment:开屏展示时长毫秒0 表示客户端默认值" json:"displayDurationMs"`
StartsAtMS int64 `gorm:"column:starts_at_ms;not null;default:0;comment:投放开始时间UTC epoch ms" json:"startsAtMs"`
EndsAtMS int64 `gorm:"column:ends_at_ms;not null;default:0;comment:投放结束时间UTC epoch ms" json:"endsAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (AppSplashScreen) TableName() string {

View File

@ -28,17 +28,18 @@ type bannerRequest struct {
}
type splashScreenRequest struct {
CoverURL string `json:"coverUrl" binding:"required"`
SplashType string `json:"splashType" binding:"required"`
Param string `json:"param"`
Status string `json:"status" binding:"required"`
Platform string `json:"platform" binding:"required"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CoverURL string `json:"coverUrl" binding:"required"`
SplashType string `json:"splashType" binding:"required"`
Param string `json:"param"`
Status string `json:"status" binding:"required"`
Platform string `json:"platform" binding:"required"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
DisplayDurationMs int `json:"displayDurationMs"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
}
type appVersionRequest struct {

View File

@ -23,6 +23,8 @@ const (
bannerStatusActive = "active"
bannerStatusDisabled = "disabled"
bannerStatusExpired = "expired"
splashDefaultDisplayDurationMS = 3000
)
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge}
@ -60,21 +62,22 @@ type AppBanner struct {
}
type AppSplashScreen struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"`
SplashType string `json:"splashType"`
Param string `json:"param"`
Status string `json:"status"`
Platform string `json:"platform"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
ID uint `json:"id"`
AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"`
SplashType string `json:"splashType"`
Param string `json:"param"`
Status string `json:"status"`
Platform string `json:"platform"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
DisplayDurationMs int `json:"displayDurationMs"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppVersion struct {
@ -295,6 +298,7 @@ func (s *AppConfigService) UpdateSplashScreen(appCode string, id uint, req splas
item.RegionID = updated.RegionID
item.CountryCode = updated.CountryCode
item.Description = updated.Description
item.DisplayDurationMS = updated.DisplayDurationMS
item.StartsAtMS = updated.StartsAtMS
item.EndsAtMS = updated.EndsAtMS
if err := s.store.UpdateAppSplashScreen(&item); err != nil {
@ -579,18 +583,19 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
func splashScreenModelFromRequest(appCode string, req splashScreenRequest) (model.AppSplashScreen, error) {
item := model.AppSplashScreen{
AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL),
SplashType: normalizeSplashType(req.SplashType),
Param: strings.TrimSpace(req.Param),
Status: normalizeBannerStatus(req.Status),
Platform: normalizeBannerPlatform(req.Platform),
SortOrder: req.SortOrder,
RegionID: req.RegionID,
CountryCode: normalizeCountryCode(req.CountryCode),
Description: strings.TrimSpace(req.Description),
StartsAtMS: req.StartsAtMs,
EndsAtMS: req.EndsAtMs,
AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL),
SplashType: normalizeSplashType(req.SplashType),
Param: strings.TrimSpace(req.Param),
Status: normalizeBannerStatus(req.Status),
Platform: normalizeBannerPlatform(req.Platform),
SortOrder: req.SortOrder,
RegionID: req.RegionID,
CountryCode: normalizeCountryCode(req.CountryCode),
Description: strings.TrimSpace(req.Description),
DisplayDurationMS: normalizeSplashDisplayDurationMS(req.DisplayDurationMs),
StartsAtMS: req.StartsAtMs,
EndsAtMS: req.EndsAtMs,
}
if item.CoverURL == "" || len(item.CoverURL) > 1024 {
return model.AppSplashScreen{}, errors.New("splash cover is invalid")
@ -638,27 +643,31 @@ func splashScreenModelFromRequest(appCode string, req splashScreenRequest) (mode
if utf8.RuneCountInString(item.Description) > 255 {
return model.AppSplashScreen{}, errors.New("splash description is too long")
}
if req.DisplayDurationMs < 0 {
return model.AppSplashScreen{}, errors.New("splash display duration is invalid")
}
return item, nil
}
func appSplashScreenFromModel(item model.AppSplashScreen) AppSplashScreen {
// DTO 只返回后台页面需要的字段不暴露数据库列名gateway 另有 snake_case 的 App 输出结构。
return AppSplashScreen{
ID: item.ID,
AppCode: item.AppCode,
CoverURL: item.CoverURL,
SplashType: item.SplashType,
Param: item.Param,
Status: item.Status,
Platform: item.Platform,
SortOrder: item.SortOrder,
RegionID: item.RegionID,
CountryCode: item.CountryCode,
Description: item.Description,
StartsAtMs: item.StartsAtMS,
EndsAtMs: item.EndsAtMS,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
ID: item.ID,
AppCode: item.AppCode,
CoverURL: item.CoverURL,
SplashType: item.SplashType,
Param: item.Param,
Status: item.Status,
Platform: item.Platform,
SortOrder: item.SortOrder,
RegionID: item.RegionID,
CountryCode: item.CountryCode,
Description: item.Description,
DisplayDurationMs: item.DisplayDurationMS,
StartsAtMs: item.StartsAtMS,
EndsAtMs: item.EndsAtMS,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
@ -751,6 +760,14 @@ func normalizeSplashType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeSplashDisplayDurationMS(value int) int {
// 管理端允许 0 表示“客户端默认 3 秒”,空请求和历史数据都统一补成 3000ms避免 App 侧拿到缺省字段后出现 0 秒闪退式开屏。
if value == 0 {
return splashDefaultDisplayDurationMS
}
return value
}
func normalizeBannerStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {

View File

@ -108,26 +108,55 @@ func TestSplashScreenModelFromRequestValidatesH5Config(t *testing.T) {
startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli()
endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond)
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "h5",
Param: "https://h5.example.com/splash",
Status: bannerStatusActive,
Platform: "android",
SortOrder: 7,
RegionID: 1001,
CountryCode: "us",
Description: "launch campaign",
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "h5",
Param: "https://h5.example.com/splash",
Status: bannerStatusActive,
Platform: "android",
SortOrder: 7,
RegionID: 1001,
CountryCode: "us",
Description: "launch campaign",
DisplayDurationMs: 4200,
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
})
if err != nil {
t.Fatalf("splash screen model should be valid: %v", err)
}
if item.AppCode != "lalu" || item.SplashType != "h5" || item.CountryCode != "US" || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs {
if item.AppCode != "lalu" || item.SplashType != "h5" || item.CountryCode != "US" || item.DisplayDurationMS != 4200 || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs {
t.Fatalf("splash screen model mismatch: %+v", item)
}
}
func TestSplashScreenModelFromRequestDefaultsDisplayDuration(t *testing.T) {
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "android",
})
if err != nil {
t.Fatalf("splash screen model should accept missing display duration: %v", err)
}
if item.DisplayDurationMS != splashDefaultDisplayDurationMS {
t.Fatalf("display duration should default to %d, got %+v", splashDefaultDisplayDurationMS, item)
}
}
func TestSplashScreenModelFromRequestRejectsNegativeDisplayDuration(t *testing.T) {
_, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "android",
DisplayDurationMs: -1,
})
if err == nil {
t.Fatal("expected negative display duration to fail")
}
}
func TestSplashScreenModelFromRequestRequiresH5Link(t *testing.T) {
_, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
@ -158,24 +187,25 @@ func TestSplashScreenModelFromRequestExpiresEndedActiveConfig(t *testing.T) {
func TestAppSplashScreenFromModelMapsDTOFields(t *testing.T) {
item := appSplashScreenFromModel(model.AppSplashScreen{
ID: 9,
AppCode: "lalu",
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Param: "profile",
Status: bannerStatusDisabled,
Platform: "ios",
SortOrder: 3,
RegionID: 2002,
CountryCode: "BR",
Description: "disabled campaign",
StartsAtMS: 1700000000000,
EndsAtMS: 1800000000000,
CreatedAtMS: 1690000000000,
UpdatedAtMS: 1700000002000,
ID: 9,
AppCode: "lalu",
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Param: "profile",
Status: bannerStatusDisabled,
Platform: "ios",
SortOrder: 3,
RegionID: 2002,
CountryCode: "BR",
Description: "disabled campaign",
DisplayDurationMS: 5000,
StartsAtMS: 1700000000000,
EndsAtMS: 1800000000000,
CreatedAtMS: 1690000000000,
UpdatedAtMS: 1700000002000,
})
if item.ID != 9 || item.SplashType != "app" || item.Param != "profile" || item.RegionID != 2002 || item.UpdatedAtMs != 1700000002000 {
if item.ID != 9 || item.SplashType != "app" || item.Param != "profile" || item.RegionID != 2002 || item.DisplayDurationMs != 5000 || item.UpdatedAtMs != 1700000002000 {
t.Fatalf("splash screen dto mismatch: %+v", item)
}
}

View File

@ -51,6 +51,9 @@ type tierDTO struct {
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
USDMinorAmount int64 `json:"usd_minor_amount"`
GoogleProductID string `json:"google_product_id"`
DiscountPercent int32 `json:"discount_percent"`
}
type claimDTO struct {
@ -66,6 +69,9 @@ type claimDTO struct {
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
RechargeSequence int64 `json:"recharge_sequence"`
RechargeType string `json:"recharge_type"`
TierUSDMinorAmount int64 `json:"tier_usd_minor_amount"`
GoogleProductID string `json:"google_product_id"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
Status string `json:"status"`
WalletCommandID string `json:"wallet_command_id"`
WalletGrantID string `json:"wallet_grant_id"`
@ -86,7 +92,7 @@ type userDTO struct {
func (h *Handler) GetConfig(c *gin.Context) {
resp, err := h.activity.GetFirstRechargeRewardConfig(c.Request.Context(), &activityv1.GetFirstRechargeRewardConfigRequest{Meta: h.meta(c)})
if err != nil {
response.ServerError(c, "获取首冲奖励配置失败")
response.ServerError(c, "获取首充礼包配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
@ -95,7 +101,7 @@ func (h *Handler) GetConfig(c *gin.Context) {
func (h *Handler) UpdateConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "首冲奖励配置参数不正确")
response.BadRequest(c, "首充礼包配置参数不正确")
return
}
tiers := make([]*activityv1.FirstRechargeRewardTier, 0, len(req.Tiers))
@ -109,6 +115,9 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
ResourceGroupId: tier.ResourceGroupID,
Status: strings.TrimSpace(tier.Status),
SortOrder: tier.SortOrder,
UsdMinorAmount: tier.USDMinorAmount,
GoogleProductId: strings.TrimSpace(tier.GoogleProductID),
DiscountPercent: tier.DiscountPercent,
})
}
resp, err := h.activity.UpdateFirstRechargeRewardConfig(c.Request.Context(), &activityv1.UpdateFirstRechargeRewardConfigRequest{
@ -145,7 +154,7 @@ func (h *Handler) ListClaims(c *gin.Context) {
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取首冲奖励发放记录失败")
response.ServerError(c, "获取首充礼包发放记录失败")
return
}
items := make([]claimDTO, 0, len(resp.GetClaims()))
@ -282,6 +291,9 @@ func tierFromProto(tier *activityv1.FirstRechargeRewardTier) tierDTO {
SortOrder: tier.GetSortOrder(),
CreatedAtMS: tier.GetCreatedAtMs(),
UpdatedAtMS: tier.GetUpdatedAtMs(),
USDMinorAmount: tier.GetUsdMinorAmount(),
GoogleProductID: tier.GetGoogleProductId(),
DiscountPercent: tier.GetDiscountPercent(),
}
}
@ -301,6 +313,9 @@ func claimFromProto(claim *activityv1.FirstRechargeRewardClaim) claimDTO {
RechargeCoinAmount: claim.GetRechargeCoinAmount(),
RechargeSequence: claim.GetRechargeSequence(),
RechargeType: claim.GetRechargeType(),
TierUSDMinorAmount: claim.GetTierUsdMinorAmount(),
GoogleProductID: claim.GetGoogleProductId(),
RechargeUSDMinor: claim.GetRechargeUsdMinor(),
Status: claim.GetStatus(),
WalletCommandID: claim.GetWalletCommandId(),
WalletGrantID: claim.GetWalletGrantId(),

View File

@ -1,6 +1,10 @@
package gamemanagement
import gamev1 "hyapp.local/api/proto/game/v1"
import (
"strconv"
gamev1 "hyapp.local/api/proto/game/v1"
)
type platformDTO struct {
AppCode string `json:"appCode"`
@ -43,6 +47,52 @@ type catalogDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type diceConfigDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
Status string `json:"status"`
StakeOptions []diceStakeDTO `json:"stakeOptions"`
FeeBPS int32 `json:"feeBps"`
PoolBPS int32 `json:"poolBps"`
MinPlayers int32 `json:"minPlayers"`
MaxPlayers int32 `json:"maxPlayers"`
RobotEnabled bool `json:"robotEnabled"`
RobotMatchWaitMS int64 `json:"robotMatchWaitMs"`
PoolBalanceCoin int64 `json:"poolBalanceCoin"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type diceStakeDTO struct {
StakeCoin int64 `json:"stakeCoin"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
type dicePoolAdjustmentDTO struct {
AdjustmentID string `json:"adjustmentId"`
GameID string `json:"gameId"`
AmountCoin int64 `json:"amountCoin"`
Direction string `json:"direction"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balanceAfter"`
CreatedAtMS int64 `json:"createdAtMs"`
}
type diceRobotDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
UserID string `json:"userId"`
UserIDNumber int64 `json:"userIdNumber"`
ShortID string `json:"shortId"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Status string `json:"status"`
CreatedByAdminID int64 `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func platformFromProto(item *gamev1.GamePlatform) platformDTO {
if item == nil {
return platformDTO{}
@ -88,3 +138,63 @@ func catalogFromProto(item *gamev1.GameCatalogItem) catalogDTO {
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func diceConfigFromProto(item *gamev1.DiceConfig) diceConfigDTO {
if item == nil {
return diceConfigDTO{}
}
options := make([]diceStakeDTO, 0, len(item.GetStakeOptions()))
for _, option := range item.GetStakeOptions() {
options = append(options, diceStakeDTO{
StakeCoin: option.GetStakeCoin(),
Enabled: option.GetEnabled(),
SortOrder: option.GetSortOrder(),
})
}
return diceConfigDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
Status: item.GetStatus(),
StakeOptions: options,
FeeBPS: item.GetFeeBps(),
PoolBPS: item.GetPoolBps(),
MinPlayers: item.GetMinPlayers(),
MaxPlayers: item.GetMaxPlayers(),
RobotEnabled: item.GetRobotEnabled(),
RobotMatchWaitMS: item.GetRobotMatchWaitMs(),
PoolBalanceCoin: item.GetPoolBalanceCoin(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func dicePoolAdjustmentFromProto(item *gamev1.DicePoolAdjustment) dicePoolAdjustmentDTO {
if item == nil {
return dicePoolAdjustmentDTO{}
}
return dicePoolAdjustmentDTO{
AdjustmentID: item.GetAdjustmentId(),
GameID: item.GetGameId(),
AmountCoin: item.GetAmountCoin(),
Direction: item.GetDirection(),
Reason: item.GetReason(),
BalanceAfter: item.GetBalanceAfter(),
CreatedAtMS: item.GetCreatedAtMs(),
}
}
func diceRobotFromProto(item *gamev1.DiceRobot) diceRobotDTO {
if item == nil {
return diceRobotDTO{}
}
return diceRobotDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
UserID: strconv.FormatInt(item.GetUserId(), 10),
UserIDNumber: item.GetUserId(),
Status: item.GetStatus(),
CreatedByAdminID: item.GetCreatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
@ -13,12 +14,23 @@ import (
)
type Handler struct {
game gameclient.Client
audit shared.OperationLogger
game gameclient.Client
user userclient.Client
audit shared.OperationLogger
robotAvatar robotAvatarConfig
}
func New(game gameclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{game: game, audit: audit}
// Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。
type Option func(*Handler)
func New(game gameclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *Handler {
h := &Handler{game: game, user: user, audit: audit, robotAvatar: defaultRobotAvatarConfig()}
for _, option := range options {
if option != nil {
option(h)
}
}
return h
}
// ListPlatforms 返回后台游戏平台筛选项和平台配置列表。

View File

@ -47,6 +47,42 @@ type statusRequest struct {
Status string `json:"status"`
}
type diceConfigRequest struct {
Status string `json:"status"`
StakeOptions []diceStakeReq `json:"stakeOptions"`
FeeBPS int32 `json:"feeBps"`
PoolBPS int32 `json:"poolBps"`
MinPlayers int32 `json:"minPlayers"`
MaxPlayers int32 `json:"maxPlayers"`
RobotEnabled bool `json:"robotEnabled"`
RobotMatchWaitMS int64 `json:"robotMatchWaitMs"`
}
type diceStakeReq struct {
StakeCoin int64 `json:"stakeCoin"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
type dicePoolAdjustRequest struct {
AmountCoin int64 `json:"amountCoin"`
Direction string `json:"direction"`
Reason string `json:"reason"`
}
type diceGenerateRobotsRequest struct {
Count int32 `json:"count"`
NicknamePrefix string `json:"nicknamePrefix"`
NicknameLanguage string `json:"nicknameLanguage"`
AvatarURLs []string `json:"avatarUrls"`
Country string `json:"country"`
Gender string `json:"gender"`
}
type diceRobotStatusRequest struct {
Status string `json:"status"`
}
func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
if platformCode == "" {
platformCode = r.PlatformCode
@ -86,6 +122,28 @@ func (r catalogRequest) toProto(gameID string) *gamev1.GameCatalogItem {
}
}
func (r diceConfigRequest) toProto(gameID string) *gamev1.DiceConfig {
options := make([]*gamev1.DiceStakeOption, 0, len(r.StakeOptions))
for _, option := range r.StakeOptions {
options = append(options, &gamev1.DiceStakeOption{
StakeCoin: option.StakeCoin,
Enabled: option.Enabled,
SortOrder: option.SortOrder,
})
}
return &gamev1.DiceConfig{
GameId: strings.TrimSpace(gameID),
Status: strings.TrimSpace(r.Status),
StakeOptions: options,
FeeBps: r.FeeBPS,
PoolBps: r.PoolBPS,
MinPlayers: r.MinPlayers,
MaxPlayers: r.MaxPlayers,
RobotEnabled: r.RobotEnabled,
RobotMatchWaitMs: r.RobotMatchWaitMS,
}
}
func compactTags(tags []string) []string {
out := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))

View File

@ -0,0 +1,167 @@
package gamemanagement
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"hyapp-admin-server/internal/platform/idgen"
)
const (
robotAvatarDownloadTimeout = 10 * time.Second
robotAvatarMaxBytes = 5 << 20
robotAvatarSniffBytes = 512
)
// ObjectUploader 是游戏管理模块需要的对象存储最小能力,实际生产实现由后台 COS 平台层提供。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
type robotAvatarConfig struct {
uploader ObjectUploader
objectPrefix string
httpClient *http.Client
}
func defaultRobotAvatarConfig() robotAvatarConfig {
return robotAvatarConfig{
objectPrefix: "admin",
httpClient: &http.Client{Timeout: robotAvatarDownloadTimeout},
}
}
// WithRobotAvatarUploader 注入后台已装配的 COS 上传器,机器人创建只拿这个薄接口,不直接依赖 COS SDK。
func WithRobotAvatarUploader(uploader ObjectUploader, objectPrefix string) Option {
return func(h *Handler) {
h.robotAvatar.uploader = uploader
h.robotAvatar.objectPrefix = cleanRobotAvatarObjectPrefix(objectPrefix)
}
}
// WithRobotAvatarHTTPClient 只给测试替换下载客户端;生产默认使用带超时的标准 HTTP client。
func WithRobotAvatarHTTPClient(client *http.Client) Option {
return func(h *Handler) {
if client != nil {
h.robotAvatar.httpClient = client
}
}
}
func (h *Handler) uploadRobotAvatarFromSource(ctx context.Context, actorID uint, sourceURL string) (string, error) {
sourceURL = strings.TrimSpace(sourceURL)
if sourceURL == "" {
return "", fmt.Errorf("robot avatar source url is empty")
}
if h.robotAvatar.uploader == nil {
return "", fmt.Errorf("robot avatar cos uploader is not configured")
}
data, contentType, err := h.downloadRobotAvatar(ctx, sourceURL)
if err != nil {
return "", err
}
// 机器人头像先进入后台 COS 前缀再写入用户资料App 端只依赖自有 CDN URL不直接依赖第三方头像站。
key := buildRobotAvatarObjectKey(h.robotAvatar.objectPrefix, actorID, contentType, time.Now().UTC())
return h.robotAvatar.uploader.PutObject(ctx, key, bytes.NewReader(data), int64(len(data)), contentType)
}
func (h *Handler) downloadRobotAvatar(ctx context.Context, sourceURL string) ([]byte, string, error) {
if !validRobotAvatarSourceURL(sourceURL) {
return nil, "", fmt.Errorf("robot avatar source url is invalid")
}
client := h.robotAvatar.httpClient
if client == nil {
client = &http.Client{Timeout: robotAvatarDownloadTimeout}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, "", fmt.Errorf("build robot avatar request: %w", err)
}
req.Header.Set("User-Agent", "hyapp-admin-server/robot-avatar")
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download robot avatar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, "", fmt.Errorf("download robot avatar returned status %d", resp.StatusCode)
}
if resp.ContentLength > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar is too large")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, robotAvatarMaxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read robot avatar: %w", err)
}
if len(data) == 0 || len(data) > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar size is invalid")
}
sniffSize := len(data)
if sniffSize > robotAvatarSniffBytes {
sniffSize = robotAvatarSniffBytes
}
contentType, ok := normalizeRobotAvatarContentType(http.DetectContentType(data[:sniffSize]), resp.Header.Get("Content-Type"))
if !ok {
return nil, "", fmt.Errorf("robot avatar content type is unsupported")
}
return data, contentType, nil
}
func validRobotAvatarSourceURL(raw string) bool {
u, err := url.ParseRequestURI(strings.TrimSpace(raw))
if err != nil {
return false
}
return (u.Scheme == "https" || u.Scheme == "http") && strings.TrimSpace(u.Host) != ""
}
func normalizeRobotAvatarContentType(values ...string) (string, bool) {
for _, value := range values {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0]))
switch contentType {
case "image/jpeg", "image/jpg":
return "image/jpeg", true
case "image/png":
return "image/png", true
case "image/webp":
return "image/webp", true
}
}
return "", false
}
func buildRobotAvatarObjectKey(objectPrefix string, actorID uint, contentType string, now time.Time) string {
extension := ".jpg"
switch contentType {
case "image/png":
extension = ".png"
case "image/webp":
extension = ".webp"
}
return fmt.Sprintf(
"%s/game-robots/avatars/%d/%s/%s%s",
cleanRobotAvatarObjectPrefix(objectPrefix),
actorID,
now.UTC().Format("20060102"),
idgen.New("avatar"),
extension,
)
}
func cleanRobotAvatarObjectPrefix(value string) string {
value = strings.Trim(strings.TrimSpace(value), "/")
if value == "" {
return "admin"
}
return value
}

View File

@ -0,0 +1,133 @@
package gamemanagement
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode"
)
type fakeRobotAvatarUploader struct {
key string
contentType string
data []byte
}
func (u *fakeRobotAvatarUploader) PutObject(_ context.Context, key string, reader io.Reader, _ int64, contentType string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
u.key = key
u.contentType = contentType
u.data = data
return "https://media.example.com/" + key, nil
}
func TestUploadRobotAvatarFromSourceUploadsDownloadedImageToCOS(t *testing.T) {
imageBytes, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
if err != nil {
t.Fatalf("decode png: %v", err)
}
avatarServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
defer avatarServer.Close()
uploader := &fakeRobotAvatarUploader{}
handler := New(nil, nil, nil, WithRobotAvatarUploader(uploader, "admin"), WithRobotAvatarHTTPClient(avatarServer.Client()))
got, err := handler.uploadRobotAvatarFromSource(t.Context(), 42, avatarServer.URL+"/avatar.png")
if err != nil {
t.Fatalf("uploadRobotAvatarFromSource failed: %v", err)
}
if !strings.HasPrefix(got, "https://media.example.com/admin/game-robots/avatars/42/") {
t.Fatalf("uploaded url = %q", got)
}
if uploader.contentType != "image/png" {
t.Fatalf("contentType = %q, want image/png", uploader.contentType)
}
if string(uploader.data) != string(imageBytes) {
t.Fatalf("uploaded bytes mismatch")
}
}
func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) {
// 预设池是批量创建机器人的基础数据,数量变化会直接影响前端一次创建 200 个机器人时的去重效果。
if len(defaultArabicRobotNicknames) != 100 {
t.Fatalf("arabic nickname preset count = %d, want 100", len(defaultArabicRobotNicknames))
}
if len(defaultEnglishRobotNicknames) != 100 {
t.Fatalf("english nickname preset count = %d, want 100", len(defaultEnglishRobotNicknames))
}
if len(defaultRobotAvatarSources) != 200 {
t.Fatalf("avatar preset count = %d, want 200", len(defaultRobotAvatarSources))
}
if uniqueCount(defaultRobotAvatarSources) != 200 {
t.Fatalf("avatar preset must keep 200 unique source urls")
}
englishNames := make([]string, 0, 200)
arabicNames := make([]string, 0, 200)
for index := 0; index < 200; index++ {
english := robotNickname(index, "english")
if !isPureEnglishNickname(english) {
t.Fatalf("english nickname[%d] = %q, want letters only", index, english)
}
englishNames = append(englishNames, english)
arabic := robotNickname(index, "arabic")
if !isPureArabicNickname(arabic) {
t.Fatalf("arabic nickname[%d] = %q, want arabic letters only", index, arabic)
}
arabicNames = append(arabicNames, arabic)
}
if uniqueCount(englishNames) != len(englishNames) {
t.Fatalf("english nickname presets and overflow combinations must keep 200 unique names")
}
if uniqueCount(arabicNames) != len(arabicNames) {
t.Fatalf("arabic nickname presets and overflow combinations must keep 200 unique names")
}
if got := robotNickname(100, "english"); got != "AlexJordan" {
t.Fatalf("english overflow nickname = %q, want pure english combined name", got)
}
if got := robotNickname(100, "arabic"); got != "أميرسيف" {
t.Fatalf("arabic overflow nickname = %q, want pure arabic combined name", got)
}
}
func isPureEnglishNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if (item < 'A' || item > 'Z') && (item < 'a' || item > 'z') {
return false
}
}
return true
}
func isPureArabicNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if !unicode.Is(unicode.Arabic, item) {
return false
}
}
return true
}
func uniqueCount(values []string) int {
seen := make(map[string]struct{}, len(values))
for _, value := range values {
seen[value] = struct{}{}
}
return len(seen)
}

View File

@ -0,0 +1,513 @@
package gamemanagement
import (
"strings"
"unicode"
)
const (
robotNicknameLanguageArabic = "arabic"
robotNicknameLanguageEnglish = "english"
)
var defaultRobotAvatarSources = []string{
"https://randomuser.me/api/portraits/men/0.jpg",
"https://randomuser.me/api/portraits/men/1.jpg",
"https://randomuser.me/api/portraits/men/2.jpg",
"https://randomuser.me/api/portraits/men/3.jpg",
"https://randomuser.me/api/portraits/men/4.jpg",
"https://randomuser.me/api/portraits/men/5.jpg",
"https://randomuser.me/api/portraits/men/6.jpg",
"https://randomuser.me/api/portraits/men/7.jpg",
"https://randomuser.me/api/portraits/men/8.jpg",
"https://randomuser.me/api/portraits/men/9.jpg",
"https://randomuser.me/api/portraits/men/10.jpg",
"https://randomuser.me/api/portraits/men/11.jpg",
"https://randomuser.me/api/portraits/men/12.jpg",
"https://randomuser.me/api/portraits/men/13.jpg",
"https://randomuser.me/api/portraits/men/14.jpg",
"https://randomuser.me/api/portraits/men/15.jpg",
"https://randomuser.me/api/portraits/men/16.jpg",
"https://randomuser.me/api/portraits/men/17.jpg",
"https://randomuser.me/api/portraits/men/18.jpg",
"https://randomuser.me/api/portraits/men/19.jpg",
"https://randomuser.me/api/portraits/men/20.jpg",
"https://randomuser.me/api/portraits/men/21.jpg",
"https://randomuser.me/api/portraits/men/22.jpg",
"https://randomuser.me/api/portraits/men/23.jpg",
"https://randomuser.me/api/portraits/men/24.jpg",
"https://randomuser.me/api/portraits/men/25.jpg",
"https://randomuser.me/api/portraits/men/26.jpg",
"https://randomuser.me/api/portraits/men/27.jpg",
"https://randomuser.me/api/portraits/men/28.jpg",
"https://randomuser.me/api/portraits/men/29.jpg",
"https://randomuser.me/api/portraits/men/30.jpg",
"https://randomuser.me/api/portraits/men/31.jpg",
"https://randomuser.me/api/portraits/men/32.jpg",
"https://randomuser.me/api/portraits/men/33.jpg",
"https://randomuser.me/api/portraits/men/34.jpg",
"https://randomuser.me/api/portraits/men/35.jpg",
"https://randomuser.me/api/portraits/men/36.jpg",
"https://randomuser.me/api/portraits/men/37.jpg",
"https://randomuser.me/api/portraits/men/38.jpg",
"https://randomuser.me/api/portraits/men/39.jpg",
"https://randomuser.me/api/portraits/men/40.jpg",
"https://randomuser.me/api/portraits/men/41.jpg",
"https://randomuser.me/api/portraits/men/42.jpg",
"https://randomuser.me/api/portraits/men/43.jpg",
"https://randomuser.me/api/portraits/men/44.jpg",
"https://randomuser.me/api/portraits/men/45.jpg",
"https://randomuser.me/api/portraits/men/46.jpg",
"https://randomuser.me/api/portraits/men/47.jpg",
"https://randomuser.me/api/portraits/men/48.jpg",
"https://randomuser.me/api/portraits/men/49.jpg",
"https://randomuser.me/api/portraits/men/50.jpg",
"https://randomuser.me/api/portraits/men/51.jpg",
"https://randomuser.me/api/portraits/men/52.jpg",
"https://randomuser.me/api/portraits/men/53.jpg",
"https://randomuser.me/api/portraits/men/54.jpg",
"https://randomuser.me/api/portraits/men/55.jpg",
"https://randomuser.me/api/portraits/men/56.jpg",
"https://randomuser.me/api/portraits/men/57.jpg",
"https://randomuser.me/api/portraits/men/58.jpg",
"https://randomuser.me/api/portraits/men/59.jpg",
"https://randomuser.me/api/portraits/men/60.jpg",
"https://randomuser.me/api/portraits/men/61.jpg",
"https://randomuser.me/api/portraits/men/62.jpg",
"https://randomuser.me/api/portraits/men/63.jpg",
"https://randomuser.me/api/portraits/men/64.jpg",
"https://randomuser.me/api/portraits/men/65.jpg",
"https://randomuser.me/api/portraits/men/66.jpg",
"https://randomuser.me/api/portraits/men/67.jpg",
"https://randomuser.me/api/portraits/men/68.jpg",
"https://randomuser.me/api/portraits/men/69.jpg",
"https://randomuser.me/api/portraits/men/70.jpg",
"https://randomuser.me/api/portraits/men/71.jpg",
"https://randomuser.me/api/portraits/men/72.jpg",
"https://randomuser.me/api/portraits/men/73.jpg",
"https://randomuser.me/api/portraits/men/74.jpg",
"https://randomuser.me/api/portraits/men/75.jpg",
"https://randomuser.me/api/portraits/men/76.jpg",
"https://randomuser.me/api/portraits/men/77.jpg",
"https://randomuser.me/api/portraits/men/78.jpg",
"https://randomuser.me/api/portraits/men/79.jpg",
"https://randomuser.me/api/portraits/men/80.jpg",
"https://randomuser.me/api/portraits/men/81.jpg",
"https://randomuser.me/api/portraits/men/82.jpg",
"https://randomuser.me/api/portraits/men/83.jpg",
"https://randomuser.me/api/portraits/men/84.jpg",
"https://randomuser.me/api/portraits/men/85.jpg",
"https://randomuser.me/api/portraits/men/86.jpg",
"https://randomuser.me/api/portraits/men/87.jpg",
"https://randomuser.me/api/portraits/men/88.jpg",
"https://randomuser.me/api/portraits/men/89.jpg",
"https://randomuser.me/api/portraits/men/90.jpg",
"https://randomuser.me/api/portraits/men/91.jpg",
"https://randomuser.me/api/portraits/men/92.jpg",
"https://randomuser.me/api/portraits/men/93.jpg",
"https://randomuser.me/api/portraits/men/94.jpg",
"https://randomuser.me/api/portraits/men/95.jpg",
"https://randomuser.me/api/portraits/men/96.jpg",
"https://randomuser.me/api/portraits/men/97.jpg",
"https://randomuser.me/api/portraits/men/98.jpg",
"https://randomuser.me/api/portraits/men/99.jpg",
"https://randomuser.me/api/portraits/women/0.jpg",
"https://randomuser.me/api/portraits/women/1.jpg",
"https://randomuser.me/api/portraits/women/2.jpg",
"https://randomuser.me/api/portraits/women/3.jpg",
"https://randomuser.me/api/portraits/women/4.jpg",
"https://randomuser.me/api/portraits/women/5.jpg",
"https://randomuser.me/api/portraits/women/6.jpg",
"https://randomuser.me/api/portraits/women/7.jpg",
"https://randomuser.me/api/portraits/women/8.jpg",
"https://randomuser.me/api/portraits/women/9.jpg",
"https://randomuser.me/api/portraits/women/10.jpg",
"https://randomuser.me/api/portraits/women/11.jpg",
"https://randomuser.me/api/portraits/women/12.jpg",
"https://randomuser.me/api/portraits/women/13.jpg",
"https://randomuser.me/api/portraits/women/14.jpg",
"https://randomuser.me/api/portraits/women/15.jpg",
"https://randomuser.me/api/portraits/women/16.jpg",
"https://randomuser.me/api/portraits/women/17.jpg",
"https://randomuser.me/api/portraits/women/18.jpg",
"https://randomuser.me/api/portraits/women/19.jpg",
"https://randomuser.me/api/portraits/women/20.jpg",
"https://randomuser.me/api/portraits/women/21.jpg",
"https://randomuser.me/api/portraits/women/22.jpg",
"https://randomuser.me/api/portraits/women/23.jpg",
"https://randomuser.me/api/portraits/women/24.jpg",
"https://randomuser.me/api/portraits/women/25.jpg",
"https://randomuser.me/api/portraits/women/26.jpg",
"https://randomuser.me/api/portraits/women/27.jpg",
"https://randomuser.me/api/portraits/women/28.jpg",
"https://randomuser.me/api/portraits/women/29.jpg",
"https://randomuser.me/api/portraits/women/30.jpg",
"https://randomuser.me/api/portraits/women/31.jpg",
"https://randomuser.me/api/portraits/women/32.jpg",
"https://randomuser.me/api/portraits/women/33.jpg",
"https://randomuser.me/api/portraits/women/34.jpg",
"https://randomuser.me/api/portraits/women/35.jpg",
"https://randomuser.me/api/portraits/women/36.jpg",
"https://randomuser.me/api/portraits/women/37.jpg",
"https://randomuser.me/api/portraits/women/38.jpg",
"https://randomuser.me/api/portraits/women/39.jpg",
"https://randomuser.me/api/portraits/women/40.jpg",
"https://randomuser.me/api/portraits/women/41.jpg",
"https://randomuser.me/api/portraits/women/42.jpg",
"https://randomuser.me/api/portraits/women/43.jpg",
"https://randomuser.me/api/portraits/women/44.jpg",
"https://randomuser.me/api/portraits/women/45.jpg",
"https://randomuser.me/api/portraits/women/46.jpg",
"https://randomuser.me/api/portraits/women/47.jpg",
"https://randomuser.me/api/portraits/women/48.jpg",
"https://randomuser.me/api/portraits/women/49.jpg",
"https://randomuser.me/api/portraits/women/50.jpg",
"https://randomuser.me/api/portraits/women/51.jpg",
"https://randomuser.me/api/portraits/women/52.jpg",
"https://randomuser.me/api/portraits/women/53.jpg",
"https://randomuser.me/api/portraits/women/54.jpg",
"https://randomuser.me/api/portraits/women/55.jpg",
"https://randomuser.me/api/portraits/women/56.jpg",
"https://randomuser.me/api/portraits/women/57.jpg",
"https://randomuser.me/api/portraits/women/58.jpg",
"https://randomuser.me/api/portraits/women/59.jpg",
"https://randomuser.me/api/portraits/women/60.jpg",
"https://randomuser.me/api/portraits/women/61.jpg",
"https://randomuser.me/api/portraits/women/62.jpg",
"https://randomuser.me/api/portraits/women/63.jpg",
"https://randomuser.me/api/portraits/women/64.jpg",
"https://randomuser.me/api/portraits/women/65.jpg",
"https://randomuser.me/api/portraits/women/66.jpg",
"https://randomuser.me/api/portraits/women/67.jpg",
"https://randomuser.me/api/portraits/women/68.jpg",
"https://randomuser.me/api/portraits/women/69.jpg",
"https://randomuser.me/api/portraits/women/70.jpg",
"https://randomuser.me/api/portraits/women/71.jpg",
"https://randomuser.me/api/portraits/women/72.jpg",
"https://randomuser.me/api/portraits/women/73.jpg",
"https://randomuser.me/api/portraits/women/74.jpg",
"https://randomuser.me/api/portraits/women/75.jpg",
"https://randomuser.me/api/portraits/women/76.jpg",
"https://randomuser.me/api/portraits/women/77.jpg",
"https://randomuser.me/api/portraits/women/78.jpg",
"https://randomuser.me/api/portraits/women/79.jpg",
"https://randomuser.me/api/portraits/women/80.jpg",
"https://randomuser.me/api/portraits/women/81.jpg",
"https://randomuser.me/api/portraits/women/82.jpg",
"https://randomuser.me/api/portraits/women/83.jpg",
"https://randomuser.me/api/portraits/women/84.jpg",
"https://randomuser.me/api/portraits/women/85.jpg",
"https://randomuser.me/api/portraits/women/86.jpg",
"https://randomuser.me/api/portraits/women/87.jpg",
"https://randomuser.me/api/portraits/women/88.jpg",
"https://randomuser.me/api/portraits/women/89.jpg",
"https://randomuser.me/api/portraits/women/90.jpg",
"https://randomuser.me/api/portraits/women/91.jpg",
"https://randomuser.me/api/portraits/women/92.jpg",
"https://randomuser.me/api/portraits/women/93.jpg",
"https://randomuser.me/api/portraits/women/94.jpg",
"https://randomuser.me/api/portraits/women/95.jpg",
"https://randomuser.me/api/portraits/women/96.jpg",
"https://randomuser.me/api/portraits/women/97.jpg",
"https://randomuser.me/api/portraits/women/98.jpg",
"https://randomuser.me/api/portraits/women/99.jpg",
}
var defaultArabicRobotNicknames = []string{
"أمير",
"سيف",
"مالك",
"فارس",
"ليث",
"بدر",
"ماهر",
"رامي",
"كريم",
"سامي",
"عمر",
"يوسف",
"آدم",
"ريان",
"زيد",
"نور",
"ياسين",
"أنس",
"خالد",
"طارق",
"حازم",
"نادر",
"وسيم",
"جواد",
"مروان",
"إياد",
"نبيل",
"هلال",
"تميم",
"سلمان",
"راشد",
"جاسم",
"ماجد",
"سلطان",
"عادل",
"هاني",
"بلال",
"يزن",
"غيث",
"قصي",
"سليم",
"ريان_الليل",
"نجم",
"ملك_اللعبة",
"صقر",
"ذيب",
"أسد",
"الزعيم",
"القناص",
"الأسطورة",
"ظل",
"برق",
"رعد",
"موج",
"نمر",
"شهاب",
"فهد",
"نايف",
"نوف",
"ليان",
"ريم",
"سارة",
"مريم",
"لينا",
"هنا",
"جود",
"تالا",
"دانا",
"لارا",
"ياسمين",
"نوران",
"رؤى",
"سما",
"ملك",
"جنى",
"آية",
"ندى",
"فرح",
"أمل",
"هدى",
"سلمى",
"ليلى",
"رنا",
"نورا",
"ديما",
"مايا",
"ميس",
"رهف",
"شهد",
"غلا",
"لولو",
"وردة",
"قمر",
"نجمة",
"لؤلؤة",
"الملكة",
"أميرة",
"روح",
"حلم",
"بسمة",
}
var defaultEnglishRobotNicknames = []string{
"Alex",
"Jordan",
"Taylor",
"Morgan",
"Riley",
"Nova",
"Phoenix",
"Hunter",
"Luna",
"Mia",
"Noah",
"Liam",
"Ava",
"Emma",
"Oliver",
"Elijah",
"James",
"William",
"Benjamin",
"Lucas",
"Mason",
"Ethan",
"Logan",
"Jackson",
"Levi",
"Sebastian",
"Mateo",
"Jack",
"Owen",
"Theodore",
"Aiden",
"Samuel",
"Joseph",
"John",
"David",
"Wyatt",
"Matthew",
"Luke",
"Asher",
"Carter",
"Julian",
"Grayson",
"Leo",
"Jayden",
"Gabriel",
"Isaac",
"Lincoln",
"Anthony",
"Hudson",
"Dylan",
"Ezra",
"Thomas",
"Charles",
"Christopher",
"Jaxon",
"Maverick",
"Josiah",
"Isaiah",
"Andrew",
"Elias",
"Joshua",
"Nathan",
"Caleb",
"Ryan",
"Adrian",
"Miles",
"Eli",
"Nolan",
"Christian",
"Aaron",
"Cameron",
"Ezekiel",
"Colton",
"Luca",
"Landon",
"Hannah",
"Sofia",
"Isabella",
"Amelia",
"Olivia",
"Charlotte",
"Harper",
"Evelyn",
"Abigail",
"Ella",
"Scarlett",
"Grace",
"Chloe",
"Victoria",
"Aria",
"Penelope",
"Layla",
"Zoey",
"Nora",
"Lily",
"Ellie",
"Violet",
"Aurora",
"Stella",
"Hazel",
}
func normalizeRobotNicknameLanguage(value string) string {
// 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。
switch strings.ToLower(strings.TrimSpace(value)) {
case "ar", "arabic", "arab":
return robotNicknameLanguageArabic
case "en", "english":
return robotNicknameLanguageEnglish
default:
return robotNicknameLanguageEnglish
}
}
func robotAccountLanguage(nicknameLanguage string) string {
// 用户资料里的 language 跟昵称池保持一致,后续客户端或运营筛选机器人时不用再反推昵称字符集。
if normalizeRobotNicknameLanguage(nicknameLanguage) == robotNicknameLanguageArabic {
return "ar"
}
return "en"
}
func robotNickname(index int, nicknameLanguage string) string {
// 机器人昵称只从单一语言池取值,不能再拼随机 hex否则会出现数字、下划线和英阿混排。
language := normalizeRobotNicknameLanguage(nicknameLanguage)
if language == robotNicknameLanguageArabic {
return robotNicknameFromPool(index, defaultArabicRobotNicknames, "لاعب", language)
}
return robotNicknameFromPool(index, defaultEnglishRobotNicknames, "Player", language)
}
func robotNicknameFromPool(index int, pool []string, fallback string, language string) string {
// 前 100 个直接使用预设名;超过预设数量时用同语言两个预设名拼接,保证批量 200 个仍然不带数字后缀。
if index < 0 {
index = 0
}
if len(pool) == 0 {
return fallback
}
base := sanitizeRobotNickname(pool[index%len(pool)], language)
if base == "" {
base = fallback
}
if index < len(pool) {
return base
}
next := sanitizeRobotNickname(pool[(index%len(pool)+1)%len(pool)], language)
if next == "" || next == base {
next = sanitizeRobotNickname(pool[(index+index/len(pool)+2)%len(pool)], language)
}
if next == "" || next == base {
return base
}
return base + next
}
func sanitizeRobotNickname(value string, language string) string {
// 预设里可能有旧的分隔符或非目标语言字符;写库前统一剔除,保证“英语纯英语、阿拉伯语纯阿拉伯语”。
var builder strings.Builder
for _, item := range strings.TrimSpace(value) {
switch language {
case robotNicknameLanguageArabic:
if unicode.Is(unicode.Arabic, item) {
builder.WriteRune(item)
}
default:
if (item >= 'A' && item <= 'Z') || (item >= 'a' && item <= 'z') {
builder.WriteRune(item)
}
}
}
return builder.String()
}
func robotGender(index int, requested string) string {
requested = strings.TrimSpace(requested)
if requested != "" {
return requested
}
if index%2 == 0 {
return "male"
}
return "female"
}
func robotAvatarSource(index int, requested []string) string {
if source := pickString(requested, index); source != "" {
return source
}
if len(defaultRobotAvatarSources) == 0 {
return ""
}
return defaultRobotAvatarSources[index%len(defaultRobotAvatarSources)]
}

View File

@ -0,0 +1,42 @@
package gamemanagement
import (
"context"
"hyapp-admin-server/internal/integration/userclient"
)
func (h *Handler) enrichDiceRobotProfiles(ctx context.Context, requestID string, items []diceRobotDTO) error {
if h.user == nil || len(items) == 0 {
return nil
}
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserIDNumber > 0 {
userIDs = append(userIDs, item.UserIDNumber)
}
}
if len(userIDs) == 0 {
return nil
}
// 机器人登记表只拥有游戏侧启停事实;头像、昵称和短 ID 仍以 user-service 用户主数据为准。
users, err := h.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{
RequestID: requestID,
Caller: "admin-server",
UserIDs: userIDs,
})
if err != nil {
return err
}
for index := range items {
user := users[items[index].UserIDNumber]
if user == nil {
continue
}
items[index].ShortID = user.DisplayUserID
items[index].Nickname = user.Username
items[index].Avatar = user.Avatar
}
return nil
}

View File

@ -20,4 +20,11 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
protected.GET("/admin/game/self-games", middleware.RequirePermission("game:view"), h.ListSelfGames)
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequirePermission("game:update"), h.UpdateDiceConfig)
protected.POST("/admin/game/self-games/:game_id/pool/adjust", middleware.RequirePermission("game:update"), h.AdjustDicePool)
protected.GET("/admin/game/robots", middleware.RequirePermission("game:view"), h.ListDiceRobots)
protected.POST("/admin/game/robots/generate", middleware.RequirePermission("game:create"), h.GenerateDiceRobots)
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequirePermission("game:update"), h.SetDiceRobotStatus)
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteDiceRobot)
}

View File

@ -0,0 +1,262 @@
package gamemanagement
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
func (h *Handler) ListSelfGames(c *gin.Context) {
resp, err := h.game.ListSelfGames(c.Request.Context(), &gamev1.ListSelfGamesRequest{Meta: requestMeta(c)})
if err != nil {
response.ServerError(c, "获取自研游戏失败")
return
}
items := make([]diceConfigDTO, 0, len(resp.GetGames()))
for _, item := range resp.GetGames() {
items = append(items, diceConfigFromProto(item))
}
response.OK(c, gin.H{"items": items, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) UpdateDiceConfig(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req diceConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "骰子配置参数不正确")
return
}
resp, err := h.game.UpdateDiceConfig(c.Request.Context(), &gamev1.UpdateDiceConfigRequest{
Meta: requestMeta(c),
Config: req.toProto(gameID),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := diceConfigFromProto(resp.GetConfig())
h.auditLog(c, "update-dice-config", "game_self_game_configs", item.GameID, item.Status)
response.OK(c, item)
}
func (h *Handler) AdjustDicePool(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req dicePoolAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "奖池调整参数不正确")
return
}
resp, err := h.game.AdjustDicePool(c.Request.Context(), &gamev1.AdjustDicePoolRequest{
Meta: requestMeta(c),
GameId: gameID,
AmountCoin: req.AmountCoin,
Direction: strings.TrimSpace(req.Direction),
Reason: strings.TrimSpace(req.Reason),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "adjust-dice-pool", "game_self_game_pools", gameID, fmt.Sprintf("%s:%d", req.Direction, req.AmountCoin))
response.OK(c, gin.H{"adjustment": dicePoolAdjustmentFromProto(resp.GetAdjustment()), "config": diceConfigFromProto(resp.GetConfig())})
}
func (h *Handler) ListDiceRobots(c *gin.Context) {
pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50))
requestID := middleware.CurrentRequestID(c)
resp, err := h.game.ListDiceRobots(c.Request.Context(), &gamev1.ListDiceRobotsRequest{
Meta: requestMeta(c),
GameId: strings.TrimSpace(firstQuery(c, "gameId", "game_id")),
Status: strings.TrimSpace(c.Query("status")),
PageSize: pageSize,
Cursor: strings.TrimSpace(c.Query("cursor")),
})
if err != nil {
response.ServerError(c, "获取游戏机器人失败")
return
}
items := make([]diceRobotDTO, 0, len(resp.GetRobots()))
for _, item := range resp.GetRobots() {
items = append(items, diceRobotFromProto(item))
}
if err := h.enrichDiceRobotProfiles(c.Request.Context(), requestID, items); err != nil {
response.ServerError(c, "获取机器人用户资料失败")
return
}
response.OK(c, gin.H{"items": items, "nextCursor": resp.GetNextCursor(), "pageSize": pageSize, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) GenerateDiceRobots(c *gin.Context) {
if h.user == nil {
response.ServerError(c, "用户服务不可用")
return
}
var req diceGenerateRobotsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "机器人生成参数不正确")
return
}
if req.Count <= 0 {
req.Count = 20
}
if req.Count > 200 {
req.Count = 200
}
nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage)
country := strings.TrimSpace(req.Country)
if country == "" {
country = "SA"
}
userIDs := make([]int64, 0, req.Count)
requestID := middleware.CurrentRequestID(c)
actorID := middleware.CurrentUserID(c)
avatarCache := make(map[string]string)
for index := int32(0); index < req.Count; index++ {
// 批量机器人必须一次性写齐完整资料;同一请求内相同头像源只转存一次,避免 200 个账号重复下载上传同一张图。
avatarSource := robotAvatarSource(int(index), req.AvatarURLs)
avatarURL := avatarCache[avatarSource]
if avatarURL == "" {
var err error
avatarURL, err = h.uploadRobotAvatarFromSource(c.Request.Context(), actorID, avatarSource)
if err != nil {
response.BadRequest(c, "上传机器人头像失败: "+err.Error())
return
}
avatarCache[avatarSource] = avatarURL
}
created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{
RequestID: requestID,
Caller: "admin-server",
Password: randomRobotPassword(),
Username: robotNickname(int(index), nicknameLanguage),
Avatar: avatarURL,
Gender: robotGender(int(index), req.Gender),
Country: country,
DeviceID: "game-robot-" + randomRobotHex(8),
Source: "game_robot",
InstallChannel: "admin",
Platform: "android",
Language: robotAccountLanguage(nicknameLanguage),
Timezone: "UTC",
})
if err != nil {
response.BadRequest(c, "创建机器人用户失败: "+err.Error())
return
}
userIDs = append(userIDs, created.UserID)
}
resp, err := h.game.RegisterDiceRobots(c.Request.Context(), &gamev1.RegisterDiceRobotsRequest{
Meta: requestMeta(c),
GameId: strings.TrimSpace(firstQuery(c, "gameId", "game_id")),
UserIds: userIDs,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]diceRobotDTO, 0, len(resp.GetRobots()))
for _, item := range resp.GetRobots() {
items = append(items, diceRobotFromProto(item))
}
if err := h.enrichDiceRobotProfiles(c.Request.Context(), requestID, items); err != nil {
response.ServerError(c, "获取机器人用户资料失败")
return
}
h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "dice", fmt.Sprintf("count=%d", len(userIDs)))
response.OK(c, gin.H{"created": len(userIDs), "items": items})
}
func (h *Handler) SetDiceRobotStatus(c *gin.Context) {
gameID := strings.TrimSpace(firstQuery(c, "gameId", "game_id"))
if gameID == "" {
gameID = "dice"
}
userID, err := parseInt64Param(c.Param("user_id"))
if err != nil || userID <= 0 {
response.BadRequest(c, "机器人用户 ID 参数不正确")
return
}
var req diceRobotStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "机器人状态参数不正确")
return
}
resp, err := h.game.SetDiceRobotStatus(c.Request.Context(), &gamev1.SetDiceRobotStatusRequest{
Meta: requestMeta(c),
GameId: gameID,
UserId: userID,
Status: strings.TrimSpace(req.Status),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := diceRobotFromProto(resp.GetRobot())
h.auditLog(c, "set-dice-robot-status", "game_self_game_robots", item.UserID, item.Status)
response.OK(c, item)
}
// DeleteDiceRobot 删除当前自研游戏的机器人登记,后台只移除可匹配机器人池,不触碰真实用户资料。
func (h *Handler) DeleteDiceRobot(c *gin.Context) {
gameID := strings.TrimSpace(firstQuery(c, "gameId", "game_id"))
if gameID == "" {
gameID = "dice"
}
userID, err := parseInt64Param(c.Param("user_id"))
if err != nil || userID <= 0 {
response.BadRequest(c, "机器人用户 ID 参数不正确")
return
}
// 后台删除只撤销 game-service 的机器人登记;真实用户仍保留在 user-service历史对局和钱包流水可以继续按 user_id 追溯。
if _, err := h.game.DeleteDiceRobot(c.Request.Context(), &gamev1.DeleteDiceRobotRequest{
Meta: requestMeta(c),
GameId: gameID,
UserId: userID,
}); err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "delete-dice-robot", "game_self_game_robots", fmt.Sprintf("%d", userID), gameID)
response.OK(c, gin.H{"deleted": true})
}
func randomRobotPassword() string {
return "Robot#" + randomRobotHex(12)
}
func randomRobotHex(size int) string {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "fallback"
}
return hex.EncodeToString(buf)
}
func pickString(values []string, index int) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[index%len(values)])
}
func parseInt64Param(raw string) (int64, error) {
var value int64
_, err := fmt.Sscan(strings.TrimSpace(raw), &value)
return value, err
}

View File

@ -53,6 +53,33 @@ type CoinSellerSalaryRateTier struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
func userCountryRegionJoin(userAlias, countryRegionAlias, regionAlias string) string {
// 组织列表里的区域只用于后台展示、搜索和筛选,必须以用户当前国家归属的 active 区域为准。
// 身份表不再保存区域;这里始终按用户国家映射 active 区域,避免用户国家调整后列表继续显示旧值。
return fmt.Sprintf(`
LEFT JOIN region_countries %s ON %s.app_code = %s.app_code
AND %s.country_code = %s.country
AND %s.status = 'active'
LEFT JOIN regions %s ON %s.app_code = %s.app_code
AND %s.region_id = %s.region_id
AND %s.status = 'active'`,
countryRegionAlias, countryRegionAlias, userAlias,
countryRegionAlias, userAlias,
countryRegionAlias,
regionAlias, regionAlias, countryRegionAlias,
regionAlias, countryRegionAlias,
regionAlias,
)
}
func userCountryRegionIDSQL(regionAlias string) string {
return fmt.Sprintf("COALESCE(%s.region_id, 0)", regionAlias)
}
func userCountryRegionNameSQL(regionAlias string) string {
return fmt.Sprintf("COALESCE(%s.name, '')", regionAlias)
}
// ListBDProfiles 只读 user-service 的 BD/BD Leader 事实表,后台列表按角色选择物理表,避免把两种身份重新混在一起。
func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) {
if r == nil || r.db == nil {
@ -65,9 +92,9 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
whereSQL := `
FROM bd_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.role = ?`
args := []any{appCode, role}
if query.Status != "" {
@ -75,7 +102,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND bp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.ParentLeaderUserID > 0 {
@ -84,7 +111,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
}
@ -93,10 +120,10 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
COALESCE(parent_leader.avatar, ''),
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
@ -168,8 +195,8 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
whereSQL := `
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -177,12 +204,12 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND bp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
}
@ -191,10 +218,10 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
'', '', '',
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
@ -261,10 +288,10 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
(
@ -276,8 +303,8 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
)
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
`+userCountryRegionJoin("u", "user_country_region", "user_region")+`
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.user_id = ?
`, appCode, userID).Scan(
&item.UserID,
@ -448,12 +475,14 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.
}
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', %s, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
%s
WHERE bp.app_code = ? AND bp.user_id = ?
`, appCode, userID).Scan(
`, userCountryRegionIDSQL("user_region"), userCountryRegionJoin("u", "user_country_region", "user_region")), appCode, userID).Scan(
&item.UserID,
&item.Role,
&item.RegionID,
@ -501,8 +530,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
whereSQL := `
FROM agencies a
LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
` + userCountryRegionJoin("owner", "owner_country_region", "owner_region") + `
LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
LEFT JOIN regions r ON r.app_code = a.app_code AND r.region_id = a.region_id
WHERE a.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -513,7 +542,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
args = append(args, "deleted")
}
if query.RegionID > 0 {
whereSQL += " AND a.region_id = ?"
whereSQL += " AND owner_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.ParentBDUserID > 0 {
@ -522,7 +551,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR owner_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like, like, like)
}
@ -531,13 +560,13 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, a.region_id, a.parent_bd_user_id,
SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id,
a.name, a.status, a.join_enabled, a.max_hosts,
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
COALESCE(owner.avatar, ''),
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
COALESCE(parent_bd.avatar, ''), COALESCE(r.name, '')
COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`
%s
ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ?
@ -586,9 +615,9 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
whereSQL := `
FROM host_profiles hp
LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
LEFT JOIN users agency_owner ON agency_owner.app_code = a.app_code AND agency_owner.user_id = a.owner_user_id
LEFT JOIN regions r ON r.app_code = hp.app_code AND r.region_id = hp.region_id
WHERE hp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -596,7 +625,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND hp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.AgencyID > 0 {
@ -605,7 +634,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like)
}
@ -620,11 +649,11 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
queryArgs = args
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0),
SELECT hp.user_id, hp.status, `+userCountryRegionIDSQL("user_region")+`, COALESCE(hp.current_agency_id, 0),
COALESCE(hp.current_membership_id, 0), hp.source,
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''), COALESCE(a.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
COALESCE(a.owner_user_id, 0), COALESCE(agency_owner.current_display_user_id, ''),
COALESCE(agency_owner.username, ''), COALESCE(agency_owner.avatar, '')
%s

View File

@ -1,6 +1,9 @@
package hostorg
import "testing"
import (
"strings"
"testing"
)
// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受。
func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) {
@ -25,6 +28,32 @@ func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) {
}
}
// TestUserCountryRegionSQLUsesCountryMapping 锁定组织列表区域展示口径:从用户国家映射 active 区域,不从身份表区域快照取值。
func TestUserCountryRegionSQLUsesCountryMapping(t *testing.T) {
joinSQL := userCountryRegionJoin("owner", "owner_country_region", "owner_region")
for _, want := range []string{
"LEFT JOIN region_countries owner_country_region",
"owner_country_region.country_code = owner.country",
"owner_country_region.status = 'active'",
"LEFT JOIN regions owner_region",
"owner_region.region_id = owner_country_region.region_id",
"owner_region.status = 'active'",
} {
if !strings.Contains(joinSQL, want) {
t.Fatalf("country region join missing %q in %s", want, joinSQL)
}
}
if strings.Contains(joinSQL, "owner.region_id") {
t.Fatalf("country region join must not read identity or user region snapshots: %s", joinSQL)
}
if got := userCountryRegionIDSQL("owner_region"); got != "COALESCE(owner_region.region_id, 0)" {
t.Fatalf("region id projection mismatch: %s", got)
}
if got := userCountryRegionNameSQL("owner_region"); got != "COALESCE(owner_region.name, '')" {
t.Fatalf("region name projection mismatch: %s", got)
}
}
// TestSortCoinSellerListItemsComputedFields 验证 wallet 聚合字段排序只改变展示顺序,不改动币商身份数据。
func TestSortCoinSellerListItemsComputedFields(t *testing.T) {
items := []*CoinSellerListItem{

View File

@ -47,6 +47,7 @@ type rechargeProductDTO struct {
ProductCode string `json:"productCode"`
ProductName string `json:"productName"`
Description string `json:"description"`
AudienceType string `json:"audienceType"`
Platform string `json:"platform"`
Channel string `json:"channel"`
CurrencyCode string `json:"currencyCode"`
@ -67,6 +68,34 @@ type rechargeProductDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type thirdPartyPaymentChannelDTO struct {
AppCode string `json:"appCode"`
ProviderCode string `json:"providerCode"`
ProviderName string `json:"providerName"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
Methods []thirdPartyPaymentMethodDTO `json:"methods"`
}
type thirdPartyPaymentMethodDTO struct {
MethodID int64 `json:"methodId"`
AppCode string `json:"appCode"`
ProviderCode string `json:"providerCode"`
ProviderName string `json:"providerName"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
CurrencyCode string `json:"currencyCode"`
PayWay string `json:"payWay"`
PayType string `json:"payType"`
MethodName string `json:"methodName"`
LogoURL string `json:"logoUrl"`
Status string `json:"status"`
USDToCurrencyRate string `json:"usdToCurrencyRate"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
if item == nil {
return rechargeBillDTO{}
@ -154,6 +183,7 @@ func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO
ProductCode: item.GetProductCode(),
ProductName: item.GetProductName(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
Platform: item.GetPlatform(),
Channel: item.GetChannel(),
CurrencyCode: item.GetCurrencyCode(),
@ -175,6 +205,48 @@ func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO
}
}
func thirdPartyPaymentChannelFromProto(item *walletv1.ThirdPartyPaymentChannel) thirdPartyPaymentChannelDTO {
if item == nil {
return thirdPartyPaymentChannelDTO{}
}
dto := thirdPartyPaymentChannelDTO{
AppCode: item.GetAppCode(),
ProviderCode: item.GetProviderCode(),
ProviderName: item.GetProviderName(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Methods: make([]thirdPartyPaymentMethodDTO, 0, len(item.GetMethods())),
}
for _, method := range item.GetMethods() {
dto.Methods = append(dto.Methods, thirdPartyPaymentMethodFromProto(method))
}
return dto
}
func thirdPartyPaymentMethodFromProto(item *walletv1.ThirdPartyPaymentMethod) thirdPartyPaymentMethodDTO {
if item == nil {
return thirdPartyPaymentMethodDTO{}
}
return thirdPartyPaymentMethodDTO{
MethodID: item.GetMethodId(),
AppCode: item.GetAppCode(),
ProviderCode: item.GetProviderCode(),
ProviderName: item.GetProviderName(),
CountryCode: item.GetCountryCode(),
CountryName: item.GetCountryName(),
CurrencyCode: item.GetCurrencyCode(),
PayWay: item.GetPayWay(),
PayType: item.GetPayType(),
MethodName: item.GetMethodName(),
LogoURL: item.GetLogoUrl(),
Status: item.GetStatus(),
USDToCurrencyRate: item.GetUsdToCurrencyRate(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func formatUSDTMicro(amount int64) string {
if amount <= 0 {
return "0"

View File

@ -130,14 +130,15 @@ func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, use
func (h *Handler) ListRechargeProducts(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.wallet.ListAdminRechargeProducts(c.Request.Context(), &walletv1.ListAdminRechargeProductsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
AudienceType: strings.TrimSpace(firstQuery(c, "audience_type", "audienceType")),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
writeWalletError(c, err, "获取内购配置失败")
@ -169,6 +170,7 @@ func (h *Handler) CreateRechargeProduct(c *gin.Context) {
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
AudienceType: strings.TrimSpace(request.AudienceType),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
@ -206,6 +208,7 @@ func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
AudienceType: strings.TrimSpace(request.AudienceType),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
@ -238,17 +241,99 @@ func (h *Handler) DeleteRechargeProduct(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListThirdPartyPaymentChannels(c *gin.Context) {
resp, err := h.wallet.ListThirdPartyPaymentChannels(c.Request.Context(), &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
Status: strings.TrimSpace(firstQuery(c, "status")),
IncludeDisabledMethods: true,
})
if err != nil {
writeWalletError(c, err, "获取三方支付渠道失败")
return
}
items := make([]thirdPartyPaymentChannelDTO, 0, len(resp.GetChannels()))
for _, item := range resp.GetChannels() {
items = append(items, thirdPartyPaymentChannelFromProto(item))
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) SetThirdPartyPaymentMethodStatus(c *gin.Context) {
methodID := queryPathInt64(c, "method_id")
if methodID <= 0 {
response.BadRequest(c, "支付方式ID不正确")
return
}
var request thirdPartyPaymentMethodStatusRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付方式状态参数不正确")
return
}
resp, err := h.wallet.SetThirdPartyPaymentMethodStatus(c.Request.Context(), &walletv1.SetThirdPartyPaymentMethodStatusRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: methodID,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改支付方式状态失败")
return
}
item := thirdPartyPaymentMethodFromProto(resp.GetMethod())
shared.OperationLogWithResourceID(c, h.audit, "update-third-party-payment-method-status", "third_party_payment_methods", strconv.FormatInt(methodID, 10), "success", item.Status)
response.OK(c, item)
}
func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) {
methodID := queryPathInt64(c, "method_id")
if methodID <= 0 {
response.BadRequest(c, "支付方式ID不正确")
return
}
var request thirdPartyPaymentRateRequest
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.USDToCurrencyRate) == "" {
response.BadRequest(c, "支付汇率参数不正确")
return
}
resp, err := h.wallet.UpdateThirdPartyPaymentRate(c.Request.Context(), &walletv1.UpdateThirdPartyPaymentRateRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: methodID,
UsdToCurrencyRate: strings.TrimSpace(request.USDToCurrencyRate),
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改支付方式汇率失败")
return
}
item := thirdPartyPaymentMethodFromProto(resp.GetMethod())
shared.OperationLogWithResourceID(c, h.audit, "update-third-party-payment-rate", "third_party_payment_methods", strconv.FormatInt(methodID, 10), "success", item.USDToCurrencyRate)
response.OK(c, item)
}
type rechargeProductRequest struct {
AmountUSDT string `json:"amountUsdt"`
AmountUSDTMicro int64 `json:"amountUsdtMicro"`
CoinAmount int64 `json:"coinAmount"`
ProductName string `json:"productName"`
Description string `json:"description"`
AudienceType string `json:"audienceType"`
Platform string `json:"platform"`
RegionIDs []int64 `json:"regionIds"`
Enabled bool `json:"enabled"`
}
type thirdPartyPaymentMethodStatusRequest struct {
Enabled bool `json:"enabled"`
}
type thirdPartyPaymentRateRequest struct {
USDToCurrencyRate string `json:"usdToCurrencyRate"`
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
@ -270,6 +355,15 @@ func firstQuery(c *gin.Context, keys ...string) string {
return ""
}
func queryPathInt64(c *gin.Context, name string) int64 {
value := strings.TrimSpace(c.Param(name))
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed
}
func productID(c *gin.Context) (int64, bool) {
value := strings.TrimSpace(c.Param("product_id"))
parsed, err := strconv.ParseInt(value, 10, 64)

View File

@ -0,0 +1,227 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: "lalu",
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
Status: "active",
SortOrder: 10,
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 810,
AppCode: "lalu",
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Card",
PayType: "MADA",
MethodName: "MADA",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?provider_code=mifapay&status=active", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastThirdPartyChannels == nil ||
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "mifapay" ||
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items := response.Data["items"].([]any)
channel := items[0].(map[string]any)
methods := channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || len(methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" {
t.Fatalf("third party channels response mismatch: %+v", response)
}
}
func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-methods/810/status", bytes.NewBufferString(`{"enabled":false}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastSetMethodStatus == nil ||
wallet.lastSetMethodStatus.GetMethodId() != 810 ||
wallet.lastSetMethodStatus.GetEnabled() ||
wallet.lastSetMethodStatus.GetOperatorUserId() != 7 {
t.Fatalf("method status request mismatch: %+v", wallet.lastSetMethodStatus)
}
}
func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-rates/810", bytes.NewBufferString(`{"usdToCurrencyRate":" 3.75000000 "}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastUpdateRate == nil ||
wallet.lastUpdateRate.GetMethodId() != 810 ||
wallet.lastUpdateRate.GetUsdToCurrencyRate() != "3.75000000" ||
wallet.lastUpdateRate.GetOperatorUserId() != 7 {
t.Fatalf("payment rate request mismatch: %+v", wallet.lastUpdateRate)
}
}
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}`
request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateProduct == nil ||
wallet.lastCreateProduct.GetAmountMicro() != 10_000_000 ||
wallet.lastCreateProduct.GetCoinAmount() != 880000 ||
wallet.lastCreateProduct.GetAudienceType() != "coin_seller" ||
wallet.lastCreateProduct.GetPlatform() != "web" ||
len(wallet.lastCreateProduct.GetRegionIds()) != 1 ||
wallet.lastCreateProduct.GetRegionIds()[0] != 7100 ||
!wallet.lastCreateProduct.GetEnabled() ||
wallet.lastCreateProduct.GetOperatorUserId() != 7 {
t.Fatalf("create recharge product request mismatch: %+v", wallet.lastCreateProduct)
}
}
func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
// 后台 handler 只依赖 app_code、request_id 和操作者;测试在这里模拟鉴权中间件已完成后的上下文。
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "payment-handler-test")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextUsername, "tester")
c.Next()
})
router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels)
router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus)
router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate)
router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct)
return router
}
type adminPaymentTestResponse struct {
Code int `json:"code"`
Data map[string]any `json:"data"`
}
type mockPaymentWallet struct {
walletclient.Client
lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest
lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest
lastCreateProduct *walletv1.CreateRechargeProductRequest
}
func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
m.lastThirdPartyChannels = req
if m.thirdPartyChannelsResp != nil {
return m.thirdPartyChannelsResp, nil
}
return &walletv1.ListThirdPartyPaymentChannelsResponse{}, nil
}
func (m *mockPaymentWallet) SetThirdPartyPaymentMethodStatus(_ context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
m.lastSetMethodStatus = req
status := "disabled"
if req.GetEnabled() {
status = "active"
}
return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{
MethodId: req.GetMethodId(),
AppCode: req.GetAppCode(),
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
PayWay: "Card",
PayType: "MADA",
Status: status,
}}, nil
}
func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
m.lastUpdateRate = req
return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{
MethodId: req.GetMethodId(),
AppCode: req.GetAppCode(),
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
PayWay: "Card",
PayType: "MADA",
Status: "active",
UsdToCurrencyRate: req.GetUsdToCurrencyRate(),
}}, nil
}
func (m *mockPaymentWallet) CreateRechargeProduct(_ context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) {
m.lastCreateProduct = req
return &walletv1.RechargeProductResponse{Product: &walletv1.RechargeProduct{
AppCode: req.GetAppCode(),
ProductId: 9001,
ProductCode: "h5_seller_10",
ProductName: req.GetProductName(),
Description: req.GetDescription(),
AudienceType: req.GetAudienceType(),
Platform: req.GetPlatform(),
AmountMicro: req.GetAmountMicro(),
CoinAmount: req.GetCoinAmount(),
RegionIds: append([]int64(nil), req.GetRegionIds()...),
Enabled: req.GetEnabled(),
Status: "active",
}}, nil
}

View File

@ -12,6 +12,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)

View File

@ -0,0 +1,289 @@
package prettyid
import (
"strconv"
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
user userclient.Client
audit shared.OperationLogger
}
func New(user userclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{user: user, audit: audit}
}
type poolRequest struct {
Name string `json:"name"`
LevelTrack string `json:"level_track"`
MinLevel int32 `json:"min_level"`
MaxLevel int32 `json:"max_level"`
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
}
type generateRequest struct {
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
Count int32 `json:"count"`
}
type grantRequest struct {
TargetUserID any `json:"target_user_id"`
DisplayUserID string `json:"display_user_id"`
DurationMs int64 `json:"duration_ms"`
Reason string `json:"reason"`
}
type statusRequest struct {
Status string `json:"status"`
Reason string `json:"reason"`
}
func (h *Handler) ListPools(c *gin.Context) {
page, pageSize, ok := pageQuery(c)
if !ok {
response.BadRequest(c, "分页参数不正确")
return
}
// admin-server 只解析 HTTP 查询参数和当前管理员身份,池状态、等级轨道合法性由 user-service 统一校验。
items, total, err := h.user.ListPrettyDisplayIDPools(c.Request.Context(), userclient.ListPrettyDisplayIDPoolsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
Status: strings.TrimSpace(c.Query("status")),
LevelTrack: strings.TrimSpace(c.Query("level_track")),
Page: page,
PageSize: pageSize,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize})
}
func (h *Handler) CreatePool(c *gin.Context) {
var req poolRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "靓号池参数不正确")
return
}
// 创建池时操作者从后台登录态读取,不能信任请求体里的管理员 ID。
pool, err := h.user.CreatePrettyDisplayIDPool(c.Request.Context(), userclient.PrettyDisplayIDPoolRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJSON: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-pretty-id-pool", "pretty_display_id_pools", pool.PoolID, "success", "name="+pool.Name)
response.Created(c, pool)
}
func (h *Handler) UpdatePool(c *gin.Context) {
var req poolRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "靓号池参数不正确")
return
}
// pool_id 来自路径body 只承载可编辑字段,避免前端误传 ID 导致更新目标不一致。
poolID := strings.TrimSpace(c.Param("pool_id"))
pool, err := h.user.UpdatePrettyDisplayIDPool(c.Request.Context(), userclient.PrettyDisplayIDPoolRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: poolID,
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJSON: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-pretty-id-pool", "pretty_display_id_pools", pool.PoolID, "success", "status="+pool.Status)
response.OK(c, pool)
}
func (h *Handler) Generate(c *gin.Context) {
var req generateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "生成参数不正确")
return
}
// 生成只从路径读取 pool_id请求体里的规则和数量会继续交给 user-service 校验并写批次审计。
poolID := strings.TrimSpace(c.Param("pool_id"))
batch, err := h.user.GeneratePrettyDisplayIDs(c.Request.Context(), userclient.GeneratePrettyDisplayIDsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: poolID,
RuleType: req.RuleType,
RuleConfigJSON: req.RuleConfigJSON,
Count: req.Count,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "generate-pretty-ids", "pretty_display_id_generation_batches", batch.BatchID, "success", "pool_id="+poolID)
response.Created(c, batch)
}
func (h *Handler) ListIDs(c *gin.Context) {
page, pageSize, ok := pageQuery(c)
if !ok {
response.BadRequest(c, "分页参数不正确")
return
}
assignedUserID, ok := optionalInt64Query(c, "assigned_user_id")
if !ok {
response.BadRequest(c, "assigned_user_id 参数不正确")
return
}
// 列表筛选保持轻量透传空字符串表示不过滤assigned_user_id 只在正整数时作为精确条件。
items, total, err := h.user.ListPrettyDisplayIDs(c.Request.Context(), userclient.ListPrettyDisplayIDsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: strings.TrimSpace(c.Query("pool_id")),
Source: strings.TrimSpace(c.Query("source")),
Status: strings.TrimSpace(c.Query("status")),
Keyword: strings.TrimSpace(c.Query("keyword")),
AssignedUserID: assignedUserID,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize})
}
func (h *Handler) Grant(c *gin.Context) {
var req grantRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "发放参数不正确")
return
}
targetUserID, ok := parseFlexibleInt64(req.TargetUserID)
if !ok {
response.BadRequest(c, "target_user_id 参数不正确")
return
}
// 后台发放允许 target_user_id 由 JS 以数字或字符串提交,这里转成 int64 后把业务校验交给 user-service。
result, err := h.user.AdminGrantPrettyDisplayID(c.Request.Context(), userclient.AdminGrantPrettyDisplayIDRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
TargetUserID: targetUserID,
DisplayUserID: req.DisplayUserID,
DurationMs: req.DurationMs,
Reason: req.Reason,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "grant-pretty-id", "pretty_display_ids", result.PrettyID, "success", "target_user_id="+strconv.FormatInt(targetUserID, 10))
response.Created(c, result)
}
func (h *Handler) SetStatus(c *gin.Context) {
var req statusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态参数不正确")
return
}
// 状态修改以路径 pretty_id 为准,请求体只允许目标状态和原因,防止改错号码。
prettyID := strings.TrimSpace(c.Param("pretty_id"))
item, err := h.user.SetPrettyDisplayIDStatus(c.Request.Context(), userclient.SetPrettyDisplayIDStatusRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PrettyID: prettyID,
Status: req.Status,
Reason: req.Reason,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "set-pretty-id-status", "pretty_display_ids", prettyID, "success", "status="+item.Status)
response.OK(c, item)
}
func pageQuery(c *gin.Context) (int32, int32, bool) {
// 分页参数在 admin-server 先收敛到 1..100,避免后台页面传错值造成 user-service 大分页查询。
page := int32(1)
pageSize := int32(20)
if raw := strings.TrimSpace(c.Query("page")); raw != "" {
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil || value <= 0 {
return 0, 0, false
}
page = int32(value)
}
if raw := strings.TrimSpace(c.Query("page_size")); raw != "" {
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil || value <= 0 {
return 0, 0, false
}
pageSize = int32(value)
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize, true
}
func optionalInt64Query(c *gin.Context, name string) (int64, bool) {
// 空查询表示不筛选,非空必须是正整数,避免把 0 当成真实用户 ID 传给列表接口。
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value > 0
}
func parseFlexibleInt64(raw any) (int64, bool) {
// JSON 大整数在前端经常以字符串提交,数字提交时也必须是精确整数,不能接受小数或负数。
switch value := raw.(type) {
case float64:
if value <= 0 || value != float64(int64(value)) {
return 0, false
}
return int64(value), true
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil && parsed > 0
default:
return 0, false
}
}

View File

@ -0,0 +1,21 @@
package prettyid
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/users/pretty-id-pools", middleware.RequirePermission("pretty-id:view"), h.ListPools)
protected.POST("/admin/users/pretty-id-pools", middleware.RequirePermission("pretty-id:update"), h.CreatePool)
protected.PUT("/admin/users/pretty-id-pools/:pool_id", middleware.RequirePermission("pretty-id:update"), h.UpdatePool)
protected.POST("/admin/users/pretty-id-pools/:pool_id/generate", middleware.RequirePermission("pretty-id:generate"), h.Generate)
protected.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:view"), h.ListIDs)
protected.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), h.Grant)
protected.POST("/admin/users/pretty-ids/:pretty_id/status", middleware.RequirePermission("pretty-id:update"), h.SetStatus)
}

View File

@ -354,7 +354,7 @@ func (s *Service) collectBDCandidates(ctx context.Context, appCode string, trigg
if !ok || agency.ParentBDUserID <= 0 || income <= 0 {
continue
}
// 区域来自 Agency 配置,不从主播记录推导,保证 BD 政策和组织管理后台保持同一口径
// 区域来自 Agency 拥有者 users 当前区域,不从主播记录或 Agency 历史快照推导
if len(regions) > 0 {
if _, ok := regions[agency.RegionID]; !ok {
continue
@ -399,7 +399,7 @@ func (s *Service) collectAdminCandidates(ctx context.Context, appCode string, tr
if !ok {
continue
}
// Admin 的适用区域以 bd_leader profile 为准,避免一个 BD 跨区域时把收入错配到错误政策
// Admin 的适用区域以 BD Leader 用户当前区域为准,不再读取 bd_leader_profiles 的历史 region_id
if len(regions) > 0 {
if _, ok := regions[leader.RegionID]; !ok {
continue
@ -614,7 +614,7 @@ type agencyInfo struct {
RegionID int64
}
// activeAgenciesByOwner 只使用 active agency避免历史或停用 Agency 继续给 BD 产生工资。
// activeAgenciesByOwner 只使用 active agency并从 owner users 行读取当前区域,避免历史快照继续影响 BD 工资。
func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, ownerIDs []int64) (map[int64]agencyInfo, error) {
ownerIDs = uniquePositiveUserIDs(ownerIDs)
if len(ownerIDs) == 0 {
@ -627,9 +627,11 @@ func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, own
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT owner_user_id, parent_bd_user_id, region_id
FROM agencies
WHERE app_code = ? AND status = 'active' AND owner_user_id IN (`+placeholders+`)`, args...)
SELECT a.owner_user_id, a.parent_bd_user_id, COALESCE(u.region_id, 0)
FROM agencies a
INNER JOIN users u
ON u.app_code = a.app_code AND u.user_id = a.owner_user_id
WHERE a.app_code = ? AND a.status = 'active' AND a.owner_user_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
@ -653,7 +655,7 @@ type bdProfile struct {
ParentLeaderUserID int64
}
// bdProfilesByUser 按角色取 active profile普通 BD 和 BD Leader 已拆成两张物理表,调用方必须显式传 role。
// bdProfilesByUser 按角色取 active profileRegionID 始终来自用户当前区域,调用方必须显式传 role。
func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) {
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
@ -666,14 +668,18 @@ func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs
args = append(args, id)
}
query := `
SELECT user_id, role, region_id, COALESCE(parent_leader_user_id, 0)
FROM bd_profiles
WHERE app_code = ? AND role = 'bd' AND status = 'active' AND user_id IN (` + placeholders + `)`
SELECT bp.user_id, bp.role, COALESCE(u.region_id, 0), COALESCE(bp.parent_leader_user_id, 0)
FROM bd_profiles bp
INNER JOIN users u
ON u.app_code = bp.app_code AND u.user_id = bp.user_id
WHERE bp.app_code = ? AND bp.role = 'bd' AND bp.status = 'active' AND bp.user_id IN (` + placeholders + `)`
if role == "bd_leader" {
query = `
SELECT user_id, 'bd_leader', region_id, 0
FROM bd_leader_profiles
WHERE app_code = ? AND status = 'active' AND user_id IN (` + placeholders + `)`
SELECT bl.user_id, 'bd_leader', COALESCE(u.region_id, 0), 0
FROM bd_leader_profiles bl
INNER JOIN users u
ON u.app_code = bl.app_code AND u.user_id = bl.user_id
WHERE bl.app_code = ? AND bl.status = 'active' AND bl.user_id IN (` + placeholders + `)`
}
rows, err := s.userDB.QueryContext(ctx, `
`+query, args...)

View File

@ -148,18 +148,18 @@ func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB
`INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`,
`INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`,
`INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar) VALUES
('lalu', 3001, 'AG3001', 'Agency One', ''),
('lalu', 3002, 'AG3002', 'Agency Two', ''),
('lalu', 4001, 'BD4001', 'BD One', ''),
('lalu', 5001, 'AD5001', 'Admin One', '')`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES
('lalu', 3001, 4001, 686, 'active'),
('lalu', 3002, 4001, 686, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 686, 5001, 'active')`,
`INSERT INTO bd_leader_profiles (app_code, user_id, region_id, status) VALUES
('lalu', 5001, 686, 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar, region_id) VALUES
('lalu', 3001, 'AG3001', 'Agency One', '', 686),
('lalu', 3002, 'AG3002', 'Agency Two', '', 686),
('lalu', 4001, 'BD4001', 'BD One', '', 686),
('lalu', 5001, 'AD5001', 'Admin One', '', 686)`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, status) VALUES
('lalu', 3001, 4001, 'active'),
('lalu', 3002, 4001, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 5001, 'active')`,
`INSERT INTO bd_leader_profiles (app_code, user_id, status) VALUES
('lalu', 5001, 'active')`,
)
execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records
(app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id,
@ -217,6 +217,7 @@ func teamSalaryUserDDL() []string {
current_display_user_id VARCHAR(64) NOT NULL DEFAULT '',
username VARCHAR(128) NULL,
avatar VARCHAR(512) NULL,
region_id BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (app_code, user_id)
)`,
`CREATE TABLE regions (
@ -243,7 +244,6 @@ func teamSalaryUserDDL() []string {
app_code VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
parent_bd_user_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, owner_user_id)
)`,
@ -251,7 +251,6 @@ func teamSalaryUserDDL() []string {
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(24) NOT NULL,
region_id BIGINT NOT NULL,
parent_leader_user_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id, role)
@ -259,7 +258,6 @@ func teamSalaryUserDDL() []string {
`CREATE TABLE bd_leader_profiles (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id)
)`,

View File

@ -22,6 +22,10 @@ var defaultPermissions = []model.Permission{
{Name: "App 用户设置密码", Code: "app-user:password", Kind: "button"},
{Name: "等级配置查看", Code: "level-config:view", Kind: "menu"},
{Name: "等级配置更新", Code: "level-config:update", Kind: "button"},
{Name: "靓号管理查看", Code: "pretty-id:view", Kind: "menu"},
{Name: "靓号池更新", Code: "pretty-id:update", Kind: "button"},
{Name: "靓号批量生成", Code: "pretty-id:generate", Kind: "button"},
{Name: "靓号后台发放", Code: "pretty-id:grant", Kind: "button"},
{Name: "地区屏蔽查看", Code: "region-block:view", Kind: "menu"},
{Name: "地区屏蔽更新", Code: "region-block:update", Kind: "button"},
{Name: "房间查看", Code: "room:view", Kind: "menu"},
@ -94,6 +98,8 @@ var defaultPermissions = []model.Permission{
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
@ -243,7 +249,8 @@ func (s *Store) seedMenus() error {
{ParentID: &appUsersID, Title: "封禁列表", Code: "app-user-bans", Path: "/app/users/bans", Icon: "block", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 62, Visible: true},
{ParentID: &appUsersID, Title: "等级配置", Code: "app-user-level-config", Path: "/app/users/level-config", Icon: "military_tech", PermissionCode: "level-config:view", Sort: 63, Visible: true},
{ParentID: &appUsersID, Title: "地区屏蔽", Code: "app-user-region-blocks", Path: "/app/users/region-blocks", Icon: "public", PermissionCode: "region-block:view", Sort: 64, Visible: true},
{ParentID: &appUsersID, Title: "靓号管理", Code: "app-user-pretty-ids", Path: "/app/users/pretty-ids", Icon: "tag", PermissionCode: "pretty-id:view", Sort: 64, Visible: true},
{ParentID: &appUsersID, Title: "地区屏蔽", Code: "app-user-region-blocks", Path: "/app/users/region-blocks", Icon: "public", PermissionCode: "region-block:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间置顶", Code: "room-pins", Path: "/rooms/pins", Icon: "push_pin", PermissionCode: "room-pin:view", Sort: 66, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
@ -251,7 +258,6 @@ func (s *Store) seedMenus() error {
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "开屏配置", Code: "app-config-splash-screens", Path: "/app-config/splash-screens", Icon: "image", PermissionCode: "app-config:view", Sort: 68, Visible: true},
{ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 69, Visible: true},
{ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true},
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 71, Visible: true},
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
{ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true},
@ -266,6 +272,8 @@ func (s *Store) seedMenus() error {
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 72, Visible: true},
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 73, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
{ParentID: &paymentID, Title: "内购配置", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
{ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true},
@ -277,6 +285,8 @@ func (s *Store) seedMenus() error {
{ParentID: &activityID, Title: "VIP配置", Code: "vip-config", Path: "/activities/vip-config", Icon: "workspace_premium", PermissionCode: "vip-config:view", Sort: 77, Visible: true},
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 78, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "game:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "游戏机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true},
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
{ParentID: &hostOrgID, Title: "Manager列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "agency:view", Sort: 80, Visible: true},
@ -497,6 +507,7 @@ func defaultRolePermissionCodes(code string) []string {
"user:view", "user:status",
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"region-block:view", "region-block:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
@ -513,7 +524,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -529,13 +540,14 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"app-user:view",
"level-config:view",
"pretty-id:view",
"region-block:view",
"room:view",
"room-pin:view",
@ -564,6 +576,7 @@ func defaultRolePermissionCodes(code string) []string {
"gift-diamond:view",
"lucky-gift:view",
"payment-bill:view",
"payment-third-party:view",
"payment-product:view",
"game:view",
"daily-task:view",
@ -594,6 +607,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"region-block:view", "region-block:update",
"resource:view", "resource:create", "resource:update",
"resource-shop:view", "resource-shop:update",
@ -606,7 +620,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -619,7 +633,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
}
case "auditor", "readonly":
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view"}
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view"}
default:
return nil
}

View File

@ -28,6 +28,7 @@ import (
"hyapp-admin-server/internal/modules/luckygift"
"hyapp-admin-server/internal/modules/menu"
"hyapp-admin-server/internal/modules/payment"
"hyapp-admin-server/internal/modules/prettyid"
"hyapp-admin-server/internal/modules/rbac"
"hyapp-admin-server/internal/modules/redpacket"
"hyapp-admin-server/internal/modules/regionblock"
@ -77,6 +78,7 @@ type Handlers struct {
LuckyGift *luckygift.Handler
Menu *menu.Handler
Payment *payment.Handler
PrettyID *prettyid.Handler
RBAC *rbac.Handler
RedPacket *redpacket.Handler
Report *reportmodule.Handler
@ -137,6 +139,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
hostorg.RegisterRoutes(protected, h.HostOrg)
audit.RegisterRoutes(protected, h.Audit)
payment.RegisterRoutes(protected, h.Payment)
prettyid.RegisterRoutes(protected, h.PrettyID)
job.RegisterRoutes(protected, h.Job)
levelconfig.RegisterRoutes(protected, h.LevelConfig)
luckygift.RegisterRoutes(protected, h.LuckyGift)

View File

@ -9,6 +9,8 @@ SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('金币流水查看', 'coin-ledger:view', 'menu', '', @now_ms, @now_ms),
('支付账单查看', 'payment-bill:view', 'menu', '', @now_ms, @now_ms),
('三方支付查看', 'payment-third-party:view', 'menu', '', @now_ms, @now_ms),
('三方支付更新', 'payment-third-party:update', 'button', '', @now_ms, @now_ms),
('内购配置查看', 'payment-product:view', 'menu', '', @now_ms, @now_ms),
('内购配置创建', 'payment-product:create', 'button', '', @now_ms, @now_ms),
('内购配置更新', 'payment-product:update', 'button', '', @now_ms, @now_ms),
@ -47,9 +49,9 @@ ON DUPLICATE KEY UPDATE
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '内购配置', 'payment-recharge-products', '/app-config/recharge-products', 'wallet', 'payment-product:view', 68, TRUE, @now_ms, @now_ms
SELECT parent.id, '内购配置', 'payment-recharge-products', '/payment/recharge-products', 'wallet', 'payment-product:view', 70, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'app-config'
WHERE parent.code = 'payment'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
@ -88,6 +90,20 @@ ON DUPLICATE KEY UPDATE
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '三方支付', 'payment-third-party', '/payment/third-party', 'wallet', 'payment-third-party:view', 69, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'payment'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '每日任务', 'daily-task-list', '/activities/daily-tasks', 'task', 'daily-task:view', 69, TRUE, @now_ms, @now_ms
FROM admin_menus parent
@ -130,6 +146,34 @@ ON DUPLICATE KEY UPDATE
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '自研游戏', 'self-games', '/games/self-games', 'settings', 'game:view', 80, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'games'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '游戏机器人', 'game-robots', '/games/robots', 'team', 'game:view', 90, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'games'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_roles (name, code, description, created_at_ms, updated_at_ms) VALUES
('平台管理员', 'platform-admin', '拥有管理后台全部权限', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE

View File

@ -0,0 +1,5 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE admin_app_splash_screens
ADD COLUMN display_duration_ms INT NOT NULL DEFAULT 3000 COMMENT '开屏展示时长毫秒0 表示客户端默认值'
AFTER description;

View File

@ -0,0 +1,47 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('靓号管理查看', 'pretty-id:view', 'menu', '', @now_ms, @now_ms),
('靓号池更新', 'pretty-id:update', 'button', '', @now_ms, @now_ms),
('靓号批量生成', 'pretty-id:generate', 'button', '', @now_ms, @now_ms),
('靓号后台发放', 'pretty-id:grant', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '靓号管理', 'app-user-pretty-ids', '/app/users/pretty-ids', 'tag', 'pretty-id:view', 64, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'app-users'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus
SET sort = 65, updated_at_ms = @now_ms
WHERE code = 'app-user-region-blocks'
AND sort = 64;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN ('pretty-id:view', 'pretty-id:update', 'pretty-id:generate', 'pretty-id:grant');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'pretty-id:view';

View File

@ -0,0 +1,50 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 三方支付是支付管理下的功能;内购配置也归到支付管理,不再挂在 APP 配置下。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('三方支付查看', 'payment-third-party:view', 'menu', '', @now_ms, @now_ms),
('三方支付更新', 'payment-third-party:update', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '三方支付', 'payment-third-party', '/payment/third-party', 'wallet', 'payment-third-party:view', 69, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'payment'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus child
JOIN admin_menus parent ON parent.code = 'payment'
SET child.parent_id = parent.id,
child.path = '/payment/recharge-products',
child.sort = 70,
child.visible = TRUE,
child.updated_at_ms = @now_ms
WHERE child.code = 'payment-recharge-products';
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN ('payment-third-party:view', 'payment-third-party:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'payment-third-party:view';

View File

@ -0,0 +1,16 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 只修正后台展示文案,权限码、菜单 code 和路由保持不变,避免破坏已分配角色和前端路由映射。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
UPDATE admin_permissions
SET name = '首充礼包查看', updated_at_ms = @now_ms
WHERE code = 'first-recharge-reward:view';
UPDATE admin_permissions
SET name = '首充礼包更新', updated_at_ms = @now_ms
WHERE code = 'first-recharge-reward:update';
UPDATE admin_menus
SET title = '首充礼包', updated_at_ms = @now_ms
WHERE code = 'first-recharge-reward';

View File

@ -463,15 +463,19 @@ CREATE TABLE IF NOT EXISTS first_recharge_reward_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
tier_name VARCHAR(96) NOT NULL COMMENT '档位名称',
min_coin_amount BIGINT NOT NULL COMMENT '最小充值金币数量',
min_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '历史字段:最小充值金币数量',
max_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '最大充值金币数量0 表示无上限',
usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 商品美元美分档位',
google_product_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Google Play 商品 ID',
discount_percent INT NOT NULL DEFAULT 0 COMMENT '展示优惠百分比,不参与结算',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_first_recharge_reward_tier_code (app_code, tier_code),
KEY idx_first_recharge_reward_tier_active (app_code, status, sort_order, min_coin_amount)
UNIQUE KEY uk_first_recharge_reward_google_product (app_code, google_product_id),
KEY idx_first_recharge_reward_tier_active (app_code, status, sort_order, usd_minor_amount)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励档位表';
CREATE TABLE IF NOT EXISTS first_recharge_reward_claims (
@ -484,7 +488,10 @@ CREATE TABLE IF NOT EXISTS first_recharge_reward_claims (
tier_id BIGINT NOT NULL COMMENT '命中奖励档位 ID',
tier_code VARCHAR(64) NOT NULL COMMENT '命中奖励档位编码',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
tier_usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位美元美分',
google_product_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '命中 Google Play 商品 ID',
recharge_coin_amount BIGINT NOT NULL COMMENT '充值金币数量',
recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '本次充值美元美分',
recharge_sequence BIGINT NOT NULL COMMENT '该用户充值序号',
recharge_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '充值来源类型',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
@ -498,7 +505,7 @@ CREATE TABLE IF NOT EXISTS first_recharge_reward_claims (
PRIMARY KEY (app_code, claim_id),
UNIQUE KEY uk_first_recharge_reward_event (app_code, event_id),
UNIQUE KEY uk_first_recharge_reward_transaction (app_code, transaction_id),
UNIQUE KEY uk_first_recharge_reward_user_once (app_code, user_id),
UNIQUE KEY uk_first_recharge_reward_user_tier (app_code, user_id, tier_id),
KEY idx_first_recharge_reward_list (app_code, created_at_ms, claim_id),
KEY idx_first_recharge_reward_status (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励领取表';

View File

@ -19,7 +19,7 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha
if message.EventType != firstrechargedomain.RechargeEventWalletRecorded {
return firstrechargedomain.RechargeEvent{}, false, nil
}
sequence, rechargeType := rechargeFactFromWalletPayload(message.PayloadJSON)
fields := firstRechargeFactFromWalletPayload(message.PayloadJSON)
return firstrechargedomain.RechargeEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
@ -28,8 +28,10 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha
CommandID: message.CommandID,
UserID: message.UserID,
RechargeCoinAmount: message.AvailableDelta,
RechargeSequence: sequence,
RechargeType: rechargeType,
RechargeSequence: fields.sequence,
RechargeType: fields.rechargeType,
RechargeUSDMinor: fields.rechargeUSDMinor,
GoogleProductID: fields.googleProductID,
PayloadJSON: message.PayloadJSON,
OccurredAtMS: message.OccurredAtMS,
}, true, nil
@ -101,25 +103,42 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
}, true, nil
}
func rechargeFactFromWalletPayload(payload string) (int64, string) {
type firstRechargePayloadFields struct {
sequence int64
rechargeType string
rechargeUSDMinor int64
googleProductID string
}
func firstRechargeFactFromWalletPayload(payload string) firstRechargePayloadFields {
var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
return 0, firstrechargedomain.RechargeTypeCoinSeller
decoded = map[string]any{}
}
var sequence int64
switch value := decoded["recharge_sequence"].(type) {
case float64:
sequence = int64(value)
case int64:
sequence = value
case json.Number:
sequence, _ = value.Int64()
rechargeType := firstNonEmptyString(
stringFromDecoded(decoded, "recharge_type"),
stringFromDecoded(decoded, "channel"),
stringFromDecoded(decoded, "provider"),
firstrechargedomain.RechargeTypeCoinSeller,
)
rechargeUSDMinor := firstNonZeroInt64(
int64FromDecoded(decoded, "recharge_usd_minor"),
int64FromDecoded(decoded, "usd_minor_amount"),
int64FromDecoded(decoded, "amount_usd_minor"),
)
if rechargeUSDMinor <= 0 && isCumulativeGoogleRecharge(rechargeType) {
rechargeUSDMinor = int64FromDecoded(decoded, "amount_micro") / 10000
}
rechargeType := firstrechargedomain.RechargeTypeCoinSeller
if value, ok := decoded["recharge_type"].(string); ok && value != "" {
rechargeType = value
return firstRechargePayloadFields{
sequence: int64FromDecoded(decoded, "recharge_sequence"),
rechargeType: rechargeType,
rechargeUSDMinor: rechargeUSDMinor,
googleProductID: firstNonEmptyString(
stringFromDecoded(decoded, "google_product_id"),
stringFromDecoded(decoded, "product_name"),
stringFromDecoded(decoded, "product_code"),
),
}
return sequence, rechargeType
}
type cumulativeRechargePayloadFields struct {

View File

@ -36,10 +36,11 @@ func (s *GRPCUserProfileSource) GetSenderProfile(ctx context.Context, userID int
return broadcastservice.SenderProfile{UserID: userID}, nil
}
return broadcastservice.SenderProfile{
UserID: user.GetUserId(),
Account: user.GetDisplayUserId(),
Nickname: user.GetUsername(),
Avatar: user.GetAvatar(),
RegionCode: user.GetRegionCode(),
UserID: user.GetUserId(),
Account: user.GetDisplayUserId(),
Nickname: user.GetUsername(),
Avatar: user.GetAvatar(),
AvatarFrameURL: "",
RegionCode: user.GetRegionCode(),
}, nil
}

View File

@ -12,7 +12,6 @@ const (
ReasonNotConfigured = "not_configured"
ReasonDisabled = "disabled"
ReasonInvalidConfig = "invalid_config"
ReasonNotFirst = "not_first_recharge"
ReasonTierNotMatched = "tier_not_matched"
ReasonAlreadyGranted = "already_granted"
ReasonPendingReward = "pending_reward"
@ -21,7 +20,7 @@ const (
RechargeTypeCoinSeller = "coin_seller_transfer"
)
// Tier 是首冲奖励的金额档位max_coin_amount=0 表达无上限
// Tier 是首冲奖励的 Google 商品档位;旧金币区间字段只用于兼容老配置
type Tier struct {
TierID int64
TierCode string
@ -33,6 +32,9 @@ type Tier struct {
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
USDMinorAmount int64
GoogleProductID string
DiscountPercent int32
}
// Config 是首冲奖励当前配置;档位快照由 activity-service 持有。
@ -45,7 +47,7 @@ type Config struct {
UpdatedAtMS int64
}
// Claim 是首笔充值触发后的发奖事实app_code + user_id 唯一。
// Claim 是首充档位购买后的发奖事实app_code + user_id + tier_id 唯一。
type Claim struct {
ClaimID string
EventID string
@ -66,6 +68,9 @@ type Claim struct {
GrantedAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
TierUSDMinorAmount int64
GoogleProductID string
RechargeUSDMinor int64
}
// StatusResult 是 App 首冲奖励面板读取的稳定投影。
@ -74,6 +79,7 @@ type StatusResult struct {
Tiers []Tier
Rewarded bool
Claim Claim
Claims []Claim
ServerTimeMS int64
}
@ -88,6 +94,8 @@ type RechargeEvent struct {
RechargeCoinAmount int64
RechargeSequence int64
RechargeType string
RechargeUSDMinor int64
GoogleProductID string
PayloadJSON string
OccurredAtMS int64
}

View File

@ -55,11 +55,12 @@ type SenderProfileSource interface {
// SenderProfile 是播报 IM payload 的发送者展示字段。
// activity-service 不持久化该资料,只在消费事实时组装一次播报消息。
type SenderProfile struct {
UserID int64
Account string
Nickname string
Avatar string
RegionCode string
UserID int64
Account string
Nickname string
Avatar string
AvatarFrameURL string
RegionCode string
}
// Config 保存播报策略和 worker 参数。
@ -322,12 +323,21 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
if gift.GetVisibleRegionId() <= 0 || gift.GetGiftValue() < s.cfg.SuperGiftMinValue {
if gift.GetVisibleRegionId() <= 0 || !s.shouldBroadcastGift(&gift) {
// visible_region_id 来自房间/主播可见区域,不能用客户端当前 IP 或本地时区推断。
return result, nil
}
broadcastEventID := superGiftBroadcastEventID(envelope, &gift)
payloadJSON, err := superGiftPayload(envelope, &gift, broadcastEventID, s.now().UTC().UnixMilli())
// 区域播报需要携带双方展示资料;资料服务不可用时返回错误,避免发出缺头像昵称的误导性大礼消息。
senderProfile, err := s.broadcastUserProfile(ctx, gift.GetSenderUserId())
if err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
receiverProfile, err := s.broadcastUserProfile(ctx, gift.GetTargetUserId())
if err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
payloadJSON, err := superGiftPayload(envelope, &gift, broadcastEventID, s.now().UTC().UnixMilli(), senderProfile, receiverProfile)
if err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
@ -349,6 +359,38 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
}, nil
}
func (s *Service) shouldBroadcastGift(gift *roomeventsv1.RoomGiftSent) bool {
if gift == nil {
return false
}
// 老规则仍按礼物总价值触发;新规则额外允许后台显式标记 global_broadcast 的低价礼物触发区域播报。
if gift.GetGiftValue() >= s.cfg.SuperGiftMinValue {
return true
}
for _, effectType := range gift.GetGiftEffectTypes() {
if strings.TrimSpace(strings.ToLower(effectType)) == "global_broadcast" {
return true
}
}
return false
}
func (s *Service) broadcastUserProfile(ctx context.Context, userID int64) (SenderProfile, error) {
profile := SenderProfile{UserID: userID}
// 没有 user_id 或未注入资料源时保留 user_id 兜底,保证测试和无资料源部署不会阻断原有播报链路。
if userID <= 0 || s.senderProfileSource == nil {
return profile, nil
}
resolved, err := s.senderProfileSource.GetSenderProfile(ctx, userID)
if err != nil {
return SenderProfile{}, err
}
if resolved.UserID <= 0 {
resolved.UserID = userID
}
return resolved, nil
}
func (s *Service) handleRoomPasswordChanged(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
var event roomeventsv1.RoomPasswordChanged
@ -582,22 +624,38 @@ func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roome
return "gift_broadcast:" + envelope.GetEventId()
}
func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64) (string, error) {
func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) {
// payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。
payload := map[string]any{
"event_id": eventID,
"broadcast_type": broadcastdomain.TypeSuperGift,
"scope": broadcastdomain.ScopeRegion,
"app_code": envelope.GetAppCode(),
"region_id": gift.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(),
"sender_user_id": gift.GetSenderUserId(),
"target_user_id": gift.GetTargetUserId(),
"gift_id": gift.GetGiftId(),
"gift_count": gift.GetGiftCount(),
"gift_value": gift.GetGiftValue(),
"sent_at_ms": sentAtMS,
"room_version": envelope.GetRoomVersion(),
"source_event_id": envelope.GetEventId(),
"event_id": eventID,
"broadcast_type": broadcastdomain.TypeSuperGift,
"scope": broadcastdomain.ScopeRegion,
"app_code": envelope.GetAppCode(),
"region_id": gift.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(),
"sender_user_id": gift.GetSenderUserId(),
"sender_display_user_id": sender.Account,
"sender_nickname": sender.Nickname,
"sender_avatar": sender.Avatar,
"sender_avatar_frame_url": sender.AvatarFrameURL,
"target_user_id": gift.GetTargetUserId(),
"receiver_user_id": gift.GetTargetUserId(),
"receiver_display_user_id": receiver.Account,
"receiver_nickname": receiver.Nickname,
"receiver_avatar": receiver.Avatar,
"receiver_avatar_frame_url": receiver.AvatarFrameURL,
"gift_id": gift.GetGiftId(),
"gift_name": gift.GetGiftName(),
"gift_icon_url": gift.GetGiftIconUrl(),
"gift_animation_url": gift.GetGiftAnimationUrl(),
"gift_effect_types": gift.GetGiftEffectTypes(),
"gift_count": gift.GetGiftCount(),
"gift_value": gift.GetGiftValue(),
"sent_at_ms": sentAtMS,
"room_version": envelope.GetRoomVersion(),
"source_event_id": envelope.GetEventId(),
"sender": broadcastProfilePayload(sender),
"receiver": broadcastProfilePayload(receiver),
"action": map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
@ -607,6 +665,17 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R
return string(encoded), err
}
func broadcastProfilePayload(profile SenderProfile) map[string]any {
// 嵌套资料对象只放展示字段,不放钱包或权限数据,避免 IM 广播泄露服务端内部状态。
return map[string]any{
"user_id": profile.UserID,
"display_user_id": profile.Account,
"nickname": profile.Nickname,
"avatar": profile.Avatar,
"avatar_frame_url": profile.AvatarFrameURL,
}
}
func roomRocketBroadcastEventID(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited) string {
if strings.TrimSpace(rocket.GetRocketId()) != "" {
return fmt.Sprintf("room_rocket_broadcast:%s:%s", envelope.GetRoomId(), rocket.GetRocketId())

View File

@ -18,6 +18,10 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) })
service.SetSenderProfileSource(fakeSenderProfileSource{profiles: map[int64]SenderProfile{
42: {UserID: 42, Account: "160042", Nickname: "Sender", Avatar: "https://cdn.example/sender.png", AvatarFrameURL: "https://cdn.example/sender-frame.png"},
43: {UserID: 43, Account: "160043", Nickname: "Receiver", Avatar: "https://cdn.example/receiver.png", AvatarFrameURL: "https://cdn.example/receiver-frame.png"},
}})
envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
SenderUserId: 42,
TargetUserId: 43,
@ -26,6 +30,9 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
GiftValue: 999,
VisibleRegionId: 1001,
CommandId: "cmd-send-gift-1",
GiftName: "Rocket",
GiftIconUrl: "https://cdn.example/rocket.png",
GiftEffectTypes: []string{"animation", "global_broadcast"},
})
result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
@ -43,9 +50,14 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("payload is not json: %v", err)
}
if payload["room_id"] != "room-1001" || payload["gift_id"] != "rocket" || payload["scope"] != "region" {
if payload["room_id"] != "room-1001" || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" {
t.Fatalf("payload mismatch: %+v", payload)
}
sender, _ := payload["sender"].(map[string]any)
receiver, _ := payload["receiver"].(map[string]any)
if sender["nickname"] != "Sender" || sender["avatar_frame_url"] != "https://cdn.example/sender-frame.png" || receiver["nickname"] != "Receiver" {
t.Fatalf("payload user profile mismatch: sender=%+v receiver=%+v", sender, receiver)
}
again, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
if err != nil {
@ -56,6 +68,33 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
}
}
func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a", SuperGiftMinValue: 1000}, repository, nil, nil)
envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
SenderUserId: 42,
TargetUserId: 43,
GiftId: "tagged-gift",
GiftCount: 1,
GiftValue: 10,
VisibleRegionId: 1001,
CommandId: "cmd-tagged-gift",
GiftEffectTypes: []string{"animation", "global_broadcast"},
})
result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
if err != nil {
t.Fatalf("HandleRoomEvent tagged gift failed: %v", err)
}
if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated {
t.Fatalf("tagged gift should create broadcast: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
t.Fatalf("unexpected tagged gift broadcast record: %+v", record)
}
}
func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
@ -461,10 +500,20 @@ func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, use
}
type fakeSenderProfileSource struct {
profile SenderProfile
err error
profile SenderProfile
profiles map[int64]SenderProfile
err error
}
func (s fakeSenderProfileSource) GetSenderProfile(context.Context, int64) (SenderProfile, error) {
return s.profile, s.err
func (s fakeSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (SenderProfile, error) {
if s.profiles != nil {
if profile, ok := s.profiles[userID]; ok {
return profile, s.err
}
}
profile := s.profile
if profile.UserID <= 0 {
profile.UserID = userID
}
return profile, s.err
}

View File

@ -22,7 +22,7 @@ const (
type Repository interface {
GetFirstRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error)
UpdateFirstRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
GetFirstRechargeRewardClaimByUser(ctx context.Context, userID int64) (domain.Claim, bool, error)
ListFirstRechargeRewardClaimsByUser(ctx context.Context, userID int64) ([]domain.Claim, error)
PrepareFirstRechargeRewardClaim(ctx context.Context, event domain.RechargeEvent, nowMS int64) (domain.PrepareResult, error)
MarkFirstRechargeRewardClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error)
MarkFirstRechargeRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
@ -51,7 +51,7 @@ func (s *Service) SetClock(now func() time.Time) {
}
}
// GetStatus 返回 App 可展示的当前配置和用户发放状态;未配置时不是错误。
// GetStatus 返回 App 可展示的当前配置和用户各档位发放状态;未配置时不是错误。
func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.StatusResult, error) {
if err := s.requireRepository(); err != nil {
return domain.StatusResult{}, err
@ -65,18 +65,22 @@ func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.StatusRes
if err != nil {
return domain.StatusResult{}, err
}
active := []domain.Tier{}
if exists {
result.Enabled = config.Enabled
result.Tiers = activeTiers(config.Tiers)
active = activeTiers(config.Tiers)
}
claim, claimed, err := s.repository.GetFirstRechargeRewardClaimByUser(ctx, userID)
claims, err := s.repository.ListFirstRechargeRewardClaimsByUser(ctx, userID)
if err != nil {
return domain.StatusResult{}, err
}
if claimed {
result.Claim = claim
result.Rewarded = claim.Status == domain.StatusGranted
result.Claims = claims
if len(claims) > 0 {
result.Claim = claims[0]
}
claimedTierIDs := grantedFirstRechargeTierIDs(claims)
result.Tiers = remainingFirstRechargeTiers(active, claimedTierIDs)
result.Rewarded = len(active) > 0 && len(result.Tiers) == 0
return result, nil
}
@ -106,7 +110,7 @@ func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domai
return s.repository.UpdateFirstRechargeRewardConfig(ctx, config, s.now().UnixMilli())
}
// Consume 处理 wallet 成功充值事实;只有 recharge_sequence=1 且命中启用档位才发奖。
// Consume 处理 wallet 成功充值事实;只有 Google 商品 ID 命中启用档位才发奖。
func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) (domain.Claim, bool, string, error) {
if err := s.requireRepository(); err != nil {
return domain.Claim{}, false, "", err
@ -115,9 +119,6 @@ func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) (doma
if err := validateRechargeEvent(event); err != nil {
return domain.Claim{}, false, "", err
}
if event.RechargeSequence != 1 {
return domain.Claim{}, false, domain.ReasonNotFirst, nil
}
prepared, err := s.repository.PrepareFirstRechargeRewardClaim(ctx, event, s.now().UnixMilli())
if err != nil {
return domain.Claim{}, false, "", err
@ -209,11 +210,14 @@ func normalizeTiers(tiers []domain.Tier) []domain.Tier {
ResourceGroupID: tier.ResourceGroupID,
Status: status,
SortOrder: tier.SortOrder,
USDMinorAmount: tier.USDMinorAmount,
GoogleProductID: strings.TrimSpace(tier.GoogleProductID),
DiscountPercent: tier.DiscountPercent,
})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].SortOrder == items[j].SortOrder {
return items[i].MinCoinAmount < items[j].MinCoinAmount
return items[i].USDMinorAmount < items[j].USDMinorAmount
}
return items[i].SortOrder < items[j].SortOrder
})
@ -225,6 +229,7 @@ func validateConfig(config domain.Config) error {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
seen := make(map[string]struct{}, len(config.Tiers))
seenGoogleProducts := make(map[string]struct{}, len(config.Tiers))
active := make([]domain.Tier, 0, len(config.Tiers))
for _, tier := range config.Tiers {
if tier.TierCode == "" {
@ -237,11 +242,19 @@ func validateConfig(config domain.Config) error {
if tier.TierName == "" {
return xerr.New(xerr.InvalidArgument, "tier_name is required")
}
if tier.MinCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "min_coin_amount must be greater than zero")
if tier.USDMinorAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "usd_minor_amount must be greater than zero")
}
if tier.MaxCoinAmount > 0 && tier.MaxCoinAmount < tier.MinCoinAmount {
return xerr.New(xerr.InvalidArgument, "max_coin_amount must be greater than min_coin_amount")
googleProductID := strings.ToLower(strings.TrimSpace(tier.GoogleProductID))
if googleProductID == "" {
return xerr.New(xerr.InvalidArgument, "google_product_id is required")
}
if _, exists := seenGoogleProducts[googleProductID]; exists {
return xerr.New(xerr.InvalidArgument, "google_product_id is duplicated")
}
seenGoogleProducts[googleProductID] = struct{}{}
if tier.DiscountPercent < 0 || tier.DiscountPercent > 100 {
return xerr.New(xerr.InvalidArgument, "discount_percent must be between 0 and 100")
}
if tier.ResourceGroupID <= 0 {
return xerr.New(xerr.InvalidArgument, "resource_group_id is required")
@ -257,24 +270,9 @@ func validateConfig(config domain.Config) error {
if config.Enabled && len(active) == 0 {
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
}
if hasOverlappedTiers(active) {
return xerr.New(xerr.InvalidArgument, "active tiers overlap")
}
return nil
}
func hasOverlappedTiers(tiers []domain.Tier) bool {
sort.SliceStable(tiers, func(i, j int) bool { return tiers[i].MinCoinAmount < tiers[j].MinCoinAmount })
var previousMax int64
for i, tier := range tiers {
if i > 0 && (previousMax == 0 || tier.MinCoinAmount <= previousMax) {
return true
}
previousMax = tier.MaxCoinAmount
}
return false
}
func activeTiers(tiers []domain.Tier) []domain.Tier {
items := make([]domain.Tier, 0, len(tiers))
for _, tier := range tiers {
@ -285,6 +283,27 @@ func activeTiers(tiers []domain.Tier) []domain.Tier {
return items
}
func grantedFirstRechargeTierIDs(claims []domain.Claim) map[int64]struct{} {
ids := make(map[int64]struct{}, len(claims))
for _, claim := range claims {
if claim.TierID > 0 && claim.Status == domain.StatusGranted {
ids[claim.TierID] = struct{}{}
}
}
return ids
}
func remainingFirstRechargeTiers(tiers []domain.Tier, granted map[int64]struct{}) []domain.Tier {
items := make([]domain.Tier, 0, len(tiers))
for _, tier := range tiers {
if _, ok := granted[tier.TierID]; ok {
continue
}
items = append(items, tier)
}
return items
}
func normalizeRechargeEvent(event domain.RechargeEvent) domain.RechargeEvent {
event.AppCode = appcode.Normalize(event.AppCode)
event.EventID = strings.TrimSpace(event.EventID)
@ -292,6 +311,7 @@ func normalizeRechargeEvent(event domain.RechargeEvent) domain.RechargeEvent {
event.TransactionID = strings.TrimSpace(event.TransactionID)
event.CommandID = strings.TrimSpace(event.CommandID)
event.RechargeType = strings.TrimSpace(event.RechargeType)
event.GoogleProductID = strings.TrimSpace(event.GoogleProductID)
if event.RechargeType == "" {
event.RechargeType = domain.RechargeTypeCoinSeller
}
@ -308,6 +328,9 @@ func validateRechargeEvent(event domain.RechargeEvent) error {
if event.RechargeCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must be greater than zero")
}
if event.GoogleProductID == "" {
return xerr.New(xerr.InvalidArgument, "google_product_id is required")
}
if event.OccurredAtMS <= 0 {
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
}

View File

@ -21,18 +21,29 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
Enabled: true,
UpdatedByAdminID: 90001,
Tiers: []domain.Tier{{
TierCode: "first_100",
TierName: "首冲 100+",
MinCoinAmount: 100,
MaxCoinAmount: 0,
TierCode: "first_099",
TierName: "首冲 $0.99",
USDMinorAmount: 99,
GoogleProductID: "first_recharge_google_099",
DiscountPercent: 70,
ResourceGroupID: 88001,
Status: domain.TierStatusActive,
SortOrder: 1,
}, {
TierCode: "first_199",
TierName: "首冲 $1.99",
USDMinorAmount: 199,
GoogleProductID: "first_recharge_google_199",
DiscountPercent: 60,
ResourceGroupID: 88002,
Status: domain.TierStatusActive,
SortOrder: 2,
}},
})
if err != nil {
t.Fatalf("UpdateConfig failed: %v", err)
}
if len(config.Tiers) != 1 || config.Tiers[0].TierID <= 0 {
if len(config.Tiers) != 2 || config.Tiers[0].TierID <= 0 || config.Tiers[1].TierID <= 0 {
t.Fatalf("tier id was not generated: %+v", config.Tiers)
}
@ -44,8 +55,10 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
CommandID: "seller-transfer-1",
UserID: 42001,
RechargeCoinAmount: 500,
RechargeSequence: 1,
RechargeType: domain.RechargeTypeCoinSeller,
RechargeSequence: 2,
RechargeType: "google",
RechargeUSDMinor: 99,
GoogleProductID: "first_recharge_google_099",
OccurredAtMS: 1710000000000,
}
err = svc.ConsumeWalletRechargeEvent(ctx, event)
@ -56,7 +69,7 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
if err != nil {
t.Fatalf("GetFirstRechargeRewardClaimByUser failed: %v", err)
}
if !exists || claim.Status != domain.StatusGranted || claim.ResourceGroupID != 88001 {
if !exists || claim.Status != domain.StatusGranted || claim.ResourceGroupID != 88001 || claim.GoogleProductID != "first_recharge_google_099" {
t.Fatalf("consume result mismatch: exists=%v claim=%+v", exists, claim)
}
if len(wallet.grants) != 1 || wallet.grants[0].GroupId != 88001 || wallet.grants[0].TargetUserId != 42001 {
@ -67,8 +80,8 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
if err != nil {
t.Fatalf("GetStatus failed: %v", err)
}
if !status.Rewarded || status.Claim.ClaimID != claim.ClaimID {
t.Fatalf("status should include granted claim: %+v", status)
if status.Rewarded || len(status.Tiers) != 1 || status.Tiers[0].GoogleProductID != "first_recharge_google_199" || len(status.Claims) != 1 {
t.Fatalf("status should keep the unpurchased tier visible: %+v", status)
}
err = svc.ConsumeWalletRechargeEvent(ctx, event)
@ -82,13 +95,21 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
event.EventID = "evt-recharge-2"
event.TransactionID = "wtx-recharge-2"
event.CommandID = "seller-transfer-2"
event.RechargeSequence = 2
event.RechargeUSDMinor = 199
event.GoogleProductID = "first_recharge_google_199"
err = svc.ConsumeWalletRechargeEvent(ctx, event)
if err != nil {
t.Fatalf("second recharge ConsumeWalletRechargeEvent failed: %v", err)
}
if len(wallet.grants) != 1 {
t.Fatalf("second recharge should be ignored, grants=%d", len(wallet.grants))
if len(wallet.grants) != 2 || wallet.grants[1].GroupId != 88002 {
t.Fatalf("second tier should be granted, grants=%+v", wallet.grants)
}
status, err = svc.GetStatus(ctx, 42001)
if err != nil {
t.Fatalf("GetStatus after all tiers failed: %v", err)
}
if !status.Rewarded || len(status.Tiers) != 0 || len(status.Claims) != 2 {
t.Fatalf("status should complete after all tiers are purchased: %+v", status)
}
}

View File

@ -76,10 +76,12 @@ func (r *Repository) UpdateFirstRechargeRewardConfig(ctx context.Context, config
if _, err := tx.ExecContext(ctx, `
INSERT INTO first_recharge_reward_tiers (
tier_id, app_code, tier_code, tier_name, min_coin_amount, max_coin_amount,
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
resource_group_id, status, sort_order, created_at_ms, updated_at_ms,
usd_minor_amount, google_product_id, discount_percent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tier.TierID, appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.MinCoinAmount,
tier.MaxCoinAmount, tier.ResourceGroupID, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
tier.USDMinorAmount, tier.GoogleProductID, tier.DiscountPercent,
); err != nil {
return domain.Config{}, err
}
@ -88,10 +90,12 @@ func (r *Repository) UpdateFirstRechargeRewardConfig(ctx context.Context, config
result, err := tx.ExecContext(ctx, `
INSERT INTO first_recharge_reward_tiers (
app_code, tier_code, tier_name, min_coin_amount, max_coin_amount,
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
resource_group_id, status, sort_order, created_at_ms, updated_at_ms,
usd_minor_amount, google_product_id, discount_percent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.MinCoinAmount, tier.MaxCoinAmount,
tier.ResourceGroupID, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
tier.USDMinorAmount, tier.GoogleProductID, tier.DiscountPercent,
)
if err != nil {
return domain.Config{}, err
@ -115,13 +119,33 @@ func (r *Repository) UpdateFirstRechargeRewardConfig(ctx context.Context, config
return updated, nil
}
// GetFirstRechargeRewardClaimByUser 返回 App 查询需要的用户首冲奖励事实。
// ListFirstRechargeRewardClaimsByUser 返回 App 查询需要的用户各档位首冲奖励事实。
func (r *Repository) ListFirstRechargeRewardClaimsByUser(ctx context.Context, userID int64) ([]domain.Claim, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
rows, err := r.db.QueryContext(ctx, firstRechargeRewardClaimSelectSQL()+`
WHERE app_code = ? AND user_id = ?
ORDER BY created_at_ms DESC, claim_id DESC`,
appcode.FromContext(ctx), userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
claims, _, err := scanFirstRechargeRewardClaims(rows, 0)
return claims, err
}
// GetFirstRechargeRewardClaimByUser 保留给旧测试和内部兼容;返回最近一条首充奖励事实。
func (r *Repository) GetFirstRechargeRewardClaimByUser(ctx context.Context, userID int64) (domain.Claim, bool, error) {
if r == nil || r.db == nil {
return domain.Claim{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
row := r.db.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
WHERE app_code = ? AND user_id = ?`,
WHERE app_code = ? AND user_id = ?
ORDER BY created_at_ms DESC, claim_id DESC
LIMIT 1`,
appcode.FromContext(ctx), userID,
)
claim, err := scanFirstRechargeRewardClaim(row)
@ -142,9 +166,6 @@ func (r *Repository) PrepareFirstRechargeRewardClaim(ctx context.Context, event
}
defer func() { _ = tx.Rollback() }()
if event.RechargeSequence != 1 {
return domain.PrepareResult{Reason: domain.ReasonNotFirst}, tx.Commit()
}
config, exists, err := r.getFirstRechargeRewardConfigForUpdate(ctx, tx)
if err != nil {
return domain.PrepareResult{}, err
@ -159,7 +180,7 @@ func (r *Repository) PrepareFirstRechargeRewardClaim(ctx context.Context, event
if err != nil {
return domain.PrepareResult{}, err
}
tier, matched := matchFirstRechargeRewardTier(tiers, event.RechargeCoinAmount)
tier, matched := matchFirstRechargeRewardTier(tiers, event.GoogleProductID)
if !matched {
return domain.PrepareResult{Reason: domain.ReasonTierNotMatched}, tx.Commit()
}
@ -172,7 +193,7 @@ func (r *Repository) PrepareFirstRechargeRewardClaim(ctx context.Context, event
if err != nil || exists {
return r.prepareExistingFirstRechargeRewardClaim(ctx, tx, existing, exists, err, event, nowMS)
}
existing, exists, err = r.getFirstRechargeRewardClaimByUserForUpdate(ctx, tx, event.UserID)
existing, exists, err = r.getFirstRechargeRewardClaimByUserTierForUpdate(ctx, tx, event.UserID, tier.TierID)
if err != nil || exists {
return r.prepareExistingFirstRechargeRewardClaim(ctx, tx, existing, exists, err, event, nowMS)
}
@ -193,6 +214,9 @@ func (r *Repository) PrepareFirstRechargeRewardClaim(ctx context.Context, event
RechargedAtMS: event.OccurredAtMS,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
TierUSDMinorAmount: tier.USDMinorAmount,
GoogleProductID: tier.GoogleProductID,
RechargeUSDMinor: event.RechargeUSDMinor,
}
claim.WalletCommandID = walletFirstRechargeRewardCommandID(claim.ClaimID)
if err := r.insertFirstRechargeRewardClaim(ctx, tx, claim); err != nil {
@ -214,6 +238,9 @@ func (r *Repository) prepareExistingFirstRechargeRewardClaim(ctx context.Context
if claim.UserID != event.UserID {
return domain.PrepareResult{}, xerr.New(xerr.RequestConflict, "first recharge reward event payload conflicts")
}
if claim.GoogleProductID != "" && event.GoogleProductID != "" && !strings.EqualFold(claim.GoogleProductID, event.GoogleProductID) {
return domain.PrepareResult{}, xerr.New(xerr.RequestConflict, "first recharge reward product payload conflicts")
}
if claim.Status == domain.StatusGranted {
if err := tx.Commit(); err != nil {
return domain.PrepareResult{}, err
@ -322,13 +349,13 @@ func (r *Repository) listFirstRechargeRewardTiers(ctx context.Context, tx *sql.T
if tx != nil {
rows, err = tx.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, min_coin_amount ASC, tier_id ASC`,
ORDER BY sort_order ASC, usd_minor_amount ASC, tier_id ASC`,
appcode.FromContext(ctx),
)
} else {
rows, err = r.db.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, min_coin_amount ASC, tier_id ASC`,
ORDER BY sort_order ASC, usd_minor_amount ASC, tier_id ASC`,
appcode.FromContext(ctx),
)
}
@ -342,7 +369,7 @@ func (r *Repository) listFirstRechargeRewardTiers(ctx context.Context, tx *sql.T
func (r *Repository) listFirstRechargeRewardTiersForUpdate(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
rows, err := tx.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, min_coin_amount ASC, tier_id ASC
ORDER BY sort_order ASC, usd_minor_amount ASC, tier_id ASC
FOR UPDATE`,
appcode.FromContext(ctx),
)
@ -379,11 +406,11 @@ func (r *Repository) getFirstRechargeRewardClaimByTransactionForUpdate(ctx conte
return claim, err == nil, err
}
func (r *Repository) getFirstRechargeRewardClaimByUserForUpdate(ctx context.Context, tx *sql.Tx, userID int64) (domain.Claim, bool, error) {
func (r *Repository) getFirstRechargeRewardClaimByUserTierForUpdate(ctx context.Context, tx *sql.Tx, userID int64, tierID int64) (domain.Claim, bool, error) {
row := tx.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
WHERE app_code = ? AND user_id = ?
WHERE app_code = ? AND user_id = ? AND tier_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), userID,
appcode.FromContext(ctx), userID, tierID,
)
claim, err := scanFirstRechargeRewardClaim(row)
if errors.Is(err, sql.ErrNoRows) {
@ -410,11 +437,13 @@ func (r *Repository) insertFirstRechargeRewardClaim(ctx context.Context, tx *sql
app_code, claim_id, event_id, transaction_id, command_id, user_id,
tier_id, tier_code, resource_group_id, recharge_coin_amount, recharge_sequence,
recharge_type, status, wallet_command_id, wallet_grant_id, failure_reason,
recharged_at_ms, granted_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', ?, 0, ?, ?)`,
recharged_at_ms, granted_at_ms, created_at_ms, updated_at_ms,
tier_usd_minor_amount, google_product_id, recharge_usd_minor
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', ?, 0, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), claim.ClaimID, claim.EventID, claim.TransactionID, claim.CommandID, claim.UserID,
claim.TierID, claim.TierCode, claim.ResourceGroupID, claim.RechargeCoinAmount, claim.RechargeSequence,
claim.RechargeType, claim.Status, claim.WalletCommandID, claim.RechargedAtMS, claim.CreatedAtMS, claim.UpdatedAtMS,
claim.TierUSDMinorAmount, claim.GoogleProductID, claim.RechargeUSDMinor,
)
return err
}
@ -454,6 +483,9 @@ func scanFirstRechargeRewardTiers(rows *sql.Rows) ([]domain.Tier, error) {
&item.SortOrder,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&item.USDMinorAmount,
&item.GoogleProductID,
&item.DiscountPercent,
); err != nil {
return nil, err
}
@ -499,6 +531,9 @@ func scanFirstRechargeRewardClaim(row rowScanner) (domain.Claim, error) {
&claim.GrantedAtMS,
&claim.CreatedAtMS,
&claim.UpdatedAtMS,
&claim.TierUSDMinorAmount,
&claim.GoogleProductID,
&claim.RechargeUSDMinor,
)
return claim, err
}
@ -510,7 +545,8 @@ func firstRechargeRewardConfigSelectSQL() string {
func firstRechargeRewardTierSelectSQL() string {
return `SELECT tier_id, tier_code, tier_name, min_coin_amount, max_coin_amount,
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
resource_group_id, status, sort_order, created_at_ms, updated_at_ms,
usd_minor_amount, google_product_id, discount_percent
FROM first_recharge_reward_tiers `
}
@ -518,7 +554,8 @@ func firstRechargeRewardClaimSelectSQL() string {
return `SELECT claim_id, event_id, transaction_id, command_id, user_id, tier_id, tier_code,
resource_group_id, recharge_coin_amount, recharge_sequence, recharge_type,
status, wallet_command_id, wallet_grant_id, failure_reason, recharged_at_ms,
granted_at_ms, created_at_ms, updated_at_ms
granted_at_ms, created_at_ms, updated_at_ms,
tier_usd_minor_amount, google_product_id, recharge_usd_minor
FROM first_recharge_reward_claims `
}
@ -536,18 +573,18 @@ func firstRechargeRewardClaimWhere(ctx context.Context, query domain.ClaimQuery)
return "WHERE " + strings.Join(conditions, " AND "), args
}
func matchFirstRechargeRewardTier(tiers []domain.Tier, coinAmount int64) (domain.Tier, bool) {
func matchFirstRechargeRewardTier(tiers []domain.Tier, googleProductID string) (domain.Tier, bool) {
normalizedGoogleProductID := strings.ToLower(strings.TrimSpace(googleProductID))
if normalizedGoogleProductID == "" {
return domain.Tier{}, false
}
for _, tier := range tiers {
if tier.Status != domain.TierStatusActive || tier.ResourceGroupID <= 0 || tier.MinCoinAmount <= 0 {
if tier.Status != domain.TierStatusActive || tier.ResourceGroupID <= 0 {
continue
}
if coinAmount < tier.MinCoinAmount {
continue
if strings.ToLower(strings.TrimSpace(tier.GoogleProductID)) == normalizedGoogleProductID {
return tier, true
}
if tier.MaxCoinAmount > 0 && coinAmount > tier.MaxCoinAmount {
continue
}
return tier, true
}
return domain.Tier{}, false
}

View File

@ -41,6 +41,8 @@ func (s *FirstRechargeRewardServer) ConsumeFirstRechargeReward(ctx context.Conte
RechargeCoinAmount: req.GetRechargeCoinAmount(),
RechargeSequence: req.GetRechargeSequence(),
RechargeType: req.GetRechargeType(),
RechargeUSDMinor: req.GetRechargeUsdMinor(),
GoogleProductID: req.GetGoogleProductId(),
OccurredAtMS: req.GetOccurredAtMs(),
})
if err != nil {
@ -86,6 +88,9 @@ func (s *AdminFirstRechargeRewardServer) UpdateFirstRechargeRewardConfig(ctx con
ResourceGroupID: tier.GetResourceGroupId(),
Status: tier.GetStatus(),
SortOrder: tier.GetSortOrder(),
USDMinorAmount: tier.GetUsdMinorAmount(),
GoogleProductID: tier.GetGoogleProductId(),
DiscountPercent: tier.GetDiscountPercent(),
})
}
config, err := s.svc.UpdateConfig(ctx, domain.Config{
@ -123,11 +128,15 @@ func firstRechargeRewardStatusToProto(status domain.StatusResult) *activityv1.Fi
Tiers: make([]*activityv1.FirstRechargeRewardTier, 0, len(status.Tiers)),
Rewarded: status.Rewarded,
Claim: firstRechargeRewardClaimToProto(status.Claim),
Claims: make([]*activityv1.FirstRechargeRewardClaim, 0, len(status.Claims)),
ServerTimeMs: status.ServerTimeMS,
}
for _, tier := range status.Tiers {
resp.Tiers = append(resp.Tiers, firstRechargeRewardTierToProto(tier))
}
for _, claim := range status.Claims {
resp.Claims = append(resp.Claims, firstRechargeRewardClaimToProto(claim))
}
return resp
}
@ -158,6 +167,9 @@ func firstRechargeRewardTierToProto(tier domain.Tier) *activityv1.FirstRechargeR
SortOrder: tier.SortOrder,
CreatedAtMs: tier.CreatedAtMS,
UpdatedAtMs: tier.UpdatedAtMS,
UsdMinorAmount: tier.USDMinorAmount,
GoogleProductId: tier.GoogleProductID,
DiscountPercent: tier.DiscountPercent,
}
}
@ -185,5 +197,8 @@ func firstRechargeRewardClaimToProto(claim domain.Claim) *activityv1.FirstRechar
GrantedAtMs: claim.GrantedAtMS,
CreatedAtMs: claim.CreatedAtMS,
UpdatedAtMs: claim.UpdatedAtMS,
TierUsdMinorAmount: claim.TierUSDMinorAmount,
GoogleProductId: claim.GoogleProductID,
RechargeUsdMinor: claim.RechargeUSDMinor,
}
}

View File

@ -112,6 +112,223 @@ CREATE TABLE IF NOT EXISTS game_orders (
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏钱包订单表';
CREATE TABLE IF NOT EXISTS game_dice_matches (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
match_id VARCHAR(96) NOT NULL COMMENT '骰子局 ID',
game_id VARCHAR(96) NOT NULL COMMENT '内部游戏 ID',
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码快照',
provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID 快照',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户区域 ID',
min_players INT NOT NULL COMMENT '最少开局人数',
max_players INT NOT NULL COMMENT '最多参与人数',
current_players INT NOT NULL DEFAULT 0 COMMENT '当前参与人数',
stake_coin BIGINT NOT NULL COMMENT '单人下注金币',
round_no INT NOT NULL DEFAULT 1 COMMENT '轮次',
status VARCHAR(32) NOT NULL COMMENT '局状态',
result VARCHAR(64) NOT NULL DEFAULT '' COMMENT '局结果',
join_deadline_ms BIGINT NOT NULL DEFAULT 0 COMMENT '加入截止时间UTC epoch ms',
ready_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '匹配完成时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算时间UTC epoch ms',
canceled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '取消时间UTC epoch ms',
fee_bps INT NOT NULL DEFAULT 500 COMMENT '本局平台抽水万分比快照',
pool_bps INT NOT NULL DEFAULT 100 COMMENT '本局奖池入池万分比快照',
match_mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT '匹配模式 human/robot',
forced_result VARCHAR(32) NOT NULL DEFAULT '' COMMENT '机器人奖池不足时的强制结果',
pool_delta_coin BIGINT NOT NULL DEFAULT 0 COMMENT '本局对奖池的净变化',
PRIMARY KEY(app_code, match_id),
KEY idx_game_dice_room_active(app_code, room_id, status, created_at_ms),
KEY idx_game_dice_status_time(app_code, status, updated_at_ms, match_id),
KEY idx_game_dice_waiting(app_code, game_id, stake_coin, status, created_at_ms, match_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='骰子游戏局表';
CREATE TABLE IF NOT EXISTS game_dice_participants (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
match_id VARCHAR(96) NOT NULL COMMENT '骰子局 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
participant_type VARCHAR(32) NOT NULL DEFAULT 'user' COMMENT '参与者类型 user/robot',
seat_no INT NOT NULL COMMENT '座位号',
status VARCHAR(32) NOT NULL COMMENT '参与状态',
stake_coin BIGINT NOT NULL COMMENT '下注金币',
dice_points_json JSON NULL COMMENT '骰子点数',
result VARCHAR(32) NOT NULL DEFAULT '' COMMENT '参与结果',
payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '返奖金币',
debit_order_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '下注订单 ID',
payout_order_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '派奖订单 ID',
refund_order_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '退款订单 ID',
balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '最近一次钱包账后余额',
joined_at_ms BIGINT NOT NULL COMMENT '加入时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, match_id, user_id),
UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no),
KEY idx_game_dice_participant_user(app_code, user_id, updated_at_ms),
KEY idx_game_dice_participant_status(app_code, match_id, status, seat_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='骰子游戏参与者表';
-- 骰子旧库已经存在 match/participant 表时CREATE TABLE 不会补列;这里把 H5 实时流程依赖的阶段、费率、机器人和奖池快照列幂等补齐。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'ready_at_ms') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN ready_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''匹配完成时间UTC epoch ms'' AFTER join_deadline_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'canceled_at_ms') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN canceled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''取消时间UTC epoch ms'' AFTER settled_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'fee_bps') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN fee_bps INT NOT NULL DEFAULT 500 COMMENT ''本局平台抽水万分比快照'' AFTER canceled_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'pool_bps') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN pool_bps INT NOT NULL DEFAULT 100 COMMENT ''本局奖池入池万分比快照'' AFTER fee_bps',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'match_mode') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN match_mode VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''匹配模式 human/robot'' AFTER pool_bps',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'forced_result') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN forced_result VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''机器人奖池不足时的强制结果'' AFTER match_mode',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'pool_delta_coin') = 0,
'ALTER TABLE game_dice_matches ADD COLUMN pool_delta_coin BIGINT NOT NULL DEFAULT 0 COMMENT ''本局对奖池的净变化'' AFTER forced_result',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 参与者类型是机器人扩展的事实字段;旧局默认真人,避免历史数据阻塞查询和结算。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_participants' AND COLUMN_NAME = 'participant_type') = 0,
'ALTER TABLE game_dice_participants ADD COLUMN participant_type VARCHAR(32) NOT NULL DEFAULT ''user'' COMMENT ''参与者类型 user/robot'' AFTER user_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 全服等待队列按 app/game/stake/status 命中;旧库没有这个索引时,高并发匹配会退化成扫描历史局。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND INDEX_NAME = 'idx_game_dice_waiting') = 0,
'ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_waiting (app_code, game_id, stake_coin, status, created_at_ms, match_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_participants' AND INDEX_NAME = 'idx_game_dice_participant_status') = 0,
'ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_status (app_code, match_id, status, seat_no)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS game_self_game_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '配置状态',
stake_options_json JSON NULL COMMENT '可下注金币档位',
fee_bps INT NOT NULL DEFAULT 500 COMMENT '平台抽水万分比',
pool_bps INT NOT NULL DEFAULT 100 COMMENT '奖池入池万分比',
min_players INT NOT NULL DEFAULT 2 COMMENT '最少开局人数',
max_players INT NOT NULL DEFAULT 2 COMMENT '最多参与人数',
robot_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用机器人补位',
robot_match_wait_ms BIGINT NOT NULL DEFAULT 3000 COMMENT '等待真人后机器人补位毫秒数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏配置表';
CREATE TABLE IF NOT EXISTS game_self_game_pools (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID',
balance_coin BIGINT NOT NULL DEFAULT 0 COMMENT '机器人共享奖池余额',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人奖池表';
-- 默认骰子配置只在新环境插入一次;后台后续修改必须保留,不用初始化脚本覆盖线上运营参数。
SET @now_ms := CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO game_self_game_configs (
app_code, game_id, status, stake_options_json, fee_bps, pool_bps,
min_players, max_players, robot_enabled, robot_match_wait_ms, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'dice', 'active',
CAST('[{"stake_coin":100,"enabled":true,"sort_order":10},{"stake_coin":500,"enabled":true,"sort_order":20},{"stake_coin":1000,"enabled":true,"sort_order":30},{"stake_coin":8000,"enabled":true,"sort_order":40}]' AS JSON),
500, 100, 2, 2, 1, 3000, @now_ms, @now_ms
) ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
INSERT INTO game_self_game_pools (app_code, game_id, balance_coin, created_at_ms, updated_at_ms)
VALUES ('lalu', 'dice', 0, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms;
CREATE TABLE IF NOT EXISTS game_self_game_pool_transactions (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
adjustment_id VARCHAR(96) NOT NULL COMMENT '奖池流水 ID',
game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID',
match_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '关联骰子局 ID',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '关联真实用户 ID',
direction VARCHAR(16) NOT NULL COMMENT '方向 in/out',
amount_coin BIGINT NOT NULL COMMENT '变化金币',
reason VARCHAR(128) NOT NULL DEFAULT '' COMMENT '变化原因',
balance_after BIGINT NOT NULL COMMENT '变化后奖池余额',
created_by BIGINT NOT NULL DEFAULT 0 COMMENT '后台操作者或系统用户',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY(app_code, adjustment_id),
KEY idx_game_pool_tx_game_time(app_code, game_id, created_at_ms),
KEY idx_game_pool_tx_match(app_code, match_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏奖池流水表';
CREATE TABLE IF NOT EXISTS game_self_game_robots (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID',
user_id BIGINT NOT NULL COMMENT '真实 App 机器人用户 ID',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '机器人状态',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, game_id, user_id),
KEY idx_game_robot_status(app_code, game_id, status, updated_at_ms, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人登记表';
CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
callback_id VARCHAR(96) NOT NULL COMMENT '回调 ID',

View File

@ -20,6 +20,7 @@ import (
"hyapp/pkg/rocketmqx"
"hyapp/services/game-service/internal/client"
"hyapp/services/game-service/internal/config"
diceservice "hyapp/services/game-service/internal/service/dice"
gameservice "hyapp/services/game-service/internal/service/game"
mysqlstorage "hyapp/services/game-service/internal/storage/mysql"
grpcserver "hyapp/services/game-service/internal/transport/grpc"
@ -91,8 +92,11 @@ func New(cfg config.Config) (*App, error) {
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("game-service")))
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, client.NewWalletClient(walletConn), client.NewUserClient(userConn), client.NewActivityGrowthClient(activityConn))
transport := grpcserver.NewServer(svc)
walletClient := client.NewWalletClient(walletConn)
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, client.NewUserClient(userConn), client.NewActivityGrowthClient(activityConn))
// 骰子服务复用同一个 MySQL repository 和 wallet client局事实走 dice 表,金币事实仍走 game_orders + wallet-service。
diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient)
transport := grpcserver.NewServer(svc, diceSvc)
gamev1.RegisterGameAppServiceServer(server, transport)
gamev1.RegisterGameCallbackServiceServer(server, transport)
gamev1.RegisterGameAdminServiceServer(server, transport)

View File

@ -0,0 +1,393 @@
package dice
import (
"encoding/json"
"sort"
"strings"
"hyapp/pkg/xerr"
)
const (
// 默认平台值只用于自研骰子没有第三方 provider 配置时的订单快照,保证 game_orders 唯一键仍有稳定平台维度。
DefaultPlatformCode = "dice"
DefaultProviderGameID = "dice"
// DefaultFeeBPS 用万分比表达平台费,结算时先从总奖池扣除,再把剩余奖池均分给最高分赢家。
DefaultFeeBPS = int64(500)
DefaultPoolBPS = int64(100)
DefaultJoinTTLMillis = int64(60_000)
DefaultRobotWaitMS = int64(3_000)
DefaultGameID = "dice"
// 首版禁止 1 人局避免单人场景下“自己赢自己”的派奖语义不清3 人局只是多一行 participant。
DefaultMinPlayers = int32(2)
DefaultMaxPlayers = int32(2)
HardMaxPlayers = int32(6)
// 单局胜场制每个参与者只扔一颗骰子;数组仍保留是为了兼容旧接口字段和未来玩法扩展。
PointsPerParticipant = 1
ProviderOrderPrefix = "dice"
MatchIDPrefix = "dice_match"
ParticipantResultWin = "win"
ParticipantResultLose = "lose"
ParticipantResultDraw = "draw"
MatchStatusCreated = "created"
MatchStatusJoining = "joining"
MatchStatusReady = "ready"
MatchStatusSettling = "settling"
MatchStatusPayoutApplying = "payout_applying"
MatchStatusSettled = "settled"
MatchStatusFailed = "failed"
MatchStatusCanceled = "canceled"
MatchPhaseWaiting = "waiting"
MatchPhaseCountdown = "countdown"
MatchPhaseRolling = "rolling"
MatchPhaseComparing = "comparing"
MatchPhaseSettled = "settled"
MatchPhaseCanceled = "canceled"
MatchPhaseFailed = "failed"
CountdownMillis = int64(3_000)
RollingMillis = int64(3_000)
ComparingMillis = int64(3_000)
DrawRerollMillis = int64(2_000)
SettlementShowMillis = int64(3_000)
ParticipantStatusJoined = "joined"
ParticipantStatusDebitSucceeded = "debit_succeeded"
ParticipantStatusDebitFailed = "debit_failed"
ParticipantStatusPayoutSucceeded = "payout_succeeded"
ParticipantStatusRefundSucceeded = "refund_succeeded"
ParticipantStatusSettled = "settled"
ParticipantTypeUser = "user"
ParticipantTypeRobot = "robot"
MatchModeHuman = "human"
MatchModeRobot = "robot"
ForcedResultPlayerLose = "player_lose"
ConfigStatusActive = "active"
ConfigStatusDisabled = "disabled"
RobotStatusActive = "active"
RobotStatusDisabled = "disabled"
PoolDirectionIn = "in"
PoolDirectionOut = "out"
)
// StakeOption 是后台配置给 H5 展示的下注档位;排序字段只影响展示,不参与结算公式。
type StakeOption struct {
StakeCoin int64
Enabled bool
SortOrder int32
}
// Config 是自研骰子游戏的运营配置快照;每局创建时会把费率复制到 match保证历史局按当时配置结算。
type Config struct {
AppCode string
GameID string
Status string
StakeOptions []StakeOption
FeeBPS int32
PoolBPS int32
MinPlayers int32
MaxPlayers int32
RobotEnabled bool
RobotMatchWaitMS int64
PoolBalanceCoin int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Robot 是 game-service 对“哪些真实 App 用户可作为机器人”的最小登记,不复制用户头像昵称。
type Robot struct {
AppCode string
GameID string
UserID int64
Status string
CreatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// PoolAdjustment 是奖池余额变化事实;所有机器人对局和后台手工调整都必须留下流水。
type PoolAdjustment struct {
AdjustmentID string
AppCode string
GameID string
MatchID string
UserID int64
Direction string
AmountCoin int64
Reason string
BalanceAfter int64
CreatedBy int64
CreatedAtMS int64
}
// Match 是骰子局的持久化事实;平台和 provider_game_id 是下单快照,不直接暴露给 App。
type Match struct {
AppCode string
MatchID string
GameID string
PlatformCode string
ProviderGameID string
RoomID string
RegionID int64
MinPlayers int32
MaxPlayers int32
CurrentPlayers int32
StakeCoin int64
RoundNo int32
Status string
Result string
Participants []Participant
JoinDeadlineMS int64
ReadyAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
SettledAtMS int64
CanceledAtMS int64
FeeBPS int32
PoolBPS int32
MatchMode string
ForcedResult string
PoolDeltaCoin int64
}
// Participant 是一局内单个用户的座位、扣款、投骰和派奖快照。
type Participant struct {
AppCode string
MatchID string
UserID int64
ParticipantType string
SeatNo int32
Status string
StakeCoin int64
DicePoints []int32
Result string
PayoutCoin int64
DebitOrderID string
PayoutOrderID string
RefundOrderID string
BalanceAfter int64
JoinedAtMS int64
UpdatedAtMS int64
}
// NormalizeConfig 把后台配置收口成可执行值;空配置仍返回安全默认,保证新环境可以直接打开骰子 H5。
func NormalizeConfig(config Config) (Config, error) {
config.GameID = strings.TrimSpace(config.GameID)
if config.GameID == "" {
config.GameID = DefaultGameID
}
config.Status = strings.TrimSpace(config.Status)
if config.Status == "" {
config.Status = ConfigStatusActive
}
if config.Status != ConfigStatusActive && config.Status != ConfigStatusDisabled {
return Config{}, xerr.New(xerr.InvalidArgument, "dice config status is invalid")
}
if config.FeeBPS < 0 || config.FeeBPS >= 10_000 {
return Config{}, xerr.New(xerr.InvalidArgument, "dice fee_bps is invalid")
}
if config.FeeBPS == 0 {
config.FeeBPS = int32(DefaultFeeBPS)
}
if config.PoolBPS < 0 || int64(config.FeeBPS)+int64(config.PoolBPS) >= 10_000 {
return Config{}, xerr.New(xerr.InvalidArgument, "dice pool_bps is invalid")
}
if config.PoolBPS == 0 {
config.PoolBPS = int32(DefaultPoolBPS)
}
minPlayers, maxPlayers, err := NormalizePlayerBounds(config.MinPlayers, config.MaxPlayers)
if err != nil {
return Config{}, err
}
config.MinPlayers = minPlayers
config.MaxPlayers = maxPlayers
if config.RobotMatchWaitMS <= 0 {
config.RobotMatchWaitMS = DefaultRobotWaitMS
}
config.StakeOptions = NormalizeStakeOptions(config.StakeOptions)
return config, nil
}
// NormalizeStakeOptions 去重、过滤非法档位并保持后台排序;空配置给出本地可用的保底档位。
func NormalizeStakeOptions(options []StakeOption) []StakeOption {
if len(options) == 0 {
return []StakeOption{
{StakeCoin: 100, Enabled: true, SortOrder: 10},
{StakeCoin: 500, Enabled: true, SortOrder: 20},
{StakeCoin: 1000, Enabled: true, SortOrder: 30},
{StakeCoin: 8000, Enabled: true, SortOrder: 40},
}
}
seen := make(map[int64]struct{}, len(options))
out := make([]StakeOption, 0, len(options))
for _, option := range options {
if option.StakeCoin <= 0 {
continue
}
if _, ok := seen[option.StakeCoin]; ok {
continue
}
seen[option.StakeCoin] = struct{}{}
out = append(out, option)
}
sort.SliceStable(out, func(left int, right int) bool {
if out[left].SortOrder == out[right].SortOrder {
return out[left].StakeCoin < out[right].StakeCoin
}
return out[left].SortOrder < out[right].SortOrder
})
if len(out) == 0 {
return NormalizeStakeOptions(nil)
}
return out
}
// StakeEnabled 明确校验下注金额是否来自后台启用档位,避免 H5 篡改 stake_coin 打出任意金额。
func StakeEnabled(config Config, stakeCoin int64) bool {
for _, option := range config.StakeOptions {
if option.StakeCoin == stakeCoin && option.Enabled {
return true
}
}
return false
}
// StakeOptionsJSON 把后台档位保存成稳定 JSON仓储不做字符串拼接避免后续字段扩展破坏解析。
func StakeOptionsJSON(options []StakeOption) string {
raw, err := json.Marshal(NormalizeStakeOptions(options))
if err != nil {
return "[]"
}
return string(raw)
}
// ParseStakeOptions 解析数据库 JSON坏配置回退为空后续 NormalizeConfig 会补安全默认。
func ParseStakeOptions(raw string) []StakeOption {
var options []StakeOption
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &options); err != nil {
return nil
}
return NormalizeStakeOptions(options)
}
// NormalizePlayerBounds 把客户端可调人数压回当前可承载范围;多人扩展只需要调上限,不需要换表。
func NormalizePlayerBounds(minPlayers int32, maxPlayers int32) (int32, int32, error) {
// 客户端不传人数时走产品默认值;传了非法值则直接拒绝,不在后端静默纠正成另一种玩法。
if minPlayers <= 0 {
minPlayers = DefaultMinPlayers
}
if maxPlayers <= 0 {
maxPlayers = DefaultMaxPlayers
}
if minPlayers < 2 {
return 0, 0, xerr.New(xerr.InvalidArgument, "dice min_players must be at least 2")
}
if maxPlayers < minPlayers {
return 0, 0, xerr.New(xerr.InvalidArgument, "dice max_players must be greater than or equal to min_players")
}
if maxPlayers > HardMaxPlayers {
return 0, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
}
return minPlayers, maxPlayers, nil
}
// Score 只读取单颗骰子点数;校验长度可以防止旧三骰数据或未投骰数据被当作新规则结算。
func Score(points []int32) (int32, error) {
// 点数必须来自服务端随机或已落库事实;长度不对说明本局还没完成投骰,不能推导结果。
if len(points) != PointsPerParticipant {
return 0, xerr.New(xerr.InvalidArgument, "dice points are incomplete")
}
point := points[0]
if point < 1 || point > 6 {
return 0, xerr.New(xerr.InvalidArgument, "dice point is invalid")
}
return point, nil
}
// SettleByHighestScore 使用单骰最高点数结算;全部平局只标记 draw由 service 保持同一局继续重投。
func SettleByHighestScore(participants []Participant, feeBPS int64) ([]Participant, string, error) {
return SettleByHighestScoreWithPool(participants, feeBPS, 0)
}
// SettleByHighestScoreWithPool 使用单骰最高点数结算,并把平台抽水和入池比例都从总奖池里扣出。
func SettleByHighestScoreWithPool(participants []Participant, feeBPS int64, poolBPS int64) ([]Participant, string, error) {
// 规则层再次兜底人数,避免绕过 service 或仓储测试桩时把未成局数据结算成有效账务。
if len(participants) < int(DefaultMinPlayers) {
return nil, "", xerr.New(xerr.Conflict, "dice match does not have enough participants")
}
if feeBPS < 0 || feeBPS >= 10_000 {
return nil, "", xerr.New(xerr.InvalidArgument, "dice fee_bps is invalid")
}
if poolBPS < 0 || feeBPS+poolBPS >= 10_000 {
return nil, "", xerr.New(xerr.InvalidArgument, "dice pool_bps is invalid")
}
settled := append([]Participant(nil), participants...)
// 按座位稳定排序后再输出结果,客户端刷新时参与者顺序不会因为 map/查询顺序变化而跳动。
sort.SliceStable(settled, func(left int, right int) bool {
return settled[left].SeatNo < settled[right].SeatNo
})
var highScore int32
winnerIndexes := make([]int, 0, len(settled))
for index := range settled {
// 每个参与者先取单骰点数,同时借 Score 校验 dice_points_json 是否完整合法。
score, err := Score(settled[index].DicePoints)
if err != nil {
return nil, "", err
}
if index == 0 || score > highScore {
highScore = score
winnerIndexes = winnerIndexes[:0]
winnerIndexes = append(winnerIndexes, index)
continue
}
if score == highScore {
winnerIndexes = append(winnerIndexes, index)
}
}
if len(winnerIndexes) == len(settled) {
// 全员最高分相同视为和局;首版产品要求 2 秒后继续扔,所以这里不产生 payout也不进入平台费和奖池抽成。
for index := range settled {
settled[index].Result = ParticipantResultDraw
settled[index].PayoutCoin = 0
}
return settled, ParticipantResultDraw, nil
}
var totalStake int64
for index := range settled {
// 押注必须是正数;一旦有坏账务快照,整局停止结算,由上层标记 failed 等待人工或补偿。
if settled[index].StakeCoin <= 0 {
return nil, "", xerr.New(xerr.InvalidArgument, "dice stake_coin is invalid")
}
totalStake += settled[index].StakeCoin
settled[index].Result = ParticipantResultLose
settled[index].PayoutCoin = 0
}
// 非和局时先扣平台费和入池金额,再把整数奖池按最高分赢家均分;除不尽的余数留在平台费侧,避免多发币。
payoutPool := totalStake * (10_000 - feeBPS - poolBPS) / 10_000
share := payoutPool / int64(len(winnerIndexes))
for _, index := range winnerIndexes {
settled[index].Result = ParticipantResultWin
settled[index].PayoutCoin = share
}
return settled, ParticipantResultWin, nil
}
// HasRolls 判断本局是否已经保存过骰子点数;重试结算必须复用旧点数,不能重新随机。
func HasRolls(participants []Participant) bool {
for _, participant := range participants {
if len(participant.DicePoints) > 0 {
return true
}
}
return false
}

View File

@ -0,0 +1,898 @@
package dice
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"time"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
// Repository 是骰子用例层需要的持久化边界;局表只保存游戏事实,金币流水仍复用 game_orders。
type Repository interface {
GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error
CreateDiceMatch(ctx context.Context, match dicedomain.Match) (dicedomain.Match, error)
JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
GetDiceMatch(ctx context.Context, appCode string, matchID string) (dicedomain.Match, error)
ClaimDiceMatchForRoll(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
SaveDiceRolls(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error)
SaveDiceDrawForReroll(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error)
MarkDiceParticipantDebitSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
MarkDiceParticipantDebitFailed(ctx context.Context, appCode string, matchID string, userID int64, orderID string, nowMs int64) error
MarkDiceParticipantPayoutSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
MarkDiceParticipantRefundSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
MarkDiceMatchSettled(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error
MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error
GetDiceConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error)
ListSelfGameConfigs(ctx context.Context, appCode string) ([]dicedomain.Config, error)
UpsertDiceConfig(ctx context.Context, config dicedomain.Config, nowMs int64) (dicedomain.Config, error)
FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, nowMs int64) (dicedomain.Match, error)
JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, nowMs int64) (dicedomain.Match, error)
CancelDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string) (dicedomain.Robot, error)
AdjustDicePool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error)
ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error)
RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error)
SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error)
DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error
}
// WalletClient 是 game-service 调 wallet-service 的游戏改账入口。
type WalletClient interface {
ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
}
type Config struct {
JoinTTL time.Duration
FeeBPS int64
MaxPlayers int32
}
type Service struct {
config Config
repository Repository
wallet WalletClient
// now/randomInt 作为依赖注入点,保证测试能锁定时间和骰子点数,不需要碰全局随机源。
now func() time.Time
randomInt func(max int) (int, error)
}
type CreateMatchCommand struct {
AppCode string
RequestID string
UserID int64
GameID string
RoomID string
RegionID int64
StakeCoin int64
MinPlayers int32
MaxPlayers int32
}
type JoinMatchCommand struct {
AppCode string
RequestID string
UserID int64
MatchID string
}
type GetMatchCommand struct {
AppCode string
UserID int64
MatchID string
}
type RollMatchCommand struct {
AppCode string
RequestID string
UserID int64
MatchID string
}
type MatchCommand struct {
AppCode string
RequestID string
UserID int64
GameID string
RoomID string
RegionID int64
StakeCoin int64
}
type CancelMatchCommand struct {
AppCode string
RequestID string
UserID int64
MatchID string
}
// New 创建骰子游戏用例层;随机数、时间和费率都集中在这里,便于测试和后续按房间配置扩展。
func New(config Config, repository Repository, wallet WalletClient) *Service {
// 默认值在构造阶段一次性收口,后面的业务分支只看规范化后的配置,避免每个入口重复判断。
if config.JoinTTL <= 0 {
config.JoinTTL = time.Duration(dicedomain.DefaultJoinTTLMillis) * time.Millisecond
}
if config.FeeBPS <= 0 {
config.FeeBPS = dicedomain.DefaultFeeBPS
}
if config.MaxPlayers <= 0 || config.MaxPlayers > dicedomain.HardMaxPlayers {
config.MaxPlayers = dicedomain.HardMaxPlayers
}
return &Service{
config: config,
repository: repository,
wallet: wallet,
now: time.Now,
randomInt: cryptoRandomInt,
}
}
// CreateMatch 只创建局和首个参与者,不提前扣金币;真正扣款在 roll 阶段统一串到钱包和 outbox。
func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (dicedomain.Match, int64, error) {
// 创建局必须先校验 game_catalog/platform 当前可用,防止下架或维护中的游戏继续生成可结算局。
if s.repository == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" || command.StakeCoin <= 0 {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice match command is incomplete")
}
minPlayers, maxPlayers, err := dicedomain.NormalizePlayerBounds(command.MinPlayers, command.MaxPlayers)
if err != nil {
return dicedomain.Match{}, 0, err
}
if maxPlayers > s.config.MaxPlayers {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
}
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
game, err := s.repository.GetLaunchableGame(ctx, app, strings.TrimSpace(command.GameID))
if err != nil {
return dicedomain.Match{}, 0, err
}
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game is not active")
}
config, err := s.configForMatch(ctx, app, game.GameID)
if err != nil {
return dicedomain.Match{}, 0, err
}
if config.Status != dicedomain.ConfigStatusActive {
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game config is disabled")
}
if !dicedomain.StakeEnabled(config, command.StakeCoin) {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
}
if command.MinPlayers <= 0 {
command.MinPlayers = config.MinPlayers
}
if command.MaxPlayers <= 0 {
command.MaxPlayers = config.MaxPlayers
}
minPlayers, maxPlayers, err = dicedomain.NormalizePlayerBounds(command.MinPlayers, command.MaxPlayers)
if err != nil {
return dicedomain.Match{}, 0, err
}
if maxPlayers > s.config.MaxPlayers {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
}
now := s.now()
nowMS := now.UnixMilli()
// match_id 用 app/user/time/request 混合生成真正防重靠数据库主键hash 只负责长度稳定和不暴露业务输入。
matchID := dicedomain.MatchIDPrefix + "_" + stableHash(fmt.Sprintf("%s|%d|%d|%s", app, command.UserID, now.UnixNano(), command.RequestID))[:20]
match := dicedomain.Match{
AppCode: app,
MatchID: matchID,
GameID: game.GameID,
PlatformCode: strings.TrimSpace(game.PlatformCode),
ProviderGameID: strings.TrimSpace(game.ProviderGameID),
RoomID: strings.TrimSpace(command.RoomID),
RegionID: command.RegionID,
MinPlayers: minPlayers,
MaxPlayers: maxPlayers,
CurrentPlayers: 1,
StakeCoin: command.StakeCoin,
RoundNo: 1,
Status: dicedomain.MatchStatusCreated,
FeeBPS: config.FeeBPS,
PoolBPS: config.PoolBPS,
MatchMode: dicedomain.MatchModeHuman,
JoinDeadlineMS: now.Add(s.config.JoinTTL).UnixMilli(),
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
Participants: []dicedomain.Participant{{
// 创建人天然占 1 号座位;后续 join 在仓储行锁内按 max(seat_no)+1 分配,避免并发抢座冲突。
AppCode: app,
MatchID: matchID,
UserID: command.UserID,
ParticipantType: dicedomain.ParticipantTypeUser,
SeatNo: 1,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: command.StakeCoin,
JoinedAtMS: nowMS,
UpdatedAtMS: nowMS,
}},
}
if match.PlatformCode == "" {
// 自研骰子理论上仍应在 catalog 配好平台;这里兜底是为了订单唯一键和统计 outbox 字段始终非空。
match.PlatformCode = dicedomain.DefaultPlatformCode
}
if match.ProviderGameID == "" {
match.ProviderGameID = dicedomain.DefaultProviderGameID
}
saved, err := s.repository.CreateDiceMatch(ctx, match)
if err != nil {
return dicedomain.Match{}, 0, err
}
return saved, nowMS, nil
}
// GetConfig 返回 H5 大厅需要的骰子配置;没有后台配置时返回内置默认,不阻断新环境联调。
func (s *Service) GetConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, int64, error) {
if s.repository == nil {
return dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, app)
config, err := s.configForMatch(ctx, app, strings.TrimSpace(gameID))
if err != nil {
return dicedomain.Config{}, 0, err
}
return config, s.now().UnixMilli(), nil
}
func (s *Service) ListSelfGames(ctx context.Context, appCode string) ([]dicedomain.Config, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, app)
configs, err := s.repository.ListSelfGameConfigs(ctx, app)
if err != nil {
return nil, 0, err
}
if len(configs) == 0 {
config, err := s.configForMatch(ctx, app, dicedomain.DefaultGameID)
if err != nil {
return nil, 0, err
}
configs = append(configs, config)
}
return configs, s.now().UnixMilli(), nil
}
func (s *Service) UpdateConfig(ctx context.Context, config dicedomain.Config) (dicedomain.Config, int64, error) {
if s.repository == nil {
return dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
config.AppCode = appcode.Normalize(config.AppCode)
ctx = appcode.WithContext(ctx, config.AppCode)
nowMS := s.now().UnixMilli()
saved, err := s.repository.UpsertDiceConfig(ctx, config, nowMS)
return saved, nowMS, err
}
func (s *Service) AdjustPool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, dicedomain.Config, int64, error) {
if s.repository == nil {
return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
adjustment.AppCode = appcode.Normalize(adjustment.AppCode)
ctx = appcode.WithContext(ctx, adjustment.AppCode)
nowMS := s.now().UnixMilli()
adjustment.CreatedAtMS = nowMS
if adjustment.AdjustmentID == "" {
adjustment.AdjustmentID = "dpool_admin_" + stableHash(fmt.Sprintf("%s|%s|%s|%d|%d|%s", adjustment.AppCode, adjustment.GameID, adjustment.Direction, adjustment.AmountCoin, adjustment.CreatedBy, adjustment.Reason))[:20]
}
saved, err := s.repository.AdjustDicePool(ctx, adjustment)
if err != nil {
return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, err
}
config, err := s.configForMatch(ctx, adjustment.AppCode, adjustment.GameID)
return saved, config, nowMS, err
}
func (s *Service) ListRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, int64, error) {
if s.repository == nil {
return nil, "", 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, app)
items, next, err := s.repository.ListDiceRobots(ctx, app, gameID, status, pageSize, cursor)
return items, next, s.now().UnixMilli(), err
}
func (s *Service) RegisterRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64) ([]dicedomain.Robot, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
items, err := s.repository.RegisterDiceRobots(ctx, app, gameID, userIDs, adminID, nowMS)
return items, nowMS, err
}
func (s *Service) SetRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string) (dicedomain.Robot, int64, error) {
if s.repository == nil {
return dicedomain.Robot{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
item, err := s.repository.SetDiceRobotStatus(ctx, app, gameID, userID, status, nowMS)
return item, nowMS, err
}
// DeleteRobot 删除当前 app 和游戏下的机器人登记,真实用户资料仍由 user-service 保留用于历史数据追溯。
func (s *Service) DeleteRobot(ctx context.Context, appCode string, gameID string, userID int64) (int64, error) {
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if userID <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "dice robot user id is invalid")
}
app := appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
err := s.repository.DeleteDiceRobot(ctx, app, gameID, userID)
return nowMS, err
}
// Match 按全服 app_code + game_id + stake_coin 匹配;没有等待真人时创建等待局,不提前扣金币。
func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.Match, int64, error) {
if s.repository == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if s.wallet == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" || command.StakeCoin <= 0 {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice match command is incomplete")
}
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
config, err := s.configForMatch(ctx, app, strings.TrimSpace(command.GameID))
if err != nil {
return dicedomain.Match{}, 0, err
}
if config.Status != dicedomain.ConfigStatusActive {
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game config is disabled")
}
if !dicedomain.StakeEnabled(config, command.StakeCoin) {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
}
if err := s.ensureCoinBalance(ctx, command.RequestID, app, command.UserID, command.StakeCoin); err != nil {
return dicedomain.Match{}, 0, err
}
nowMS := s.now().UnixMilli()
match, err := s.repository.FindAndJoinWaitingDiceMatch(ctx, app, config.GameID, command.UserID, command.StakeCoin, nowMS)
if err == nil {
return s.attachRobotIfDue(ctx, command.RequestID, match, nowMS)
}
if !xerr.IsCode(err, xerr.NotFound) {
return dicedomain.Match{}, 0, err
}
// 没有可加入的真人局时创建新局room_id 仅落字段预留,首版全服匹配强制不按房间隔离。
return s.CreateMatch(ctx, CreateMatchCommand{
AppCode: app,
RequestID: command.RequestID,
UserID: command.UserID,
GameID: config.GameID,
RoomID: "",
RegionID: command.RegionID,
StakeCoin: command.StakeCoin,
MinPlayers: config.MinPlayers,
MaxPlayers: config.MaxPlayers,
})
}
// JoinMatch 在 match 行锁内分配座位;钱包不在这里扣款,避免未开局匹配占用用户余额。
func (s *Service) JoinMatch(ctx context.Context, command JoinMatchCommand) (dicedomain.Match, int64, error) {
if s.repository == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice join command is incomplete")
}
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
// 加入只修改参与者事实;如果用户重复点击 join仓储按唯一参与者返回已有局快照。
match, err := s.repository.JoinDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
return match, nowMS, nil
}
// GetMatch 只读取 MySQL 当前事实;如果后续加 Redis也只能作为这个结果的旁路缓存。
func (s *Service) GetMatch(ctx context.Context, command GetMatchCommand) (dicedomain.Match, int64, error) {
if s.repository == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice get command is incomplete")
}
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
match, err := s.repository.GetDiceMatch(ctx, app, strings.TrimSpace(command.MatchID))
if err != nil {
return dicedomain.Match{}, 0, err
}
return s.attachRobotIfDue(ctx, "", match, nowMS)
}
// CancelMatch 只允许等待中的创建人撤销局;没有扣款,因此取消不会触发钱包补偿。
func (s *Service) CancelMatch(ctx context.Context, command CancelMatchCommand) (dicedomain.Match, int64, error) {
if s.repository == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice cancel command is incomplete")
}
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
match, err := s.repository.CancelDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
return match, nowMS, err
}
// RollMatch 先用 MySQL 抢占结算权,再串行调用钱包;钱包幂等键保证重试不会重复扣款或派奖。
func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dicedomain.Match, int64, error) {
if s.repository == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if s.wallet == nil {
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice roll command is incomplete")
}
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
// ClaimDiceMatchForRoll 用 match 行锁把局切到 settling锁提交后才调用钱包减少数据库锁持有时间。
match, err := s.repository.ClaimDiceMatchForRoll(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
if match.Status == dicedomain.MatchStatusSettled {
// roll 重试命中已结算局时直接返回结果,客户端可以安全重复请求。
return match, nowMS, nil
}
if len(match.Participants) < int(match.MinPlayers) {
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice match does not have enough participants")
}
normalizeMatchEconomicSnapshot(&match)
for index := range match.Participants {
if match.Participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
// 机器人是真实 App 用户资料,但对局资金来自共享奖池;不能对机器人用户钱包扣本金。
match.Participants[index].Status = dicedomain.ParticipantStatusDebitSucceeded
continue
}
if alreadyDebited(match.Participants[index].Status) {
// 补偿或重试进入这里时,已扣款/已派奖/已退款的参与者不能再次扣本金。
continue
}
// 每个参与者独立生成 provider_order_idwallet-service 用 command_id/request_hash 做最终账务幂等。
order, balanceAfter, err := s.applyGameCoinChange(ctx, command.RequestID, match, match.Participants[index], "debit", match.Participants[index].StakeCoin)
if err != nil {
// 任一扣款失败,本局不能继续开奖;已扣成功的参与者尽量立即退款,失败细节留在 game_orders 和 match failed 状态里。
_ = s.repository.MarkDiceParticipantDebitFailed(ctx, app, match.MatchID, match.Participants[index].UserID, order.OrderID, nowMS)
_ = s.refundDebitedParticipants(ctx, command.RequestID, match, nowMS)
_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "debit_failed", nowMS)
return dicedomain.Match{}, 0, err
}
match.Participants[index].Status = dicedomain.ParticipantStatusDebitSucceeded
match.Participants[index].DebitOrderID = order.OrderID
match.Participants[index].BalanceAfter = balanceAfter
if err := s.repository.MarkDiceParticipantDebitSucceeded(ctx, app, match.MatchID, match.Participants[index].UserID, order.OrderID, balanceAfter, nowMS); err != nil {
return dicedomain.Match{}, 0, err
}
}
if !dicedomain.HasRolls(match.Participants) {
// 只有首次结算可以随机点数;点数一旦落库,所有后续重试必须复用旧点数,避免改变输赢结果。
if err := s.rollParticipants(match.Participants); err != nil {
return dicedomain.Match{}, 0, err
}
if match.ForcedResult == dicedomain.ForcedResultPlayerLose {
// 奖池不足的机器人局在匹配完成时已经冻结强制结果roll 阶段只负责生成与结果一致的点数。
s.forceHumanLose(match.Participants)
}
settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
if err != nil {
return dicedomain.Match{}, 0, err
}
if result == dicedomain.ParticipantResultDraw {
// 和局不退本金也不派奖;保存本轮点数给 H5 展示,局保持 ready2 秒后客户端继续调用 roll。
match.Participants = settledParticipants
match.Result = result
saved, err := s.repository.SaveDiceDrawForReroll(ctx, match, nowMS)
return saved, nowMS, err
}
// SaveDiceRolls 原子写入每个参与者点数、结果和 payout_coin同时把局推进到 payout_applying。
match.Participants = settledParticipants
match.Result = result
match, err = s.repository.SaveDiceRolls(ctx, match, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
} else if match.Result == "" {
// 兼容“点数已落库但 match.result 未写入”的半完成状态,按旧点数重新推导派彩金额。
settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
if err != nil {
return dicedomain.Match{}, 0, err
}
match.Participants = settledParticipants
match.Result = result
}
for index := range match.Participants {
participant := match.Participants[index]
if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
// 机器人输赢只影响奖池,不走用户钱包 credit/refund。
continue
}
if participant.PayoutCoin <= 0 || participant.Status == dicedomain.ParticipantStatusPayoutSucceeded || participant.Status == dicedomain.ParticipantStatusRefundSucceeded || participant.Status == dicedomain.ParticipantStatusSettled {
// 输家 payout_coin 为 0已处理过的赢家或和局用户不重复调用钱包。
continue
}
opType := "credit"
if participant.Result == dicedomain.ParticipantResultDraw {
// 和局走 refund 而不是 credit账务语义上明确这是返还本金不算中奖派奖。
opType = "refund"
}
order, balanceAfter, err := s.applyGameCoinChange(ctx, command.RequestID, match, participant, opType, participant.PayoutCoin)
if err != nil {
// 点数已经固定后派奖失败不能重投,只标记 failed 交给后续补偿继续按同一 provider_order_id 处理。
_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "payout_failed", nowMS)
return dicedomain.Match{}, 0, err
}
match.Participants[index].BalanceAfter = balanceAfter
if opType == "refund" {
match.Participants[index].Status = dicedomain.ParticipantStatusRefundSucceeded
if err := s.repository.MarkDiceParticipantRefundSucceeded(ctx, app, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil {
return dicedomain.Match{}, 0, err
}
continue
}
match.Participants[index].Status = dicedomain.ParticipantStatusPayoutSucceeded
if err := s.repository.MarkDiceParticipantPayoutSucceeded(ctx, app, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil {
return dicedomain.Match{}, 0, err
}
}
if err := s.applyPoolDelta(ctx, command.RequestID, match, nowMS); err != nil {
// 钱包账务已经按固定点数推进后,奖池流水失败不能重投;把局留在 failed 便于人工按 match_id 对账补偿。
_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "pool_failed", nowMS)
return dicedomain.Match{}, 0, err
}
// 所有应付账务都成功后才把 match 和 participants 统一标记 settled客户端看到 settled 即可认为结果不可变。
if err := s.repository.MarkDiceMatchSettled(ctx, app, match.MatchID, match.Result, nowMS); err != nil {
return dicedomain.Match{}, 0, err
}
saved, err := s.repository.GetDiceMatch(ctx, app, match.MatchID)
return saved, nowMS, err
}
func (s *Service) configForMatch(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error) {
config, err := s.repository.GetDiceConfig(ctx, appCode, strings.TrimSpace(gameID))
if err == nil {
return config, nil
}
if !xerr.IsCode(err, xerr.NotFound) {
return dicedomain.Config{}, err
}
// 没有后台配置时把默认配置写成 MySQL 事实,再重新读取;这样 H5、大厅后台和机器人奖池判断都使用同一份配置/奖池快照。
normalized, err := dicedomain.NormalizeConfig(dicedomain.Config{
AppCode: appcode.Normalize(appCode),
GameID: strings.TrimSpace(gameID),
Status: dicedomain.ConfigStatusActive,
FeeBPS: int32(dicedomain.DefaultFeeBPS),
PoolBPS: int32(dicedomain.DefaultPoolBPS),
MinPlayers: dicedomain.DefaultMinPlayers,
MaxPlayers: dicedomain.DefaultMaxPlayers,
RobotEnabled: true,
RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS,
})
if err != nil {
return dicedomain.Config{}, err
}
return s.repository.UpsertDiceConfig(ctx, normalized, s.now().UnixMilli())
}
func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, appCode string, userID int64, stakeCoin int64) error {
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: strings.TrimSpace(requestID),
UserId: userID,
AppCode: appCode,
AssetTypes: []string{"COIN"},
})
if err != nil {
return err
}
for _, balance := range resp.GetBalances() {
if strings.EqualFold(balance.GetAssetType(), "COIN") && balance.GetAvailableAmount() >= stakeCoin {
return nil
}
}
return xerr.New(xerr.InsufficientBalance, "coin balance is insufficient")
}
func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) (dicedomain.Match, int64, error) {
if match.Status != dicedomain.MatchStatusCreated && match.Status != dicedomain.MatchStatusJoining {
return match, nowMS, nil
}
if match.CurrentPlayers >= match.MinPlayers {
return match, nowMS, nil
}
config, err := s.configForMatch(ctx, match.AppCode, match.GameID)
if err != nil {
return dicedomain.Match{}, 0, err
}
if !config.RobotEnabled || nowMS < match.CreatedAtMS+config.RobotMatchWaitMS {
return match, nowMS, nil
}
robot, err := s.repository.PickActiveDiceRobot(ctx, match.AppCode, match.GameID, match.MatchID)
if err != nil {
if xerr.IsCode(err, xerr.NotFound) {
return match, nowMS, nil
}
return dicedomain.Match{}, 0, err
}
forcedResult := ""
requiredPool := robotRequiredPoolCoin(match)
if config.PoolBalanceCoin < requiredPool {
forcedResult = dicedomain.ForcedResultPlayerLose
}
joined, err := s.repository.JoinDiceRobot(ctx, match.AppCode, match.MatchID, robot.UserID, forcedResult, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
return joined, nowMS, nil
}
func (s *Service) forceHumanLose(participants []dicedomain.Participant) {
for index := range participants {
if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
// 奖池不足时仍然落真实单骰格式;机器人最大点数、真人最小点数,保证结算规则自然判定玩家输。
participants[index].DicePoints = []int32{6}
continue
}
participants[index].DicePoints = []int32{1}
}
}
func (s *Service) applyPoolDelta(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) error {
direction, amount, userID := dicePoolDelta(match)
if amount <= 0 {
return nil
}
_, err := s.repository.AdjustDicePool(ctx, dicedomain.PoolAdjustment{
AdjustmentID: "dpool_" + stableHash(fmt.Sprintf("%s|%s|%s|%d|%s", match.AppCode, match.MatchID, direction, amount, requestID))[:24],
AppCode: match.AppCode,
GameID: match.GameID,
MatchID: match.MatchID,
UserID: userID,
Direction: direction,
AmountCoin: amount,
Reason: "dice_settlement",
CreatedAtMS: nowMS,
})
return err
}
func normalizeMatchEconomicSnapshot(match *dicedomain.Match) {
if match.FeeBPS == 0 {
match.FeeBPS = int32(dicedomain.DefaultFeeBPS)
}
if match.PoolBPS == 0 {
match.PoolBPS = int32(dicedomain.DefaultPoolBPS)
}
for index := range match.Participants {
if strings.TrimSpace(match.Participants[index].ParticipantType) == "" {
match.Participants[index].ParticipantType = dicedomain.ParticipantTypeUser
}
}
}
func robotRequiredPoolCoin(match dicedomain.Match) int64 {
normalizeMatchEconomicSnapshot(&match)
robotNetStake := match.StakeCoin * (10_000 - int64(match.FeeBPS) - int64(match.PoolBPS)) / 10_000
if robotNetStake < 0 {
return 0
}
return robotNetStake
}
func dicePoolDelta(match dicedomain.Match) (string, int64, int64) {
normalizeMatchEconomicSnapshot(&match)
var totalStake int64
var firstHuman dicedomain.Participant
for _, participant := range match.Participants {
totalStake += participant.StakeCoin
if participant.ParticipantType != dicedomain.ParticipantTypeRobot && firstHuman.UserID == 0 {
firstHuman = participant
}
}
if match.Result == dicedomain.ParticipantResultDraw {
return "", 0, firstHuman.UserID
}
if match.MatchMode != dicedomain.MatchModeRobot {
amount := totalStake * int64(match.PoolBPS) / 10_000
return dicedomain.PoolDirectionIn, amount, firstHuman.UserID
}
if firstHuman.Result == dicedomain.ParticipantResultWin {
netOut := firstHuman.PayoutCoin - firstHuman.StakeCoin
if netOut <= 0 {
return "", 0, firstHuman.UserID
}
return dicedomain.PoolDirectionOut, netOut, firstHuman.UserID
}
if firstHuman.Result == dicedomain.ParticipantResultLose {
amount := firstHuman.StakeCoin * (10_000 - int64(match.FeeBPS)) / 10_000
return dicedomain.PoolDirectionIn, amount, firstHuman.UserID
}
return "", 0, firstHuman.UserID
}
func (s *Service) rollParticipants(participants []dicedomain.Participant) error {
for index := range participants {
points := make([]int32, 0, dicedomain.PointsPerParticipant)
for roll := 0; roll < dicedomain.PointsPerParticipant; roll++ {
// crypto/rand 返回 0-5这里加 1 映射成真实骰子点数 1-6。
value, err := s.randomInt(6)
if err != nil {
return err
}
points = append(points, int32(value+1))
}
participants[index].DicePoints = points
}
return nil
}
func (s *Service) refundDebitedParticipants(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) error {
var firstErr error
for _, participant := range match.Participants {
if !alreadyDebited(participant.Status) {
continue
}
// 扣款阶段失败时的退款同样走 game_orders + wallet 幂等,重复补偿不会多退。
order, balanceAfter, err := s.applyGameCoinChange(ctx, requestID, match, participant, "refund", participant.StakeCoin)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if err := s.repository.MarkDiceParticipantRefundSucceeded(ctx, match.AppCode, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) applyGameCoinChange(ctx context.Context, requestID string, match dicedomain.Match, participant dicedomain.Participant, opType string, amount int64) (gamedomain.GameOrder, int64, error) {
if amount <= 0 {
return gamedomain.GameOrder{}, participant.BalanceAfter, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
}
// provider_order_id 按 match/user/op 固定,确保同一参与者同一动作在 game_orders 和 wallet-service 两边都可幂等重放。
providerOrderID := diceProviderOrderID(match.MatchID, participant.UserID, opType)
// request_hash 覆盖会影响账务结果的字段;同一个 provider_order_id 携带不同金额或方向会被订单层拒绝。
requestHash := stableHash(fmt.Sprintf("%s|%s|%s|%d|%d|%d", match.AppCode, match.MatchID, opType, participant.UserID, amount, match.RoundNo))
order := gamedomain.GameOrder{
AppCode: match.AppCode,
OrderID: "gord_" + stableHash(match.AppCode+"|"+match.PlatformCode+"|"+providerOrderID),
PlatformCode: match.PlatformCode,
ProviderOrderID: providerOrderID,
ProviderRoundID: match.MatchID,
GameID: match.GameID,
ProviderGameID: match.ProviderGameID,
UserID: participant.UserID,
RoomID: match.RoomID,
RegionID: match.RegionID,
OpType: opType,
CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying,
RequestHash: requestHash,
}
order, exists, err := s.repository.CreateGameOrder(ctx, order)
if err != nil {
return gamedomain.GameOrder{}, 0, err
}
if exists && order.Status == gamedomain.OrderStatusSucceeded {
// game_orders 已成功说明 wallet 改账和 outbox 都已经落库;直接返回旧账后余额,避免再次触碰钱包。
return order, order.WalletBalanceAfter, nil
}
// 钱包是 COIN ownergame-service 只提交游戏改账命令,余额版本、资产账户和账本分录都由 wallet-service 保证。
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
RequestId: strings.TrimSpace(requestID),
AppCode: match.AppCode,
CommandId: "game:" + providerOrderID,
UserId: participant.UserID,
PlatformCode: match.PlatformCode,
GameId: match.GameID,
ProviderOrderId: providerOrderID,
ProviderRoundId: match.MatchID,
OpType: opType,
CoinAmount: amount,
RoomId: match.RoomID,
RequestHash: requestHash,
})
nowMS := s.now().UnixMilli()
if err != nil {
status := gamedomain.OrderStatusFailed
if xerr.IsCode(err, xerr.IdempotencyConflict) {
// 钱包侧幂等冲突代表 command_id 重放但 payload 不一致,订单状态必须标 conflict 便于排查。
status = gamedomain.OrderStatusConflict
}
_ = s.repository.MarkOrderFailed(ctx, match.AppCode, order.OrderID, status, walletFailureCode(err), err.Error(), nowMS)
return order, 0, err
}
// MarkOrderSucceeded 会同步写 game_outbox并且 debit 订单额外写 game_level_event_outbox。
if err := s.repository.MarkOrderSucceeded(ctx, match.AppCode, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMS); err != nil {
return order, 0, err
}
order.WalletTransactionID = walletResp.GetWalletTransactionId()
order.WalletBalanceAfter = walletResp.GetBalanceAfter()
order.Status = gamedomain.OrderStatusSucceeded
return order, walletResp.GetBalanceAfter(), nil
}
func alreadyDebited(status string) bool {
switch status {
case dicedomain.ParticipantStatusDebitSucceeded, dicedomain.ParticipantStatusPayoutSucceeded, dicedomain.ParticipantStatusRefundSucceeded, dicedomain.ParticipantStatusSettled:
return true
default:
return false
}
}
func diceProviderOrderID(matchID string, userID int64, opType string) string {
return fmt.Sprintf("%s:%s:%d:%s", dicedomain.ProviderOrderPrefix, strings.TrimSpace(matchID), userID, strings.TrimSpace(opType))
}
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func cryptoRandomInt(max int) (int, error) {
if max <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "random max is invalid")
}
value, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
if err != nil {
return 0, err
}
return int(value.Int64()), nil
}
func walletFailureCode(err error) string {
if code := xerr.CodeOf(err); code != "" {
return string(code)
}
return string(xerr.ReasonFromGRPC(err))
}

View File

@ -0,0 +1,512 @@
package dice
import (
"context"
"strings"
"testing"
"time"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
func TestRollMatchDebitsRollsAndPaysWinner(t *testing.T) {
repo := newFakeDiceRepository()
wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
svc := New(Config{FeeBPS: 500}, repo, wallet)
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
rolls := []int{5, 0}
svc.randomInt = func(max int) (int, error) {
if len(rolls) == 0 {
t.Fatal("randomInt called too many times")
}
value := rolls[0]
rolls = rolls[1:]
return value, nil
}
created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
AppCode: "lalu",
RequestID: "req-create",
UserID: 101,
GameID: "dice",
RoomID: "room_1",
RegionID: 100,
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 3,
})
if err != nil {
t.Fatalf("CreateMatch failed: %v", err)
}
if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join", UserID: 202, MatchID: created.MatchID}); err != nil {
t.Fatalf("JoinMatch failed: %v", err)
}
settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll", UserID: 101, MatchID: created.MatchID})
if err != nil {
t.Fatalf("RollMatch failed: %v", err)
}
if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin {
t.Fatalf("match not settled as win: %+v", settled)
}
if len(settled.Participants) != 2 {
t.Fatalf("participants len = %d", len(settled.Participants))
}
winner := participantByUser(settled.Participants, 101)
loser := participantByUser(settled.Participants, 202)
if len(winner.DicePoints) != 1 || winner.DicePoints[0] != 6 {
t.Fatalf("winner should have one dice point, got %+v", winner.DicePoints)
}
if len(loser.DicePoints) != 1 || loser.DicePoints[0] != 1 {
t.Fatalf("loser should have one dice point, got %+v", loser.DicePoints)
}
if winner.Result != dicedomain.ParticipantResultWin || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 {
t.Fatalf("winner settlement mismatch: %+v", winner)
}
if loser.Result != dicedomain.ParticipantResultLose || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 {
t.Fatalf("loser settlement mismatch: %+v", loser)
}
if wallet.calls != 3 {
t.Fatalf("wallet calls = %d, want 3", wallet.calls)
}
if repo.levelDebitOrders != 2 {
t.Fatalf("debit orders should still feed game order success path, got %d", repo.levelDebitOrders)
}
}
func TestRollMatchRequiresMinimumParticipantsBeforeWalletDebit(t *testing.T) {
repo := newFakeDiceRepository()
wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
svc := New(Config{}, repo, wallet)
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
AppCode: "lalu",
RequestID: "req-create",
UserID: 101,
GameID: "dice",
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 3,
})
if err != nil {
t.Fatalf("CreateMatch failed: %v", err)
}
_, _, err = svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll", UserID: 101, MatchID: created.MatchID})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("RollMatch should reject not enough participants, got %v", err)
}
if wallet.calls != 0 {
t.Fatalf("wallet should not be called, got %d", wallet.calls)
}
}
func TestRollMatchDrawRerollsUntilWinner(t *testing.T) {
repo := newFakeDiceRepository()
wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
svc := New(Config{FeeBPS: 500}, repo, wallet)
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
rolls := []int{2, 2, 5, 0}
svc.randomInt = func(max int) (int, error) {
if len(rolls) == 0 {
t.Fatal("randomInt called too many times")
}
value := rolls[0]
rolls = rolls[1:]
return value, nil
}
created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
AppCode: "lalu",
RequestID: "req-create",
UserID: 101,
GameID: "dice",
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 2,
})
if err != nil {
t.Fatalf("CreateMatch failed: %v", err)
}
if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join", UserID: 202, MatchID: created.MatchID}); err != nil {
t.Fatalf("JoinMatch failed: %v", err)
}
draw, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-1", UserID: 101, MatchID: created.MatchID})
if err != nil {
t.Fatalf("first RollMatch failed: %v", err)
}
if draw.Status != dicedomain.MatchStatusReady || draw.Result != dicedomain.ParticipantResultDraw || draw.RoundNo != 2 {
t.Fatalf("draw should keep match ready for reroll: %+v", draw)
}
if wallet.calls != 2 {
t.Fatalf("draw should only debit both users, wallet calls = %d", wallet.calls)
}
for _, participant := range draw.Participants {
if participant.Result != dicedomain.ParticipantResultDraw || participant.PayoutCoin != 0 || len(participant.DicePoints) != 1 || participant.DicePoints[0] != 3 {
t.Fatalf("draw participant mismatch: %+v", participant)
}
}
settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-2", UserID: 101, MatchID: created.MatchID})
if err != nil {
t.Fatalf("second RollMatch failed: %v", err)
}
if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin {
t.Fatalf("reroll should settle with winner: %+v", settled)
}
if wallet.calls != 3 {
t.Fatalf("reroll should not debit again and should only pay winner, wallet calls = %d", wallet.calls)
}
winner := participantByUser(settled.Participants, 101)
loser := participantByUser(settled.Participants, 202)
if winner.DicePoints[0] != 6 || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 {
t.Fatalf("winner after reroll mismatch: %+v", winner)
}
if loser.DicePoints[0] != 1 || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 {
t.Fatalf("loser after reroll mismatch: %+v", loser)
}
}
type fakeDiceRepository struct {
game gamedomain.LaunchableGame
match dicedomain.Match
config dicedomain.Config
poolBalance int64
orders map[string]gamedomain.GameOrder
levelDebitOrders int
}
func newFakeDiceRepository() *fakeDiceRepository {
return &fakeDiceRepository{
game: gamedomain.LaunchableGame{
CatalogItem: gamedomain.CatalogItem{
AppCode: "lalu",
GameID: "dice",
PlatformCode: "dice",
ProviderGameID: "dice",
GameName: "Dice",
Status: gamedomain.StatusActive,
},
PlatformStatus: gamedomain.StatusActive,
},
config: dicedomain.Config{
AppCode: "lalu",
GameID: "dice",
Status: dicedomain.ConfigStatusActive,
StakeOptions: dicedomain.NormalizeStakeOptions(nil),
FeeBPS: int32(dicedomain.DefaultFeeBPS),
PoolBPS: int32(dicedomain.DefaultPoolBPS),
MinPlayers: dicedomain.DefaultMinPlayers,
MaxPlayers: dicedomain.DefaultMaxPlayers,
RobotEnabled: true,
RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS,
},
orders: map[string]gamedomain.GameOrder{},
}
}
func (r *fakeDiceRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
return r.game, nil
}
func (r *fakeDiceRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
if existing, ok := r.orders[order.OrderID]; ok {
if existing.RequestHash != order.RequestHash {
return gamedomain.GameOrder{}, true, xerr.New(xerr.IdempotencyConflict, "provider order payload conflict")
}
return existing, true, nil
}
r.orders[order.OrderID] = order
return order, false, nil
}
func (r *fakeDiceRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
order := r.orders[orderID]
order.Status = gamedomain.OrderStatusSucceeded
order.WalletTransactionID = walletTransactionID
order.WalletBalanceAfter = balanceAfter
order.UpdatedAtMS = nowMs
r.orders[orderID] = order
if order.OpType == "debit" {
r.levelDebitOrders++
}
return nil
}
func (r *fakeDiceRepository) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMs int64) error {
order := r.orders[orderID]
order.Status = status
order.FailureCode = code
order.FailureMessage = message
order.UpdatedAtMS = nowMs
r.orders[orderID] = order
return nil
}
func (r *fakeDiceRepository) CreateDiceMatch(_ context.Context, match dicedomain.Match) (dicedomain.Match, error) {
r.match = cloneDiceMatch(match)
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) JoinDiceMatch(_ context.Context, _ string, _ string, userID int64, nowMs int64) (dicedomain.Match, error) {
for _, participant := range r.match.Participants {
if participant.UserID == userID {
return cloneDiceMatch(r.match), nil
}
}
seatNo := int32(len(r.match.Participants) + 1)
r.match.Participants = append(r.match.Participants, dicedomain.Participant{
AppCode: r.match.AppCode,
MatchID: r.match.MatchID,
UserID: userID,
SeatNo: seatNo,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: r.match.StakeCoin,
JoinedAtMS: nowMs,
UpdatedAtMS: nowMs,
})
r.match.CurrentPlayers = int32(len(r.match.Participants))
r.match.Status = dicedomain.MatchStatusJoining
r.match.UpdatedAtMS = nowMs
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) GetDiceMatch(context.Context, string, string) (dicedomain.Match, error) {
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) ClaimDiceMatchForRoll(_ context.Context, _ string, _ string, userID int64, nowMs int64) (dicedomain.Match, error) {
if !hasFakeDiceParticipant(r.match.Participants, userID) {
return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
}
if len(r.match.Participants) < int(r.match.MinPlayers) {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match does not have enough participants")
}
if r.match.Result == dicedomain.ParticipantResultDraw {
r.match.Result = ""
for index := range r.match.Participants {
r.match.Participants[index].DicePoints = nil
r.match.Participants[index].Result = ""
r.match.Participants[index].PayoutCoin = 0
r.match.Participants[index].UpdatedAtMS = nowMs
}
}
r.match.Status = dicedomain.MatchStatusSettling
r.match.UpdatedAtMS = nowMs
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) SaveDiceRolls(_ context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
for _, source := range match.Participants {
for index := range r.match.Participants {
if r.match.Participants[index].UserID == source.UserID {
r.match.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...)
r.match.Participants[index].Result = source.Result
r.match.Participants[index].PayoutCoin = source.PayoutCoin
r.match.Participants[index].UpdatedAtMS = nowMs
}
}
}
r.match.Status = dicedomain.MatchStatusPayoutApplying
r.match.Result = match.Result
r.match.UpdatedAtMS = nowMs
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) SaveDiceDrawForReroll(_ context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
for _, source := range match.Participants {
for index := range r.match.Participants {
if r.match.Participants[index].UserID == source.UserID {
r.match.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...)
r.match.Participants[index].Result = dicedomain.ParticipantResultDraw
r.match.Participants[index].PayoutCoin = 0
r.match.Participants[index].UpdatedAtMS = nowMs
}
}
}
r.match.Status = dicedomain.MatchStatusReady
r.match.Result = dicedomain.ParticipantResultDraw
r.match.RoundNo++
r.match.UpdatedAtMS = nowMs
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) MarkDiceParticipantDebitSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
participant.Status = dicedomain.ParticipantStatusDebitSucceeded
participant.DebitOrderID = orderID
participant.BalanceAfter = balanceAfter
participant.UpdatedAtMS = nowMs
})
}
func (r *fakeDiceRepository) MarkDiceParticipantDebitFailed(_ context.Context, _ string, _ string, userID int64, orderID string, nowMs int64) error {
return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
participant.Status = dicedomain.ParticipantStatusDebitFailed
participant.DebitOrderID = orderID
participant.UpdatedAtMS = nowMs
})
}
func (r *fakeDiceRepository) MarkDiceParticipantPayoutSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
participant.Status = dicedomain.ParticipantStatusPayoutSucceeded
participant.PayoutOrderID = orderID
participant.BalanceAfter = balanceAfter
participant.UpdatedAtMS = nowMs
})
}
func (r *fakeDiceRepository) MarkDiceParticipantRefundSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
participant.Status = dicedomain.ParticipantStatusRefundSucceeded
participant.RefundOrderID = orderID
participant.BalanceAfter = balanceAfter
participant.UpdatedAtMS = nowMs
})
}
func (r *fakeDiceRepository) MarkDiceMatchSettled(_ context.Context, _ string, _ string, result string, nowMs int64) error {
r.match.Status = dicedomain.MatchStatusSettled
r.match.Result = result
r.match.UpdatedAtMS = nowMs
r.match.SettledAtMS = nowMs
for index := range r.match.Participants {
r.match.Participants[index].Status = dicedomain.ParticipantStatusSettled
r.match.Participants[index].UpdatedAtMS = nowMs
}
return nil
}
func (r *fakeDiceRepository) MarkDiceMatchFailed(_ context.Context, _ string, _ string, result string, nowMs int64) error {
r.match.Status = dicedomain.MatchStatusFailed
r.match.Result = result
r.match.UpdatedAtMS = nowMs
return nil
}
func (r *fakeDiceRepository) GetDiceConfig(context.Context, string, string) (dicedomain.Config, error) {
return r.config, nil
}
func (r *fakeDiceRepository) ListSelfGameConfigs(context.Context, string) ([]dicedomain.Config, error) {
return []dicedomain.Config{r.config}, nil
}
func (r *fakeDiceRepository) UpsertDiceConfig(_ context.Context, config dicedomain.Config, _ int64) (dicedomain.Config, error) {
r.config = config
return config, nil
}
func (r *fakeDiceRepository) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, int64) (dicedomain.Match, error) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found")
}
func (r *fakeDiceRepository) JoinDiceRobot(context.Context, string, string, int64, string, int64) (dicedomain.Match, error) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found")
}
func (r *fakeDiceRepository) CancelDiceMatch(context.Context, string, string, int64, int64) (dicedomain.Match, error) {
r.match.Status = dicedomain.MatchStatusCanceled
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) PickActiveDiceRobot(context.Context, string, string, string) (dicedomain.Robot, error) {
return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
}
func (r *fakeDiceRepository) AdjustDicePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) {
if adjustment.Direction == dicedomain.PoolDirectionOut {
r.poolBalance -= adjustment.AmountCoin
} else {
r.poolBalance += adjustment.AmountCoin
}
adjustment.BalanceAfter = r.poolBalance
return adjustment, nil
}
func (r *fakeDiceRepository) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) {
return nil, "", nil
}
func (r *fakeDiceRepository) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) {
return nil, nil
}
func (r *fakeDiceRepository) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) {
return dicedomain.Robot{}, nil
}
func (r *fakeDiceRepository) DeleteDiceRobot(context.Context, string, string, int64) error {
return nil
}
func (r *fakeDiceRepository) updateParticipant(userID int64, apply func(*dicedomain.Participant)) error {
for index := range r.match.Participants {
if r.match.Participants[index].UserID == userID {
apply(&r.match.Participants[index])
return nil
}
}
return xerr.New(xerr.NotFound, "dice participant not found")
}
type fakeDiceWallet struct {
balances map[int64]int64
calls int
}
func (w *fakeDiceWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
w.calls++
if req.GetCoinAmount() <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
}
switch req.GetOpType() {
case "debit":
if w.balances[req.GetUserId()] < req.GetCoinAmount() {
return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
w.balances[req.GetUserId()] -= req.GetCoinAmount()
case "credit", "refund":
w.balances[req.GetUserId()] += req.GetCoinAmount()
default:
return nil, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
}
return &walletv1.ApplyGameCoinChangeResponse{
WalletTransactionId: "wtx_" + strings.ReplaceAll(req.GetProviderOrderId(), ":", "_"),
BalanceAfter: w.balances[req.GetUserId()],
}, nil
}
func (w *fakeDiceWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{
AssetType: "COIN",
AvailableAmount: w.balances[req.GetUserId()],
}}}, nil
}
func participantByUser(participants []dicedomain.Participant, userID int64) dicedomain.Participant {
for _, participant := range participants {
if participant.UserID == userID {
return participant
}
}
return dicedomain.Participant{}
}
func hasFakeDiceParticipant(participants []dicedomain.Participant, userID int64) bool {
return participantByUser(participants, userID).UserID == userID
}
func cloneDiceMatch(match dicedomain.Match) dicedomain.Match {
cloned := match
cloned.Participants = append([]dicedomain.Participant(nil), match.Participants...)
for index := range cloned.Participants {
cloned.Participants[index].DicePoints = append([]int32(nil), match.Participants[index].DicePoints...)
}
return cloned
}

View File

@ -0,0 +1,571 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
)
// GetDiceConfig 读取自研骰子配置并带上奖池余额;配置表和奖池表分离,避免频繁结算更新污染配置更新时间。
func (r *Repository) GetDiceConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
query := `SELECT c.app_code, c.game_id, c.status, COALESCE(CAST(c.stake_options_json AS CHAR), '[]'),
c.fee_bps, c.pool_bps, c.min_players, c.max_players, c.robot_enabled,
c.robot_match_wait_ms, c.created_at_ms, c.updated_at_ms,
COALESCE(p.balance_coin, 0)
FROM game_self_game_configs c
LEFT JOIN game_self_game_pools p ON p.app_code = c.app_code AND p.game_id = c.game_id
WHERE c.app_code = ? AND c.game_id = ?`
var config dicedomain.Config
var optionsJSON string
if err := r.db.QueryRowContext(ctx, query, app, gameID).Scan(
&config.AppCode, &config.GameID, &config.Status, &optionsJSON,
&config.FeeBPS, &config.PoolBPS, &config.MinPlayers, &config.MaxPlayers, &config.RobotEnabled,
&config.RobotMatchWaitMS, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Config{}, xerr.New(xerr.NotFound, "dice config not found")
}
return dicedomain.Config{}, err
}
config.StakeOptions = dicedomain.ParseStakeOptions(optionsJSON)
return dicedomain.NormalizeConfig(config)
}
// ListSelfGameConfigs 当前只有 dice 一个自研游戏;仍返回数组,方便后台后续追加更多自研游戏。
func (r *Repository) ListSelfGameConfigs(ctx context.Context, appCode string) ([]dicedomain.Config, error) {
app := appcode.Normalize(appCode)
rows, err := r.db.QueryContext(ctx,
`SELECT c.app_code, c.game_id, c.status, COALESCE(CAST(c.stake_options_json AS CHAR), '[]'),
c.fee_bps, c.pool_bps, c.min_players, c.max_players, c.robot_enabled,
c.robot_match_wait_ms, c.created_at_ms, c.updated_at_ms, COALESCE(p.balance_coin, 0)
FROM game_self_game_configs c
LEFT JOIN game_self_game_pools p ON p.app_code = c.app_code AND p.game_id = c.game_id
WHERE c.app_code = ?
ORDER BY c.game_id ASC`,
app,
)
if err != nil {
return nil, err
}
defer rows.Close()
configs := []dicedomain.Config{}
for rows.Next() {
var config dicedomain.Config
var optionsJSON string
if err := rows.Scan(
&config.AppCode, &config.GameID, &config.Status, &optionsJSON,
&config.FeeBPS, &config.PoolBPS, &config.MinPlayers, &config.MaxPlayers, &config.RobotEnabled,
&config.RobotMatchWaitMS, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin,
); err != nil {
return nil, err
}
config.StakeOptions = dicedomain.ParseStakeOptions(optionsJSON)
normalized, err := dicedomain.NormalizeConfig(config)
if err != nil {
return nil, err
}
configs = append(configs, normalized)
}
return configs, rows.Err()
}
// UpsertDiceConfig 保存后台可热更新配置,并确保对应奖池行存在;具体余额变更必须走 AdjustDicePool。
func (r *Repository) UpsertDiceConfig(ctx context.Context, config dicedomain.Config, nowMs int64) (dicedomain.Config, error) {
normalized, err := dicedomain.NormalizeConfig(config)
if err != nil {
return dicedomain.Config{}, err
}
normalized.AppCode = appcode.Normalize(normalized.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Config{}, err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_self_game_configs (
app_code, game_id, status, stake_options_json, fee_bps, pool_bps,
min_players, max_players, robot_enabled, robot_match_wait_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
stake_options_json = VALUES(stake_options_json),
fee_bps = VALUES(fee_bps),
pool_bps = VALUES(pool_bps),
min_players = VALUES(min_players),
max_players = VALUES(max_players),
robot_enabled = VALUES(robot_enabled),
robot_match_wait_ms = VALUES(robot_match_wait_ms),
updated_at_ms = VALUES(updated_at_ms)`,
normalized.AppCode, normalized.GameID, normalized.Status, dicedomain.StakeOptionsJSON(normalized.StakeOptions),
normalized.FeeBPS, normalized.PoolBPS, normalized.MinPlayers, normalized.MaxPlayers, normalized.RobotEnabled,
normalized.RobotMatchWaitMS, nowMs, nowMs,
); err != nil {
return dicedomain.Config{}, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_self_game_pools (app_code, game_id, balance_coin, created_at_ms, updated_at_ms)
VALUES (?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
normalized.AppCode, normalized.GameID, nowMs, nowMs,
); err != nil {
return dicedomain.Config{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Config{}, err
}
return r.GetDiceConfig(ctx, normalized.AppCode, normalized.GameID)
}
// FindAndJoinWaitingDiceMatch 在全服等待队列里原子抢一个真人局;没有命中时返回 NotFound让 service 创建新局。
func (r *Repository) FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
var matchID string
err = tx.QueryRowContext(ctx,
`SELECT m.match_id
FROM game_dice_matches m
WHERE m.app_code = ?
AND m.game_id = ?
AND m.room_id = ''
AND m.stake_coin = ?
AND m.status IN (?, ?)
AND m.current_players < m.max_players
AND (m.join_deadline_ms = 0 OR m.join_deadline_ms >= ?)
AND (m.match_mode = '' OR m.match_mode = ?)
AND NOT EXISTS (
SELECT 1 FROM game_dice_participants p
WHERE p.app_code = m.app_code AND p.match_id = m.match_id AND p.user_id = ?
)
ORDER BY m.created_at_ms ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`,
app, gameID, stakeCoin, dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining, nowMs, dicedomain.MatchModeHuman, userID,
).Scan(&matchID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found")
}
return dicedomain.Match{}, err
}
match, err := queryDiceMatch(ctx, tx, app, matchID, true)
if err != nil {
return dicedomain.Match{}, err
}
if match.MatchMode == "" {
match.MatchMode = dicedomain.MatchModeHuman
}
joined, err := insertDiceParticipantLocked(ctx, tx, match, userID, dicedomain.ParticipantTypeUser, nowMs)
if err != nil {
return dicedomain.Match{}, err
}
if err := updateDiceMatchAfterJoinLocked(ctx, tx, joined, match.MatchMode, match.ForcedResult, nowMs); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return r.GetDiceMatch(ctx, app, matchID)
}
// JoinDiceRobot 把一个已登记真实 App 用户作为机器人补位;机器人只参与局事实,不触发钱包扣款。
func (r *Repository) JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
if err != nil {
return dicedomain.Match{}, err
}
if !diceMatchAllowsJoin(match.Status) {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not joinable")
}
if match.CurrentPlayers >= match.MaxPlayers {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is full")
}
joined, err := insertDiceParticipantLocked(ctx, tx, match, robotUserID, dicedomain.ParticipantTypeRobot, nowMs)
if err != nil {
return dicedomain.Match{}, err
}
if err := updateDiceMatchAfterJoinLocked(ctx, tx, joined, dicedomain.MatchModeRobot, strings.TrimSpace(forcedResult), nowMs); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return r.GetDiceMatch(ctx, app, match.MatchID)
}
// CancelDiceMatch 只取消还未成局的等待局;已匹配成功后必须走正常结算或失败补偿。
func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
if err != nil {
return dicedomain.Match{}, err
}
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
if err != nil {
return dicedomain.Match{}, err
}
if !diceMatchHasParticipant(participants, userID) {
return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
}
if match.Status != dicedomain.MatchStatusCreated && match.Status != dicedomain.MatchStatusJoining {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not cancelable")
}
if len(participants) > 1 {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match already has opponent")
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, canceled_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusCanceled, nowMs, nowMs, app, match.MatchID,
); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return r.GetDiceMatch(ctx, app, match.MatchID)
}
// PickActiveDiceRobot 选择一个未在本局中的可用机器人;首版用更新时间排序,后续可替换成权重或 Redis 活跃池。
func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string) (dicedomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
var robot dicedomain.Robot
err := r.db.QueryRowContext(ctx,
`SELECT r.app_code, r.game_id, r.user_id, r.status, r.created_by_admin_id, r.created_at_ms, r.updated_at_ms
FROM game_self_game_robots r
WHERE r.app_code = ? AND r.game_id = ? AND r.status = ?
AND NOT EXISTS (
SELECT 1 FROM game_dice_participants p
WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id
)
ORDER BY r.updated_at_ms ASC, r.user_id ASC
LIMIT 1`,
app, gameID, dicedomain.RobotStatusActive, strings.TrimSpace(matchID),
).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
}
return dicedomain.Robot{}, err
}
return robot, nil
}
// AdjustDicePool 用一把奖池行锁串行更新余额并写流水,防止多个机器人局并发时把余额扣成负数。
func (r *Repository) AdjustDicePool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) {
if adjustment.AmountCoin <= 0 {
return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "dice pool amount is invalid")
}
adjustment.AppCode = appcode.Normalize(adjustment.AppCode)
adjustment.GameID = normalizeDiceGameID(adjustment.GameID)
adjustment.Direction = strings.TrimSpace(adjustment.Direction)
if adjustment.Direction != dicedomain.PoolDirectionIn && adjustment.Direction != dicedomain.PoolDirectionOut {
return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "dice pool direction is invalid")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.PoolAdjustment{}, err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_self_game_pools (app_code, game_id, balance_coin, created_at_ms, updated_at_ms)
VALUES (?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
adjustment.AppCode, adjustment.GameID, adjustment.CreatedAtMS, adjustment.CreatedAtMS,
); err != nil {
return dicedomain.PoolAdjustment{}, err
}
var balance int64
if err := tx.QueryRowContext(ctx,
`SELECT balance_coin FROM game_self_game_pools WHERE app_code = ? AND game_id = ? FOR UPDATE`,
adjustment.AppCode, adjustment.GameID,
).Scan(&balance); err != nil {
return dicedomain.PoolAdjustment{}, err
}
next := balance + adjustment.AmountCoin
if adjustment.Direction == dicedomain.PoolDirectionOut {
next = balance - adjustment.AmountCoin
if next < 0 {
return dicedomain.PoolAdjustment{}, xerr.New(xerr.Conflict, "dice pool balance is insufficient")
}
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_self_game_pools SET balance_coin = ?, updated_at_ms = ? WHERE app_code = ? AND game_id = ?`,
next, adjustment.CreatedAtMS, adjustment.AppCode, adjustment.GameID,
); err != nil {
return dicedomain.PoolAdjustment{}, err
}
adjustment.BalanceAfter = next
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_self_game_pool_transactions (
app_code, adjustment_id, game_id, match_id, user_id, direction, amount_coin,
reason, balance_after, created_by, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
adjustment.AppCode, adjustment.AdjustmentID, adjustment.GameID, adjustment.MatchID, adjustment.UserID,
adjustment.Direction, adjustment.AmountCoin, adjustment.Reason, adjustment.BalanceAfter, adjustment.CreatedBy, adjustment.CreatedAtMS,
); err != nil {
return dicedomain.PoolAdjustment{}, err
}
if adjustment.MatchID != "" {
delta := adjustment.AmountCoin
if adjustment.Direction == dicedomain.PoolDirectionOut {
delta = -delta
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET pool_delta_coin = pool_delta_coin + ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
delta, adjustment.CreatedAtMS, adjustment.AppCode, adjustment.MatchID,
); err != nil {
return dicedomain.PoolAdjustment{}, err
}
}
if err := tx.Commit(); err != nil {
return dicedomain.PoolAdjustment{}, err
}
return adjustment, nil
}
func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
status = strings.TrimSpace(status)
cursorID := int64(0)
if strings.TrimSpace(cursor) != "" {
_, _ = fmt.Sscan(strings.TrimSpace(cursor), &cursorID)
}
args := []any{app, gameID}
query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
FROM game_self_game_robots
WHERE app_code = ? AND game_id = ?`
if status != "" {
query += ` AND status = ?`
args = append(args, status)
}
if cursorID > 0 {
query += ` AND user_id > ?`
args = append(args, cursorID)
}
query += ` ORDER BY user_id ASC LIMIT ?`
args = append(args, int(pageSize)+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
robots := []dicedomain.Robot{}
for rows.Next() {
var robot dicedomain.Robot
if err := rows.Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS); err != nil {
return nil, "", err
}
robots = append(robots, robot)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
nextCursor := ""
if len(robots) > int(pageSize) {
nextCursor = fmt.Sprintf("%d", robots[pageSize-1].UserID)
robots = robots[:pageSize]
}
return robots, nextCursor, nil
}
func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
for _, userID := range uniquePositiveInt64(userIDs) {
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_self_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`,
app, gameID, userID, dicedomain.RobotStatusActive, adminID, nowMs, nowMs,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
robots, _, err := r.ListDiceRobots(ctx, app, gameID, "", 200, "")
return robots, err
}
func (r *Repository) SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
status = strings.TrimSpace(status)
if status != dicedomain.RobotStatusActive && status != dicedomain.RobotStatusDisabled {
return dicedomain.Robot{}, xerr.New(xerr.InvalidArgument, "dice robot status is invalid")
}
res, err := r.db.ExecContext(ctx,
`UPDATE game_self_game_robots
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND game_id = ? AND user_id = ?`,
status, nowMs, app, gameID, userID,
)
if err != nil {
return dicedomain.Robot{}, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
}
var robot dicedomain.Robot
err = r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
FROM game_self_game_robots
WHERE app_code = ? AND game_id = ? AND user_id = ?`,
app, gameID, userID,
).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS)
return robot, err
}
// DeleteDiceRobot 只删除机器人登记关系,不删除 user-service 主账号,避免历史对局和钱包流水失去用户归属。
func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error {
app := appcode.Normalize(appCode)
gameID = normalizeDiceGameID(gameID)
res, err := r.db.ExecContext(ctx,
`DELETE FROM game_self_game_robots
WHERE app_code = ? AND game_id = ? AND user_id = ?`,
app, gameID, userID,
)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return xerr.New(xerr.NotFound, "dice robot not found")
}
return nil
}
func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, userID int64, participantType string, nowMs int64) (dicedomain.Match, error) {
var seatNo int32
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(seat_no), 0) + 1
FROM game_dice_participants
WHERE app_code = ? AND match_id = ?`,
match.AppCode, match.MatchID,
).Scan(&seatNo); err != nil {
return dicedomain.Match{}, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_dice_participants (
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, result,
payout_coin, debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), '', 0, '', '', '', 0, ?, ?)`,
match.AppCode, match.MatchID, userID, normalizeParticipantType(participantType), seatNo,
dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", nowMs, nowMs,
); err != nil {
return dicedomain.Match{}, err
}
match.CurrentPlayers++
if match.Participants == nil {
match.Participants = []dicedomain.Participant{}
}
match.Participants = append(match.Participants, dicedomain.Participant{
AppCode: match.AppCode,
MatchID: match.MatchID,
UserID: userID,
ParticipantType: normalizeParticipantType(participantType),
SeatNo: seatNo,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: match.StakeCoin,
JoinedAtMS: nowMs,
UpdatedAtMS: nowMs,
})
return match, nil
}
func updateDiceMatchAfterJoinLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, matchMode string, forcedResult string, nowMs int64) error {
nextStatus := dicedomain.MatchStatusJoining
readyAtMS := match.ReadyAtMS
if match.CurrentPlayers >= match.MinPlayers {
nextStatus = dicedomain.MatchStatusReady
if readyAtMS == 0 {
readyAtMS = nowMs
}
}
_, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET current_players = ?, status = ?, ready_at_ms = ?, match_mode = ?, forced_result = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
match.CurrentPlayers, nextStatus, readyAtMS, strings.TrimSpace(matchMode), strings.TrimSpace(forcedResult), nowMs,
match.AppCode, match.MatchID,
)
return err
}
func normalizeDiceGameID(gameID string) string {
gameID = strings.TrimSpace(gameID)
if gameID == "" {
return dicedomain.DefaultGameID
}
return gameID
}
func normalizeParticipantType(value string) string {
if strings.TrimSpace(value) == dicedomain.ParticipantTypeRobot {
return dicedomain.ParticipantTypeRobot
}
return dicedomain.ParticipantTypeUser
}
func uniquePositiveInt64(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}

View File

@ -0,0 +1,510 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
)
type diceQuerier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// CreateDiceMatch 在同一个事务内写入 match 和首个参与者,保证创建后不会出现空局。
func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
match.AppCode = appcode.Normalize(match.AppCode)
// match 和首个 participant 必须同事务提交;否则创建成功但参与者缺失会导致后续 roll 永远人数不足。
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_dice_matches (
app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID,
match.MinPlayers, match.MaxPlayers, match.CurrentPlayers, match.StakeCoin, match.RoundNo, match.Status, match.Result,
match.JoinDeadlineMS, match.ReadyAtMS, match.CreatedAtMS, match.UpdatedAtMS, match.SettledAtMS, match.CanceledAtMS,
match.FeeBPS, match.PoolBPS, match.MatchMode, match.ForcedResult, match.PoolDeltaCoin,
); err != nil {
return dicedomain.Match{}, err
}
for _, participant := range match.Participants {
// dice_points_json 创建阶段固定写 [],后续 SaveDiceRolls 才允许写真实点数。
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_dice_participants (
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, result,
payout_coin, debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?)`,
match.AppCode, match.MatchID, participant.UserID, normalizeParticipantType(participant.ParticipantType), participant.SeatNo, participant.Status, participant.StakeCoin,
dicePointsJSON(participant.DicePoints), participant.Result, participant.PayoutCoin, participant.DebitOrderID,
participant.PayoutOrderID, participant.RefundOrderID, participant.BalanceAfter, participant.JoinedAtMS, participant.UpdatedAtMS,
); err != nil {
return dicedomain.Match{}, err
}
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
// 提交后重新读取,返回值使用数据库里的排序、默认值和 JSON 解析结果,不信任入参原对象。
return r.GetDiceMatch(ctx, match.AppCode, match.MatchID)
}
// JoinDiceMatch 只在 match 行锁内分配座位和增加人数;外部钱包调用不会持有这把锁。
func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
// 锁 match 主行后再检查状态和人数,保证并发 join 时只有一个事务能看到并更新同一份 current_players。
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
if err != nil {
return dicedomain.Match{}, err
}
if !diceMatchAllowsJoin(match.Status) {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not joinable")
}
if match.JoinDeadlineMS > 0 && nowMs > match.JoinDeadlineMS {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match join window is closed")
}
var existing int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM game_dice_participants
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
app, match.MatchID, userID,
).Scan(&existing); err != nil {
return dicedomain.Match{}, err
}
if existing > 0 {
// 重复点击 join 直接返回当前局快照,不制造第二个座位,也不把重复请求当错误打断客户端刷新。
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return r.GetDiceMatch(ctx, app, match.MatchID)
}
if match.CurrentPlayers >= match.MaxPlayers {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is full")
}
var seatNo int32
// seat_no 在同一把 match 锁下用 max+1 分配;配合唯一索引防止未来修改锁粒度后出现重复座位。
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(seat_no), 0) + 1
FROM game_dice_participants
WHERE app_code = ? AND match_id = ?`,
app, match.MatchID,
).Scan(&seatNo); err != nil {
return dicedomain.Match{}, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_dice_participants (
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, result,
payout_coin, debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), '', 0, '', '', '', 0, ?, ?)`,
app, match.MatchID, userID, dicedomain.ParticipantTypeUser, seatNo, dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", nowMs, nowMs,
); err != nil {
return dicedomain.Match{}, err
}
nextPlayers := match.CurrentPlayers + 1
nextStatus := dicedomain.MatchStatusJoining
readyAtMS := match.ReadyAtMS
if nextPlayers >= match.MinPlayers {
nextStatus = dicedomain.MatchStatusReady
if readyAtMS == 0 {
readyAtMS = nowMs
}
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET current_players = current_players + 1, status = ?, ready_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
nextStatus, readyAtMS, nowMs, app, match.MatchID,
); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
// join 返回完整 participantsH5 可以直接刷新座位,不需要再额外查一次。
return r.GetDiceMatch(ctx, app, match.MatchID)
}
func (r *Repository) GetDiceMatch(ctx context.Context, appCode string, matchID string) (dicedomain.Match, error) {
app := appcode.Normalize(appCode)
// 查询接口不加行锁,只读取当前已提交事实;所有会改变状态的入口都有单独的锁定方法。
match, err := queryDiceMatch(ctx, r.db, app, strings.TrimSpace(matchID), false)
if err != nil {
return dicedomain.Match{}, err
}
participants, err := queryDiceParticipants(ctx, r.db, app, match.MatchID, false)
if err != nil {
return dicedomain.Match{}, err
}
match.Participants = participants
return match, nil
}
// ClaimDiceMatchForRoll 把可结算局切到 settling重复 roll 只能拿到同一局事实继续幂等补偿。
func (r *Repository) ClaimDiceMatchForRoll(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
// 先锁 match再锁 participants所有结算入口保持固定锁顺序降低死锁概率。
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
if err != nil {
return dicedomain.Match{}, err
}
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
if err != nil {
return dicedomain.Match{}, err
}
match.Participants = participants
if match.Status == dicedomain.MatchStatusSettled {
// 已结算局允许 roll 重试读取结果,但不再改变任何状态。
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return match, nil
}
if !diceMatchAllowsRoll(match.Status) {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not rollable")
}
if len(participants) < int(match.MinPlayers) {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match does not have enough participants")
}
if !diceMatchHasParticipant(participants, userID) {
// 非参与者不能替别人开奖;权限判断放在锁内,避免刚 join 的并发事务未提交时误判状态。
return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
}
if match.Result == dicedomain.ParticipantResultDraw {
// 上一轮和局只用于 H5 展示点数;再次 roll 时必须清空旧点数和旧结果,避免 SaveDiceRolls 误判为幂等重试。
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_participants
SET dice_points_json = CAST('[]' AS JSON), result = '', payout_coin = 0, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
nowMs, app, match.MatchID,
); err != nil {
return dicedomain.Match{}, err
}
for index := range match.Participants {
match.Participants[index].DicePoints = nil
match.Participants[index].Result = ""
match.Participants[index].PayoutCoin = 0
match.Participants[index].UpdatedAtMS = nowMs
}
match.Result = ""
}
// 状态推进到 settling 后立即提交,外部钱包调用不持有行锁;失败补偿靠订单幂等和状态字段继续推进。
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, result = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusSettling, match.Result, nowMs, app, match.MatchID,
); err != nil {
return dicedomain.Match{}, err
}
match.Status = dicedomain.MatchStatusSettling
match.UpdatedAtMS = nowMs
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return match, nil
}
// SaveDiceDrawForReroll 保存本轮和局点数但不推进派奖match 保持 ready让 H5 2 秒后继续调用 roll。
func (r *Repository) SaveDiceDrawForReroll(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(match.AppCode)
locked, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(match.MatchID), true)
if err != nil {
return dicedomain.Match{}, err
}
if locked.Status == dicedomain.MatchStatusSettled {
participants, err := queryDiceParticipants(ctx, tx, app, locked.MatchID, true)
if err != nil {
return dicedomain.Match{}, err
}
locked.Participants = participants
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return locked, nil
}
if locked.Status != dicedomain.MatchStatusSettling {
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not saving draw")
}
for _, participant := range match.Participants {
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_participants
SET dice_points_json = CAST(? AS JSON), result = ?, payout_coin = 0, updated_at_ms = ?
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
dicePointsJSON(participant.DicePoints), dicedomain.ParticipantResultDraw, nowMs,
app, match.MatchID, participant.UserID,
); err != nil {
return dicedomain.Match{}, err
}
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, result = ?, round_no = round_no + 1, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusReady, dicedomain.ParticipantResultDraw, nowMs, app, match.MatchID,
); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return r.GetDiceMatch(ctx, app, match.MatchID)
}
// SaveDiceRolls 保存首次随机结果;如果重试时已经有点数,直接返回旧结果,保证不会重复随机。
func (r *Repository) SaveDiceRolls(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(match.AppCode)
// 保存点数前再次锁局和参与者,防止两个补偿 worker 同时为同一局写入不同随机结果。
locked, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(match.MatchID), true)
if err != nil {
return dicedomain.Match{}, err
}
existing, err := queryDiceParticipants(ctx, tx, app, locked.MatchID, true)
if err != nil {
return dicedomain.Match{}, err
}
if dicedomain.HasRolls(existing) {
// 点数已经存在时只返回旧事实;这条分支是“重试不重投”的最后防线。
locked.Participants = existing
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
return locked, nil
}
for _, participant := range match.Participants {
// 点数、结果和 payout_coin 同时落参与者行,补偿任务可以只靠 participants 重建派奖命令。
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_participants
SET dice_points_json = CAST(? AS JSON), result = ?, payout_coin = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
dicePointsJSON(participant.DicePoints), participant.Result, participant.PayoutCoin, nowMs,
app, match.MatchID, participant.UserID,
); err != nil {
return dicedomain.Match{}, err
}
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, result = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusPayoutApplying, match.Result, nowMs, app, match.MatchID,
); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
// 返回提交后的完整事实,确保 service 后续派奖使用数据库中已固定的点数和 payout。
return r.GetDiceMatch(ctx, app, match.MatchID)
}
func (r *Repository) MarkDiceParticipantDebitSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusDebitSucceeded, "debit_order_id", orderID, balanceAfter, nowMs)
}
func (r *Repository) MarkDiceParticipantDebitFailed(ctx context.Context, appCode string, matchID string, userID int64, orderID string, nowMs int64) error {
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusDebitFailed, "debit_order_id", orderID, 0, nowMs)
}
func (r *Repository) MarkDiceParticipantPayoutSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusPayoutSucceeded, "payout_order_id", orderID, balanceAfter, nowMs)
}
func (r *Repository) MarkDiceParticipantRefundSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusRefundSucceeded, "refund_order_id", orderID, balanceAfter, nowMs)
}
func (r *Repository) updateDiceParticipantOrder(ctx context.Context, appCode string, matchID string, userID int64, status string, orderColumn string, orderID string, balanceAfter int64, nowMs int64) error {
// orderColumn 是 SQL 标识符,不能作为参数绑定;必须用白名单避免外部输入拼进 SQL。
switch orderColumn {
case "debit_order_id", "payout_order_id", "refund_order_id":
default:
return xerr.New(xerr.InvalidArgument, "dice participant order column is invalid")
}
_, err := r.db.ExecContext(ctx,
`UPDATE game_dice_participants
SET status = ?, `+orderColumn+` = ?, balance_after = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
status, strings.TrimSpace(orderID), balanceAfter, nowMs, appcode.Normalize(appCode), strings.TrimSpace(matchID), userID,
)
return err
}
func (r *Repository) MarkDiceMatchSettled(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
// match 和 participants 在同一事务内改为 settled客户端不会看到局已结算但参与者还停在 payout 状态。
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, result = ?, updated_at_ms = ?, settled_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusSettled, strings.TrimSpace(result), nowMs, nowMs, app, strings.TrimSpace(matchID),
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_participants
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.ParticipantStatusSettled, nowMs, app, strings.TrimSpace(matchID),
); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, result = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusFailed, strings.TrimSpace(result), nowMs, appcode.Normalize(appCode), strings.TrimSpace(matchID),
)
return err
}
func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) (dicedomain.Match, error) {
// forUpdate 只给状态推进路径使用;读路径不加锁,避免普通刷新阻塞 join/roll。
query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
FROM game_dice_matches
WHERE app_code = ? AND match_id = ?`
if forUpdate {
query += ` FOR UPDATE`
}
row := q.QueryRowContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
var match dicedomain.Match
if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
}
return dicedomain.Match{}, err
}
return match, nil
}
func queryDiceParticipants(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) ([]dicedomain.Participant, error) {
// participants 始终按座位排序HTTP 响应和补偿重放都能得到稳定顺序。
query := `SELECT app_code, match_id, user_id, participant_type, seat_no, status, stake_coin,
COALESCE(CAST(dice_points_json AS CHAR), '[]'), result, payout_coin,
debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
FROM game_dice_participants
WHERE app_code = ? AND match_id = ?
ORDER BY seat_no ASC`
if forUpdate {
query += ` FOR UPDATE`
}
rows, err := q.QueryContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
if err != nil {
return nil, err
}
defer rows.Close()
participants := []dicedomain.Participant{}
for rows.Next() {
var participant dicedomain.Participant
var pointsJSON string
if err := rows.Scan(&participant.AppCode, &participant.MatchID, &participant.UserID, &participant.ParticipantType, &participant.SeatNo, &participant.Status, &participant.StakeCoin, &pointsJSON, &participant.Result, &participant.PayoutCoin, &participant.DebitOrderID, &participant.PayoutOrderID, &participant.RefundOrderID, &participant.BalanceAfter, &participant.JoinedAtMS, &participant.UpdatedAtMS); err != nil {
return nil, err
}
participant.ParticipantType = normalizeParticipantType(participant.ParticipantType)
participant.DicePoints = parseDicePoints(pointsJSON)
participants = append(participants, participant)
}
return participants, rows.Err()
}
func diceMatchAllowsJoin(status string) bool {
// 只有未进入结算的局允许加入settling 之后人数和奖池都必须冻结。
switch status {
case dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining:
return true
default:
return false
}
}
func diceMatchAllowsRoll(status string) bool {
// settling/payout_applying 允许再次进入,是为了补偿同一局未完成的钱包步骤,不代表重新开奖。
switch status {
case dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining, dicedomain.MatchStatusReady, dicedomain.MatchStatusSettling, dicedomain.MatchStatusPayoutApplying:
return true
default:
return false
}
}
func diceMatchHasParticipant(participants []dicedomain.Participant, userID int64) bool {
for _, participant := range participants {
if participant.UserID == userID {
return true
}
}
return false
}
func dicePointsJSON(points []int32) string {
if len(points) == 0 {
return "[]"
}
raw, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(raw)
}
func parseDicePoints(raw string) []int32 {
var points []int32
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &points); err != nil {
// JSON 异常按空点数处理,让上层 Score/HasRolls 决定是否能继续结算,不在扫描阶段吞掉整行。
return nil
}
return points
}

View File

@ -160,6 +160,50 @@ func (r *Repository) Migrate(ctx context.Context) error {
KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_dice_matches (
app_code VARCHAR(32) NOT NULL,
match_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
min_players INT NOT NULL,
max_players INT NOT NULL,
current_players INT NOT NULL DEFAULT 0,
stake_coin BIGINT NOT NULL,
round_no INT NOT NULL DEFAULT 1,
status VARCHAR(32) NOT NULL,
result VARCHAR(64) NOT NULL DEFAULT '',
join_deadline_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
settled_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(app_code, match_id),
KEY idx_game_dice_room_active(app_code, room_id, status, created_at_ms),
KEY idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_dice_participants (
app_code VARCHAR(32) NOT NULL,
match_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
seat_no INT NOT NULL,
status VARCHAR(32) NOT NULL,
stake_coin BIGINT NOT NULL,
dice_points_json JSON NULL,
result VARCHAR(32) NOT NULL DEFAULT '',
payout_coin BIGINT NOT NULL DEFAULT 0,
debit_order_id VARCHAR(96) NOT NULL DEFAULT '',
payout_order_id VARCHAR(96) NOT NULL DEFAULT '',
refund_order_id VARCHAR(96) NOT NULL DEFAULT '',
balance_after BIGINT NOT NULL DEFAULT 0,
joined_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, match_id, user_id),
UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no),
KEY idx_game_dice_participant_user(app_code, user_id, updated_at_ms),
KEY idx_game_dice_participant_status(app_code, match_id, status, seat_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL,
callback_id VARCHAR(96) NOT NULL,
@ -296,6 +340,21 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_matches", "idx_game_dice_room_active", []string{"app_code", "room_id", "status", "created_at_ms"}, "ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_room_active(app_code, room_id, status, created_at_ms)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_matches", "idx_game_dice_status_time", []string{"app_code", "status", "updated_at_ms", "match_id"}, "ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "uk_game_dice_seat", []string{"app_code", "match_id", "seat_no"}, "ALTER TABLE game_dice_participants ADD UNIQUE INDEX uk_game_dice_seat(app_code, match_id, seat_no)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_user", []string{"app_code", "user_id", "updated_at_ms"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_user(app_code, user_id, updated_at_ms)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_status", []string{"app_code", "match_id", "status", "seat_no"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_status(app_code, match_id, status, seat_no)"); err != nil {
return err
}
return nil
}

View File

@ -8,7 +8,9 @@ import (
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
gamedomain "hyapp/services/game-service/internal/domain/game"
diceservice "hyapp/services/game-service/internal/service/dice"
gameservice "hyapp/services/game-service/internal/service/game"
)
@ -19,11 +21,17 @@ type Server struct {
gamev1.UnimplementedGameAdminServiceServer
gamev1.UnimplementedGameCronServiceServer
svc *gameservice.Service
svc *gameservice.Service
diceSvc *diceservice.Service
}
func NewServer(svc *gameservice.Service) *Server {
return &Server{svc: svc}
func NewServer(svc *gameservice.Service, diceServices ...*diceservice.Service) *Server {
// diceSvc 用可选参数接入,保留旧测试和非骰子场景的构造方式;生产 app 会显式传入骰子服务。
var diceSvc *diceservice.Service
if len(diceServices) > 0 {
diceSvc = diceServices[0]
}
return &Server{svc: svc, diceSvc: diceSvc}
}
func (s *Server) ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
@ -110,6 +118,131 @@ func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest)
}, nil
}
func (s *Server) GetDiceConfig(ctx context.Context, req *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, serverTimeMS, err := s.diceSvc.GetConfig(ctx, req.GetMeta().GetAppCode(), req.GetGameId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceConfigResponse{Config: diceConfigToProto(config), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
match, serverTimeMS, err := s.diceSvc.Match(ctx, diceservice.MatchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
GameID: req.GetGameId(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
StakeCoin: req.GetStakeCoin(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
// gRPC 层只把 proto 字段转成 command人数、金额、游戏状态和钱包逻辑都留给 service 层验证。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
match, serverTimeMS, err := s.diceSvc.CreateMatch(ctx, diceservice.CreateMatchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
GameID: req.GetGameId(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
StakeCoin: req.GetStakeCoin(),
MinPlayers: req.GetMinPlayers(),
MaxPlayers: req.GetMaxPlayers(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) JoinDiceMatch(ctx context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
// user_id 由 gateway 从登录态注入transport 不允许用 actor_user_id 推导业务用户,避免调用方混淆身份字段。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
match, serverTimeMS, err := s.diceSvc.JoinMatch(ctx, diceservice.JoinMatchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
// 查询接口仍要求 user_id后续如果要做观战/隐私控制,可以在 service 层基于同一 command 加规则。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
match, serverTimeMS, err := s.diceSvc.GetMatch(ctx, diceservice.GetMatchCommand{
AppCode: req.GetMeta().GetAppCode(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
// roll 是唯一会触发钱包改账的骰子 RPCgRPC 层不拆分扣款/派奖,避免客户端绕过局状态机。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
match, serverTimeMS, err := s.diceSvc.RollMatch(ctx, diceservice.RollMatchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
match, serverTimeMS, err := s.diceSvc.CancelMatch(ctx, diceservice.CancelMatchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
raw, contentType, err := s.svc.HandleCallback(ctx, req)
@ -218,6 +351,112 @@ func (s *Server) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogReq
return &gamev1.DeleteCatalogResponse{ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, serverTimeMS, err := s.diceSvc.ListSelfGames(ctx, req.GetMeta().GetAppCode())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListSelfGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.DiceConfig, 0, len(items))}
for _, item := range items {
resp.Games = append(resp.Games, diceConfigToProto(item))
}
return resp, nil
}
func (s *Server) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config := diceConfigFromProto(req.GetConfig())
config.AppCode = req.GetMeta().GetAppCode()
saved, serverTimeMS, err := s.diceSvc.UpdateConfig(ctx, config)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceConfigResponse{Config: diceConfigToProto(saved), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
adjustment, config, serverTimeMS, err := s.diceSvc.AdjustPool(ctx, dicedomain.PoolAdjustment{
AppCode: req.GetMeta().GetAppCode(),
GameID: req.GetGameId(),
Direction: req.GetDirection(),
AmountCoin: req.GetAmountCoin(),
Reason: req.GetReason(),
CreatedBy: req.GetMeta().GetActorUserId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.AdjustDicePoolResponse{Adjustment: dicePoolAdjustmentToProto(adjustment), Config: diceConfigToProto(config), ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, next, serverTimeMS, err := s.diceSvc.ListRobots(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetStatus(), req.GetPageSize(), req.GetCursor())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListDiceRobotsResponse{NextCursor: next, ServerTimeMs: serverTimeMS, Robots: make([]*gamev1.DiceRobot, 0, len(items))}
for _, item := range items {
resp.Robots = append(resp.Robots, diceRobotToProto(item))
}
return resp, nil
}
func (s *Server) RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, serverTimeMS, err := s.diceSvc.RegisterRobots(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserIds(), req.GetMeta().GetActorUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.RegisterDiceRobotsResponse{ServerTimeMs: serverTimeMS, Robots: make([]*gamev1.DiceRobot, 0, len(items))}
for _, item := range items {
resp.Robots = append(resp.Robots, diceRobotToProto(item))
}
return resp, nil
}
func (s *Server) SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, serverTimeMS, err := s.diceSvc.SetRobotStatus(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserId(), req.GetStatus())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DiceRobotResponse{Robot: diceRobotToProto(item), ServerTimeMs: serverTimeMS}, nil
}
// DeleteDiceRobot 只对管理端暴露机器人池删除能力,删除范围由 app_code、game_id、user_id 三元组限定。
func (s *Server) DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
}
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
serverTimeMS, err := s.diceSvc.DeleteRobot(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DeleteDiceRobotResponse{Deleted: true, ServerTimeMs: serverTimeMS}, nil
}
func (s *Server) svcRepository() gameservice.Repository {
if s == nil || s.svc == nil || s.svc.Repository() == nil {
return unavailableRepository{}
@ -313,6 +552,161 @@ func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame {
}
}
func diceMatchToProto(match dicedomain.Match) *gamev1.DiceMatch {
return diceMatchToProtoAt(match, time.Now().UnixMilli())
}
func diceMatchToProtoAt(match dicedomain.Match, nowMS int64) *gamev1.DiceMatch {
participants := make([]*gamev1.DiceParticipant, 0, len(match.Participants))
for _, participant := range match.Participants {
participants = append(participants, diceParticipantToProto(participant))
}
phase, phaseDeadlineMS := dicePhase(match, nowMS)
// proto 只暴露客户端需要的局事实;订单 ID 和 provider 快照留在 MySQL避免把账务内部键泄漏到 H5。
return &gamev1.DiceMatch{
AppCode: match.AppCode,
MatchId: match.MatchID,
GameId: match.GameID,
RoomId: match.RoomID,
RegionId: match.RegionID,
MinPlayers: match.MinPlayers,
MaxPlayers: match.MaxPlayers,
CurrentPlayers: match.CurrentPlayers,
StakeCoin: match.StakeCoin,
RoundNo: match.RoundNo,
Status: match.Status,
Result: match.Result,
Participants: participants,
JoinDeadlineMs: match.JoinDeadlineMS,
CreatedAtMs: match.CreatedAtMS,
UpdatedAtMs: match.UpdatedAtMS,
SettledAtMs: match.SettledAtMS,
Phase: phase,
PhaseDeadlineMs: phaseDeadlineMS,
FeeBps: match.FeeBPS,
PoolBps: match.PoolBPS,
MatchMode: match.MatchMode,
ForcedResult: match.ForcedResult,
ReadyAtMs: match.ReadyAtMS,
CanceledAtMs: match.CanceledAtMS,
PoolDeltaCoin: match.PoolDeltaCoin,
}
}
func diceParticipantToProto(participant dicedomain.Participant) *gamev1.DiceParticipant {
// balance_after 是最近一次钱包操作后的快照,供 H5 结算弹层刷新余额;不是局内可计算字段。
return &gamev1.DiceParticipant{
UserId: participant.UserID,
SeatNo: participant.SeatNo,
Status: participant.Status,
StakeCoin: participant.StakeCoin,
DicePoints: participant.DicePoints,
Result: participant.Result,
PayoutCoin: participant.PayoutCoin,
BalanceAfter: participant.BalanceAfter,
JoinedAtMs: participant.JoinedAtMS,
UpdatedAtMs: participant.UpdatedAtMS,
ParticipantType: participant.ParticipantType,
IsRobot: participant.ParticipantType == dicedomain.ParticipantTypeRobot,
}
}
func diceConfigToProto(config dicedomain.Config) *gamev1.DiceConfig {
options := make([]*gamev1.DiceStakeOption, 0, len(config.StakeOptions))
for _, option := range config.StakeOptions {
options = append(options, &gamev1.DiceStakeOption{StakeCoin: option.StakeCoin, Enabled: option.Enabled, SortOrder: option.SortOrder})
}
return &gamev1.DiceConfig{
AppCode: config.AppCode,
GameId: config.GameID,
Status: config.Status,
StakeOptions: options,
FeeBps: config.FeeBPS,
PoolBps: config.PoolBPS,
MinPlayers: config.MinPlayers,
MaxPlayers: config.MaxPlayers,
RobotEnabled: config.RobotEnabled,
RobotMatchWaitMs: config.RobotMatchWaitMS,
PoolBalanceCoin: config.PoolBalanceCoin,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
}
func diceConfigFromProto(config *gamev1.DiceConfig) dicedomain.Config {
if config == nil {
return dicedomain.Config{}
}
options := make([]dicedomain.StakeOption, 0, len(config.GetStakeOptions()))
for _, option := range config.GetStakeOptions() {
options = append(options, dicedomain.StakeOption{StakeCoin: option.GetStakeCoin(), Enabled: option.GetEnabled(), SortOrder: option.GetSortOrder()})
}
return dicedomain.Config{
AppCode: config.GetAppCode(),
GameID: config.GetGameId(),
Status: config.GetStatus(),
StakeOptions: options,
FeeBPS: config.GetFeeBps(),
PoolBPS: config.GetPoolBps(),
MinPlayers: config.GetMinPlayers(),
MaxPlayers: config.GetMaxPlayers(),
RobotEnabled: config.GetRobotEnabled(),
RobotMatchWaitMS: config.GetRobotMatchWaitMs(),
}
}
func dicePoolAdjustmentToProto(adjustment dicedomain.PoolAdjustment) *gamev1.DicePoolAdjustment {
return &gamev1.DicePoolAdjustment{
AdjustmentId: adjustment.AdjustmentID,
GameId: adjustment.GameID,
AmountCoin: adjustment.AmountCoin,
Direction: adjustment.Direction,
Reason: adjustment.Reason,
BalanceAfter: adjustment.BalanceAfter,
CreatedAtMs: adjustment.CreatedAtMS,
}
}
func diceRobotToProto(robot dicedomain.Robot) *gamev1.DiceRobot {
return &gamev1.DiceRobot{
AppCode: robot.AppCode,
GameId: robot.GameID,
UserId: robot.UserID,
Status: robot.Status,
CreatedByAdminId: robot.CreatedByAdminID,
CreatedAtMs: robot.CreatedAtMS,
UpdatedAtMs: robot.UpdatedAtMS,
}
}
func dicePhase(match dicedomain.Match, nowMS int64) (string, int64) {
switch match.Status {
case dicedomain.MatchStatusCanceled:
return dicedomain.MatchPhaseCanceled, match.CanceledAtMS
case dicedomain.MatchStatusFailed:
return dicedomain.MatchPhaseFailed, match.UpdatedAtMS
case dicedomain.MatchStatusSettled:
return dicedomain.MatchPhaseSettled, match.SettledAtMS + dicedomain.SettlementShowMillis
}
if match.Result == dicedomain.ParticipantResultDraw {
// 和局不是终局,只给客户端一个 2 秒比点展示窗口,窗口结束后 H5 可以继续调用 roll。
return dicedomain.MatchPhaseComparing, match.UpdatedAtMS + dicedomain.DrawRerollMillis
}
if match.ReadyAtMS <= 0 {
return dicedomain.MatchPhaseWaiting, match.JoinDeadlineMS
}
countdownEnd := match.ReadyAtMS + dicedomain.CountdownMillis
rollingEnd := countdownEnd + dicedomain.RollingMillis
comparingEnd := rollingEnd + dicedomain.ComparingMillis
if nowMS < countdownEnd {
return dicedomain.MatchPhaseCountdown, countdownEnd
}
if nowMS < rollingEnd {
return dicedomain.MatchPhaseRolling, rollingEnd
}
return dicedomain.MatchPhaseComparing, comparingEnd
}
func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
return &gamev1.GamePlatform{
AppCode: item.AppCode,

View File

@ -11,21 +11,7 @@ http_addr: ":13000"
cors:
enabled: true
allowed_origins:
- "http://localhost:3000"
- "http://127.0.0.1:3000"
- "http://localhost:5173"
- "http://127.0.0.1:5173"
# 本地工具页会用 10300/8787/8788 承载静态 HTML精确放行测试端口避免用 "*" 破坏凭证请求边界。
- "http://localhost:10300"
- "http://127.0.0.1:10300"
- "http://localhost:8787"
- "http://127.0.0.1:8787"
- "http://localhost:8788"
- "http://127.0.0.1:8788"
- "https://global-interaction.com"
- "https://www.global-interaction.com"
- "https://h5.global-interaction.com"
- "https://api.global-interaction.com"
- "*"
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
allowed_headers:
- "Authorization"

View File

@ -11,23 +11,7 @@ http_addr: ":13000"
cors:
enabled: true
allowed_origins:
- "http://localhost:3000"
- "http://127.0.0.1:3000"
- "http://localhost:5173"
- "http://127.0.0.1:5173"
- "http://localhost:30000"
- "http://127.0.0.1:30000"
# 本地工具页会用 10300/8787/8788 承载静态 HTML精确放行测试端口避免用 "*" 破坏凭证请求边界。
- "http://localhost:10300"
- "http://127.0.0.1:10300"
- "http://localhost:8787"
- "http://127.0.0.1:8787"
- "http://localhost:8788"
- "http://127.0.0.1:8788"
- "https://global-interaction.com"
- "https://www.global-interaction.com"
- "https://h5.global-interaction.com"
- "https://api.global-interaction.com"
- "*"
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
allowed_headers:
- "Authorization"

View File

@ -36,7 +36,7 @@ const listAppBannersSQL = `
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
const listSplashScreensSQL = `
SELECT id, app_code, cover_url, splash_type, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
SELECT id, app_code, cover_url, splash_type, param, platform, sort_order, region_id, country_code, description, display_duration_ms, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_splash_screens
WHERE app_code = ? AND status = 'active'
AND (? = '' OR platform = ?)
@ -115,18 +115,19 @@ type SplashScreenQuery struct {
// SplashScreen 是 gateway 下发给 App 的开屏配置type 语义和 banner 保持一致h5 参数是链接app 参数由客户端解释。
type SplashScreen struct {
ID uint `json:"id"`
CoverURL string `json:"cover_url"`
SplashType string `json:"type"`
Param string `json:"param"`
Platform string `json:"platform"`
SortOrder int `json:"sort_order"`
RegionID int64 `json:"region_id"`
CountryCode string `json:"country_code"`
Description string `json:"description"`
StartsAtMs int64 `json:"starts_at_ms"`
EndsAtMs int64 `json:"ends_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
ID uint `json:"id"`
CoverURL string `json:"cover_url"`
SplashType string `json:"type"`
Param string `json:"param"`
Platform string `json:"platform"`
SortOrder int `json:"sort_order"`
RegionID int64 `json:"region_id"`
CountryCode string `json:"country_code"`
Description string `json:"description"`
DisplayDurationMs int `json:"display_duration_ms"`
StartsAtMs int64 `json:"starts_at_ms"`
EndsAtMs int64 `json:"ends_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// VersionQuery 是 App 检查更新的公开筛选条件。
@ -350,6 +351,7 @@ func (r *MySQLReader) ListSplashScreens(ctx context.Context, query SplashScreenQ
&item.RegionID,
&item.CountryCode,
&item.Description,
&item.DisplayDurationMs,
&item.StartsAtMs,
&item.EndsAtMs,
&updatedAtMS,

View File

@ -20,11 +20,12 @@ const (
// Claims 是 gateway 从 access token 中消费的最小用户状态快照。
// 它只用于入口鉴权和 profile gate不替代 user-service 的用户主数据。
type Claims struct {
AppCode string
UserID int64
SessionID string
ProfileCompleted bool
OnboardingStatus string
AppCode string
UserID int64
SessionID string
ProfileCompleted bool
OnboardingStatus string
AccessTokenExpiresAtMS int64
}
// Verifier 负责在 gateway 入口层校验用户 JWT。
@ -84,16 +85,46 @@ func (v *Verifier) Verify(header string) (Claims, error) {
onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string)
appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"])
return Claims{
AppCode: appcode.Normalize(appCode),
UserID: userID,
SessionID: strings.TrimSpace(sessionID),
ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AppCode: appcode.Normalize(appCode),
UserID: userID,
SessionID: strings.TrimSpace(sessionID),
ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS,
}, nil
}
func claimUnixSecondsMS(value any) int64 {
switch typed := value.(type) {
case float64:
if typed <= 0 {
return 0
}
return int64(typed) * 1000
case int64:
if typed <= 0 {
return 0
}
return typed * 1000
case int:
if typed <= 0 {
return 0
}
return int64(typed) * 1000
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed * 1000
default:
return 0
}
}
// WithUserID 把已鉴权用户写入请求上下文,供 gateway 组装 RequestMeta。
func WithUserID(ctx context.Context, userID int64) context.Context {
return context.WithValue(ctx, userContextKey, userID)
@ -130,3 +161,9 @@ func OnboardingStatusFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.OnboardingStatus
}
// AccessTokenExpiresAtMSFromContext 返回当前 access token 的过期时间;旧测试 token 没有 exp 时返回 0。
func AccessTokenExpiresAtMSFromContext(ctx context.Context) int64 {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.AccessTokenExpiresAtMS
}

View File

@ -13,6 +13,13 @@ type GameClient interface {
ListRecentGames(ctx context.Context, req *gamev1.ListRecentGamesRequest) (*gamev1.ListGamesResponse, error)
GetBridgeScript(ctx context.Context, req *gamev1.GetBridgeScriptRequest) (*gamev1.GetBridgeScriptResponse, error)
LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error)
GetDiceConfig(ctx context.Context, req *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error)
MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error)
CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
JoinDiceMatch(ctx context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error)
}
@ -44,6 +51,34 @@ func (c *grpcGameClient) LaunchGame(ctx context.Context, req *gamev1.LaunchGameR
return c.app.LaunchGame(ctx, req)
}
func (c *grpcGameClient) GetDiceConfig(ctx context.Context, req *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
return c.app.GetDiceConfig(ctx, req)
}
func (c *grpcGameClient) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) {
return c.app.MatchDice(ctx, req)
}
func (c *grpcGameClient) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return c.app.CreateDiceMatch(ctx, req)
}
func (c *grpcGameClient) JoinDiceMatch(ctx context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return c.app.JoinDiceMatch(ctx, req)
}
func (c *grpcGameClient) GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return c.app.GetDiceMatch(ctx, req)
}
func (c *grpcGameClient) RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return c.app.RollDiceMatch(ctx, req)
}
func (c *grpcGameClient) CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return c.app.CancelDiceMatch(ctx, req)
}
func (c *grpcGameClient) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
return c.callback.HandleCallback(ctx, req)
}

View File

@ -26,6 +26,8 @@ type UserIdentityClient interface {
ResolveDisplayUserID(ctx context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error)
ChangeDisplayUserID(ctx context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(ctx context.Context, req *userv1.ApplyPrettyDisplayUserIDRequest) (*userv1.ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(ctx context.Context, req *userv1.ListAvailablePrettyDisplayIDsRequest) (*userv1.ListAvailablePrettyDisplayIDsResponse, error)
ApplyPrettyDisplayIDFromPool(ctx context.Context, req *userv1.ApplyPrettyDisplayIDFromPoolRequest) (*userv1.ApplyPrettyDisplayIDFromPoolResponse, error)
}
// UserProfileClient 抽象 gateway 对 user-service 用户资料能力的依赖。
@ -270,6 +272,14 @@ func (c *grpcUserIdentityClient) ApplyPrettyDisplayUserID(ctx context.Context, r
return c.client.ApplyPrettyDisplayUserID(ctx, req)
}
func (c *grpcUserIdentityClient) ListAvailablePrettyDisplayIDs(ctx context.Context, req *userv1.ListAvailablePrettyDisplayIDsRequest) (*userv1.ListAvailablePrettyDisplayIDsResponse, error) {
return c.client.ListAvailablePrettyDisplayIDs(ctx, req)
}
func (c *grpcUserIdentityClient) ApplyPrettyDisplayIDFromPool(ctx context.Context, req *userv1.ApplyPrettyDisplayIDFromPoolRequest) (*userv1.ApplyPrettyDisplayIDFromPoolResponse, error) {
return c.client.ApplyPrettyDisplayIDFromPool(ctx, req)
}
func (c *grpcUserProfileClient) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
return c.client.GetUser(ctx, req)
}

View File

@ -17,6 +17,11 @@ type WalletClient interface {
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error)
ListH5RechargeOptions(ctx context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(ctx context.Context, req *walletv1.CreateH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error)
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
@ -85,6 +90,26 @@ func (c *grpcWalletClient) ListRechargeProducts(ctx context.Context, req *wallet
return c.client.ListRechargeProducts(ctx, req)
}
func (c *grpcWalletClient) ListH5RechargeOptions(ctx context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error) {
return c.client.ListH5RechargeOptions(ctx, req)
}
func (c *grpcWalletClient) CreateH5RechargeOrder(ctx context.Context, req *walletv1.CreateH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.CreateH5RechargeOrder(ctx, req)
}
func (c *grpcWalletClient) SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.SubmitH5RechargeTx(ctx, req)
}
func (c *grpcWalletClient) GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.GetH5RechargeOrder(ctx, req)
}
func (c *grpcWalletClient) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) {
return c.client.HandleMifapayNotify(ctx, req)
}
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
return c.client.ConfirmGooglePayment(ctx, req)
}

View File

@ -22,7 +22,7 @@ func TestLoad(t *testing.T) {
if cfg.RoomServiceAddr != "127.0.0.1:13001" {
t.Fatalf("unexpected room addr: %s", cfg.RoomServiceAddr)
}
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "https://h5.global-interaction.com") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Code") {
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "*") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Code") {
t.Fatalf("unexpected cors config: %+v", cfg.CORS)
}
if cfg.GRPCClient.DefaultTimeout != 5*time.Second || cfg.GRPCClient.RetryMaxAttempts != 2 || len(cfg.GRPCClient.RetryableStatusCodes) != 1 || cfg.GRPCClient.RetryableStatusCodes[0] != "UNAVAILABLE" {

View File

@ -2,6 +2,7 @@ package activityapi
import (
"net/http"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/gateway-service/internal/auth"
@ -9,14 +10,31 @@ import (
)
type firstRechargeRewardTierData struct {
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
TierName string `json:"tier_name"`
MinCoinAmount int64 `json:"min_coin_amount"`
MaxCoinAmount int64 `json:"max_coin_amount"`
ResourceGroupID int64 `json:"resource_group_id"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
TierName string `json:"tier_name"`
MinCoinAmount int64 `json:"min_coin_amount"`
MaxCoinAmount int64 `json:"max_coin_amount"`
USDMinorAmount int64 `json:"usd_minor_amount"`
GoogleProductID string `json:"google_product_id"`
DiscountPercent int32 `json:"discount_percent"`
ResourceGroupID int64 `json:"resource_group_id"`
ResourceGroup *checkinResourceGroupData `json:"resource_group,omitempty"`
Rewards []firstRechargeRewardItemData `json:"rewards"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
}
type firstRechargeRewardItemData struct {
ResourceID int64 `json:"resource_id,omitempty"`
ResourceType string `json:"resource_type"`
ResourceName string `json:"resource_name"`
CoverURL string `json:"cover_url"`
EffectURL string `json:"effect_url"`
DurationMS int64 `json:"duration_ms"`
Quantity int64 `json:"quantity"`
WalletAssetType string `json:"wallet_asset_type,omitempty"`
WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"`
}
type firstRechargeRewardClaimData struct {
@ -29,6 +47,9 @@ type firstRechargeRewardClaimData struct {
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
RechargeSequence int64 `json:"recharge_sequence"`
RechargeType string `json:"recharge_type"`
TierUSDMinorAmount int64 `json:"tier_usd_minor_amount"`
GoogleProductID string `json:"google_product_id"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
Status string `json:"status"`
WalletGrantID string `json:"wallet_grant_id"`
FailureReason string `json:"failure_reason"`
@ -39,11 +60,12 @@ type firstRechargeRewardClaimData struct {
}
type firstRechargeRewardStatusData struct {
Enabled bool `json:"enabled"`
Tiers []firstRechargeRewardTierData `json:"tiers"`
Rewarded bool `json:"rewarded"`
Claim *firstRechargeRewardClaimData `json:"claim,omitempty"`
ServerTimeMS int64 `json:"server_time_ms"`
Enabled bool `json:"enabled"`
Tiers []firstRechargeRewardTierData `json:"tiers"`
Rewarded bool `json:"rewarded"`
Claim *firstRechargeRewardClaimData `json:"claim,omitempty"`
Claims []firstRechargeRewardClaimData `json:"claims"`
ServerTimeMS int64 `json:"server_time_ms"`
}
func (h *Handler) getFirstRechargeRewardStatus(writer http.ResponseWriter, request *http.Request) {
@ -59,45 +81,66 @@ func (h *Handler) getFirstRechargeRewardStatus(writer http.ResponseWriter, reque
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, firstRechargeRewardStatusFromProto(resp.GetStatus()))
status := resp.GetStatus()
groups, ok := h.resolveCheckinResourceGroups(writer, request, firstRechargeRewardGroupIDs(status))
if !ok {
return
}
httpkit.WriteOK(writer, request, firstRechargeRewardStatusFromProto(status, groups))
}
func firstRechargeRewardStatusFromProto(item *activityv1.FirstRechargeRewardStatus) firstRechargeRewardStatusData {
func firstRechargeRewardStatusFromProto(item *activityv1.FirstRechargeRewardStatus, groups map[int64]checkinResourceGroupData) firstRechargeRewardStatusData {
if item == nil {
return firstRechargeRewardStatusData{Tiers: []firstRechargeRewardTierData{}}
return firstRechargeRewardStatusData{Tiers: []firstRechargeRewardTierData{}, Claims: []firstRechargeRewardClaimData{}}
}
tiers := make([]firstRechargeRewardTierData, 0, len(item.GetTiers()))
for _, tier := range item.GetTiers() {
tiers = append(tiers, firstRechargeRewardTierFromProto(tier))
tiers = append(tiers, firstRechargeRewardTierFromProto(tier, groups))
}
var claim *firstRechargeRewardClaimData
if item.GetClaim() != nil {
converted := firstRechargeRewardClaimFromProto(item.GetClaim())
claim = &converted
}
claims := make([]firstRechargeRewardClaimData, 0, len(item.GetClaims()))
for _, item := range item.GetClaims() {
claims = append(claims, firstRechargeRewardClaimFromProto(item))
}
return firstRechargeRewardStatusData{
Enabled: item.GetEnabled(),
Tiers: tiers,
Rewarded: item.GetRewarded(),
Claim: claim,
Claims: claims,
ServerTimeMS: item.GetServerTimeMs(),
}
}
func firstRechargeRewardTierFromProto(item *activityv1.FirstRechargeRewardTier) firstRechargeRewardTierData {
func firstRechargeRewardTierFromProto(item *activityv1.FirstRechargeRewardTier, groups map[int64]checkinResourceGroupData) firstRechargeRewardTierData {
if item == nil {
return firstRechargeRewardTierData{}
}
return firstRechargeRewardTierData{
data := firstRechargeRewardTierData{
TierID: item.GetTierId(),
TierCode: item.GetTierCode(),
TierName: item.GetTierName(),
MinCoinAmount: item.GetMinCoinAmount(),
MaxCoinAmount: item.GetMaxCoinAmount(),
USDMinorAmount: item.GetUsdMinorAmount(),
GoogleProductID: item.GetGoogleProductId(),
DiscountPercent: item.GetDiscountPercent(),
ResourceGroupID: item.GetResourceGroupId(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
}
if group, ok := groups[item.GetResourceGroupId()]; ok {
data.ResourceGroup = &group
data.Rewards = firstRechargeRewardItemsFromGroup(group)
}
if data.Rewards == nil {
data.Rewards = []firstRechargeRewardItemData{}
}
return data
}
func firstRechargeRewardClaimFromProto(item *activityv1.FirstRechargeRewardClaim) firstRechargeRewardClaimData {
@ -114,6 +157,9 @@ func firstRechargeRewardClaimFromProto(item *activityv1.FirstRechargeRewardClaim
RechargeCoinAmount: item.GetRechargeCoinAmount(),
RechargeSequence: item.GetRechargeSequence(),
RechargeType: item.GetRechargeType(),
TierUSDMinorAmount: item.GetTierUsdMinorAmount(),
GoogleProductID: item.GetGoogleProductId(),
RechargeUSDMinor: item.GetRechargeUsdMinor(),
Status: item.GetStatus(),
WalletGrantID: item.GetWalletGrantId(),
FailureReason: item.GetFailureReason(),
@ -123,3 +169,68 @@ func firstRechargeRewardClaimFromProto(item *activityv1.FirstRechargeRewardClaim
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func firstRechargeRewardGroupIDs(item *activityv1.FirstRechargeRewardStatus) []int64 {
if item == nil {
return nil
}
ids := make([]int64, 0, len(item.GetTiers()))
for _, tier := range item.GetTiers() {
if tier.GetResourceGroupId() > 0 {
ids = append(ids, tier.GetResourceGroupId())
}
}
return ids
}
func firstRechargeRewardItemsFromGroup(group checkinResourceGroupData) []firstRechargeRewardItemData {
items := make([]firstRechargeRewardItemData, 0, len(group.Items))
for _, item := range group.Items {
reward := firstRechargeRewardItemData{
ResourceID: item.ResourceID,
DurationMS: item.DurationMS,
Quantity: item.Quantity,
WalletAssetType: item.WalletAssetType,
WalletAssetAmount: item.WalletAssetAmount,
}
if item.Resource != nil {
reward.ResourceType = item.Resource.ResourceType
reward.ResourceName = item.Resource.Name
reward.CoverURL = firstRechargeFirstNonEmpty(item.Resource.AssetURL, item.Resource.PreviewURL, item.Resource.AnimationURL)
reward.EffectURL = firstRechargeFirstNonEmpty(item.Resource.AnimationURL, item.Resource.PreviewURL, item.Resource.AssetURL)
} else {
reward.ResourceType = firstRechargeWalletAssetType(item.WalletAssetType)
reward.ResourceName = firstRechargeWalletAssetType(item.WalletAssetType)
reward.Quantity = firstRechargePositiveOr(item.WalletAssetAmount, item.Quantity)
}
items = append(items, reward)
}
return items
}
func firstRechargeWalletAssetType(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return "wallet_asset"
}
if normalized == "coin" || normalized == "coins" {
return "coin"
}
return normalized
}
func firstRechargePositiveOr(primary int64, fallback int64) int64 {
if primary > 0 {
return primary
}
return fallback
}
func firstRechargeFirstNonEmpty(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}

View File

@ -2,6 +2,7 @@ package appapi
import (
"net/http"
"strconv"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
@ -9,11 +10,20 @@ import (
)
type appHeartbeatData struct {
Accepted bool `json:"accepted"`
HeartbeatAtMS int64 `json:"heartbeat_at_ms"`
ServerTimeMS int64 `json:"server_time_ms"`
Accepted bool `json:"accepted"`
HeartbeatAtMS int64 `json:"heartbeat_at_ms"`
ServerTimeMS int64 `json:"server_time_ms"`
AccessToken string `json:"access_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpiresInSec int64 `json:"expires_in_sec,omitempty"`
}
const (
renewedAccessTokenHeader = "X-Hyapp-Access-Token"
renewedTokenTypeHeader = "X-Hyapp-Token-Type"
renewedExpiresInHeader = "X-Hyapp-Expires-In"
)
// appHeartbeat 刷新当前登录 App 会话,不写房间 presence也不改变麦位在线状态。
func (h *Handler) appHeartbeat(writer http.ResponseWriter, request *http.Request) {
if h.userAuthClient == nil {
@ -32,10 +42,20 @@ func (h *Handler) appHeartbeat(writer http.ResponseWriter, request *http.Request
httpkit.WriteRPCError(writer, request, err)
return
}
token := resp.GetToken()
if token.GetAccessToken() != "" {
// HTTP header 让 H5 通用请求层无感更新 tokenJSON 字段保留给原生端显式消费。
writer.Header().Set(renewedAccessTokenHeader, token.GetAccessToken())
writer.Header().Set(renewedTokenTypeHeader, token.GetTokenType())
writer.Header().Set(renewedExpiresInHeader, strconv.FormatInt(token.GetExpiresInSec(), 10))
}
httpkit.WriteOK(writer, request, appHeartbeatData{
Accepted: resp.GetAccepted(),
HeartbeatAtMS: resp.GetHeartbeatAtMs(),
ServerTimeMS: resp.GetServerTimeMs(),
AccessToken: token.GetAccessToken(),
TokenType: token.GetTokenType(),
ExpiresInSec: token.GetExpiresInSec(),
})
}

View File

@ -6,7 +6,7 @@ import (
"strings"
)
// CORSConfig 是 gateway transport 层的浏览器跨域策略;生产只回显白名单 Origin。
// CORSConfig 是 gateway transport 层的浏览器跨域策略;生产可用白名单,联调环境可用 "*" 放开所有 Origin。
type CORSConfig struct {
Enabled bool
AllowedOrigins []string
@ -24,7 +24,7 @@ func WithCORS(config CORSConfig, next http.Handler) http.Handler {
return next
}
allowedOrigins := allowedOriginSet(config.AllowedOrigins)
allowAnyOrigin := allowedOrigins["*"] && !config.AllowCredentials
allowAnyOrigin := allowedOrigins["*"]
allowedMethods := strings.Join(config.AllowedMethods, ", ")
allowedHeaders := strings.Join(config.AllowedHeaders, ", ")
exposeHeaders := strings.Join(config.ExposeHeaders, ", ")
@ -52,9 +52,10 @@ func WithCORS(config CORSConfig, next http.Handler) http.Handler {
header.Add("Vary", "Access-Control-Request-Method")
header.Add("Vary", "Access-Control-Request-Headers")
}
if allowAnyOrigin {
if allowAnyOrigin && !config.AllowCredentials {
header.Set("Access-Control-Allow-Origin", "*")
} else {
// 浏览器规范不允许 "*" 和 Allow-Credentials 同时使用;全放开且允许凭证时必须回显当前 Origin。
header.Set("Access-Control-Allow-Origin", origin)
}
if config.AllowCredentials {
@ -103,7 +104,7 @@ func normalizeCORSConfig(config CORSConfig) CORSConfig {
config.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform"}
}
if len(config.ExposeHeaders) == 0 {
config.ExposeHeaders = []string{"X-Request-ID"}
config.ExposeHeaders = []string{"X-Request-ID", "X-Hyapp-Access-Token", "X-Hyapp-Token-Type", "X-Hyapp-Expires-In"}
}
if config.MaxAgeSec <= 0 {
config.MaxAgeSec = 600

View File

@ -3,6 +3,7 @@ package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@ -99,3 +100,40 @@ func TestCORSAddsHeadersForAllowedActualRequest(t *testing.T) {
t.Fatalf("unexpected expose headers: %q", got)
}
}
func TestCORSWildcardWithCredentialsEchoesAnyOrigin(t *testing.T) {
called := false
handler := WithCORS(CORSConfig{
Enabled: true,
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-App-Code"},
AllowCredentials: true,
}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
request := httptest.NewRequest(http.MethodOptions, "/api/v1/users/me/appearance", nil)
request.Header.Set("Origin", "http://127.0.0.1:8081")
request.Header.Set("Access-Control-Request-Method", "GET")
request.Header.Set("Access-Control-Request-Headers", "authorization,x-app-code")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if called {
t.Fatal("wildcard preflight must not reach business handler")
}
if recorder.Code != http.StatusNoContent {
t.Fatalf("unexpected wildcard preflight status: %d", recorder.Code)
}
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:8081" {
t.Fatalf("wildcard with credentials must echo request origin, got %q", got)
}
if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("unexpected allow credentials: %q", got)
}
if got := recorder.Header().Get("Access-Control-Expose-Headers"); !strings.Contains(got, "X-Hyapp-Access-Token") {
t.Fatalf("renewed token header must be exposed to browser javascript, got %q", got)
}
}

View File

@ -3,10 +3,15 @@ package http
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/services/gateway-service/internal/auth"
@ -154,6 +159,34 @@ func TestLaunchGameForwardsDisplayUserID(t *testing.T) {
}
}
func TestDiceMatchRenewsAccessTokenWhenTokenNearExpiry(t *testing.T) {
userClient := &fakeUserAuthClient{}
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, userClient, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/dice/match", bytes.NewReader([]byte(`{"game_id":"dice","stake_coin":100}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithExpiresAt(t, "secret", 42, true, time.Now().Add(2*time.Minute)))
request.Header.Set("X-Device-ID", "dev-dice")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if userClient.lastAppHeartbeat == nil || userClient.lastAppHeartbeat.GetUserId() != 42 || userClient.lastAppHeartbeat.GetSessionId() != "sess-test" {
t.Fatalf("dice match must renew with authenticated user/session: %+v", userClient.lastAppHeartbeat)
}
if userClient.lastAppHeartbeat.GetMeta().GetDeviceId() != "dev-dice" {
t.Fatalf("dice match must forward device meta during token renew: %+v", userClient.lastAppHeartbeat.GetMeta())
}
if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || recorder.Header().Get("X-Hyapp-Expires-In") != "1800" {
t.Fatalf("dice match must expose renewed token headers: %+v", recorder.Header())
}
}
func TestGameCallbackIsRawPublicResponse(t *testing.T) {
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
@ -173,6 +206,86 @@ func TestGameCallbackIsRawPublicResponse(t *testing.T) {
}
}
func TestHotgameCompatFallbacksToHyappReyouCallback(t *testing.T) {
body := `{"gameId":"101","uid":"420001","token":"hyapp-token","sign":"s"}`
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/getUserInfo?nonce=1", bytes.NewReader([]byte(body)))
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0}` {
t.Fatalf("hotgame hyapp callback response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCallback == nil || gameClient.lastCallback.GetPlatformCode() != "reyou" || gameClient.lastCallback.GetOperation() != "getUserInfo" || string(gameClient.lastCallback.GetRawBody()) != body || gameClient.lastCallback.GetQuery()["nonce"] != "1" {
t.Fatalf("hotgame hyapp callback request mismatch: %+v", gameClient.lastCallback)
}
}
func TestHotgameCompatProxiesChatapp3TokenWithoutGameCallback(t *testing.T) {
token := chatapp3HotgameToken("v1", 1001, "LIKEI", 4102444800000, 1700000000000)
body := `{"orderId":"o1","gameId":"13","roundId":"r1","uid":"1001","coin":12,"type":1,"token":"` + token + `","sign":"s"}`
var gotHost string
var gotPath string
var gotQuery string
var gotBody string
var gotContentType string
oldTransport := http.DefaultTransport
http.DefaultTransport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
gotHost = request.URL.Host
gotPath = request.URL.Path
gotQuery = request.URL.RawQuery
gotContentType = request.Header.Get("Content-Type")
raw, _ := io.ReadAll(request.Body)
gotBody = string(raw)
header := http.Header{}
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Header: header,
Body: io.NopCloser(bytes.NewReader([]byte(`{"errorCode":0,"data":{"source":"chatapp3"}}`))),
Request: request,
}, nil
})
defer func() { http.DefaultTransport = oldTransport }()
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/updateBalance?nonce=1", bytes.NewReader([]byte(body)))
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0,"data":{"source":"chatapp3"}}` {
t.Fatalf("hotgame chatapp3 proxy response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gotHost != "jvapi.haiyihy.com" || gotPath != "/go/updateBalance" || gotQuery != "nonce=1" || gotBody != body || gotContentType != "application/json" {
t.Fatalf("hotgame chatapp3 proxy request mismatch: host=%q path=%q query=%q content_type=%q body=%s", gotHost, gotPath, gotQuery, gotContentType, gotBody)
}
if gameClient.lastCallback != nil {
t.Fatalf("chatapp3 token must not enter hyapp game callback: %+v", gameClient.lastCallback)
}
}
func chatapp3HotgameToken(version string, userID int64, sysOrigin string, expireMs int64, releaseMs int64) string {
payload := url.QueryEscape(version + ":" + strconv.FormatInt(userID, 10) + ":" + sysOrigin + ":" + strconv.FormatInt(expireMs, 10) + ":" + strconv.FormatInt(releaseMs, 10))
return "sign." + base64.StdEncoding.EncodeToString([]byte(payload))
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
type fakeGatewayGameClient struct {
listResp *gamev1.ListGamesResponse
recentResp *gamev1.ListGamesResponse
@ -218,6 +331,34 @@ func (f *fakeGatewayGameClient) LaunchGame(_ context.Context, req *gamev1.Launch
return &gamev1.LaunchGameResponse{}, nil
}
func (f *fakeGatewayGameClient) GetDiceConfig(context.Context, *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
return &gamev1.DiceConfigResponse{}, nil
}
func (f *fakeGatewayGameClient) MatchDice(context.Context, *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CreateDiceMatch(context.Context, *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) JoinDiceMatch(context.Context, *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) GetDiceMatch(context.Context, *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) RollDiceMatch(context.Context, *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CancelDiceMatch(context.Context, *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) HandleCallback(_ context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
f.lastCallback = req
if f.callbackResp != nil {

View File

@ -0,0 +1,109 @@
package gameapi
import (
"net/http"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const diceAvatarFrameResourceType = "avatar_frame"
// enrichDiceMatchParticipants 聚合用户基础资料和佩戴头像框;展示资料失败不影响游戏事实返回。
func (h *Handler) enrichDiceMatchParticipants(request *http.Request, match *diceMatchItemData) {
if h == nil || match == nil || len(match.Participants) == 0 {
return
}
userIDs := diceParticipantUserIDs(match.Participants)
users := h.diceUsersByID(request, userIDs)
frames := h.diceAvatarFramesByID(request, userIDs)
for index := range match.Participants {
userID := match.Participants[index].UserIDNumber
if user, ok := users[userID]; ok {
match.Participants[index].User = user
} else {
match.Participants[index].User = diceUserData{UserID: int64String(userID)}
}
if frame, ok := frames[userID]; ok {
match.Participants[index].AvatarFrame = frame
}
}
}
func diceParticipantUserIDs(participants []diceParticipantItemData) []int64 {
seen := make(map[int64]struct{}, len(participants))
out := make([]int64, 0, len(participants))
for _, participant := range participants {
if participant.UserIDNumber <= 0 {
continue
}
if _, ok := seen[participant.UserIDNumber]; ok {
continue
}
seen[participant.UserIDNumber] = struct{}{}
out = append(out, participant.UserIDNumber)
}
return out
}
func (h *Handler) diceUsersByID(request *http.Request, userIDs []int64) map[int64]diceUserData {
out := make(map[int64]diceUserData, len(userIDs))
if h.userProfileClient == nil || len(userIDs) == 0 {
return out
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
return out
}
for userID, user := range resp.GetUsers() {
out[userID] = diceUserData{
UserID: int64String(user.GetUserId()),
DisplayUserID: user.GetDisplayUserId(),
Nickname: user.GetUsername(),
Avatar: user.GetAvatar(),
}
}
return out
}
func (h *Handler) diceAvatarFramesByID(request *http.Request, userIDs []int64) map[int64]map[string]any {
out := make(map[int64]map[string]any, len(userIDs))
if h.walletClient == nil || len(userIDs) == 0 {
return out
}
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserIds: userIDs,
ResourceTypes: []string{diceAvatarFrameResourceType},
})
if err != nil {
return out
}
for _, item := range resp.GetUsers() {
for _, entitlement := range item.GetResources() {
resource := entitlement.GetResource()
if resource.GetResourceType() != diceAvatarFrameResourceType {
continue
}
out[item.GetUserId()] = map[string]any{
"resource_id": int64String(resource.GetResourceId()),
"resource_code": resource.GetResourceCode(),
"name": resource.GetName(),
"asset_url": resource.GetAssetUrl(),
"preview_url": resource.GetPreviewUrl(),
"animation_url": resource.GetAnimationUrl(),
"metadata_json": resource.GetMetadataJson(),
"entitlement_id": entitlement.GetEntitlementId(),
"expires_at_ms": entitlement.GetExpiresAtMs(),
}
break
}
}
return out
}

View File

@ -2,18 +2,28 @@ package gameapi
import (
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
diceAccessTokenRenewBefore = 10 * time.Minute
hyappAccessTokenHeader = "X-Hyapp-Access-Token"
hyappTokenTypeHeader = "X-Hyapp-Token-Type"
hyappExpiresInHeader = "X-Hyapp-Expires-In"
)
func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -124,20 +134,246 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err)
}
func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) {
func (h *Handler) getDiceConfig(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
http.Error(writer, "upstream service error", http.StatusBadGateway)
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
gameID := strings.TrimSpace(request.URL.Query().Get("game_id"))
if gameID == "" {
gameID = "dice"
}
// 配置接口只返回当前 App 的可下注档位和费率,不接受客户端传 app_code。
resp, err := h.gameClient.GetDiceConfig(request.Context(), &gamev1.GetDiceConfigRequest{
Meta: gameMeta(request),
GameId: gameID,
})
if err == nil {
h.renewDiceAccessTokenIfNeeded(writer, request)
}
httpkit.Write(writer, request, diceConfigDataFromProto(resp), err)
}
func (h *Handler) matchDice(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
GameID string `json:"game_id"`
RoomID string `json:"room_id"`
StakeCoin int64 `json:"stake_coin"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
if strings.TrimSpace(body.GameID) == "" {
body.GameID = "dice"
}
if body.StakeCoin <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.gameClient.MatchDice(request.Context(), &gamev1.MatchDiceRequest{
Meta: gameMeta(request),
UserId: userID,
GameId: strings.TrimSpace(body.GameID),
RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(),
StakeCoin: body.StakeCoin,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) createDiceMatch(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
// gateway 只接受 H5 的展示输入user_id、region_id、app_code 和 request_id 都由网关上下文补齐。
var body struct {
GameID string `json:"game_id"`
RoomID string `json:"room_id"`
StakeCoin int64 `json:"stake_coin"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
if strings.TrimSpace(body.GameID) == "" || body.StakeCoin <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
// region_id 来自 user-service 当前用户资料,不信任客户端 body避免跨区域创建可结算局。
resp, err := h.gameClient.CreateDiceMatch(request.Context(), &gamev1.CreateDiceMatchRequest{
Meta: gameMeta(request),
UserId: userID,
GameId: strings.TrimSpace(body.GameID),
RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(),
StakeCoin: body.StakeCoin,
MinPlayers: body.MinPlayers,
MaxPlayers: body.MaxPlayers,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) joinDiceMatch(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
matchID := strings.TrimSpace(request.PathValue("match_id"))
if matchID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// join 不接受 body 里的 user_id 或 stake_coin参与者身份来自 JWT押注金额来自 match 当前事实。
resp, err := h.gameClient.JoinDiceMatch(request.Context(), &gamev1.JoinDiceMatchRequest{
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
matchID := strings.TrimSpace(request.PathValue("match_id"))
if matchID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// 查询接口只是透传当前局事实,前端轮询或 IM 事件后的刷新都可以复用这条路径。
resp, err := h.gameClient.GetDiceMatch(request.Context(), &gamev1.GetDiceMatchRequest{
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
matchID := strings.TrimSpace(request.PathValue("match_id"))
if matchID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// roll 不接收骰子点数、结果或派奖金额;这些全部由 game-service 服务端随机和结算。
resp, err := h.gameClient.RollDiceMatch(request.Context(), &gamev1.RollDiceMatchRequest{
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
matchID := strings.TrimSpace(request.PathValue("match_id"))
if matchID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.gameClient.CancelDiceMatch(request.Context(), &gamev1.CancelDiceMatchRequest{
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) writeDiceMatch(writer http.ResponseWriter, request *http.Request, resp *gamev1.DiceMatchResponse, err error) {
if err != nil {
httpkit.Write(writer, request, diceMatchData{}, err)
return
}
data := diceMatchDataFromProto(resp)
h.enrichDiceMatchParticipants(request, &data.Match)
h.renewDiceAccessTokenIfNeeded(writer, request)
httpkit.Write(writer, request, data, nil)
}
func (h *Handler) renewDiceAccessTokenIfNeeded(writer http.ResponseWriter, request *http.Request) {
if h.userAuthClient == nil {
return
}
expiresAtMS := auth.AccessTokenExpiresAtMSFromContext(request.Context())
if expiresAtMS <= 0 || time.Until(time.UnixMilli(expiresAtMS)) > diceAccessTokenRenewBefore {
// 旧测试 token 或距离过期还很久的 token 不做续签,避免每秒轮询把 user-service 和登录审计打满。
return
}
sessionID := auth.SessionIDFromContext(request.Context())
if strings.TrimSpace(sessionID) == "" {
return
}
resp, err := h.userAuthClient.AppHeartbeat(request.Context(), &userv1.AppHeartbeatRequest{
Meta: httpkit.UserMeta(request, httpkit.FirstHeader(request, "X-Device-ID", "X-HY-Device-ID")),
UserId: auth.UserIDFromContext(request.Context()),
SessionId: sessionID,
})
if err != nil {
// 游戏事实响应已经成功,续签失败只记录日志;客户端下一次仍可走 refresh token 兜底。
logx.Warn(request.Context(), "dice_access_token_renew_failed", slog.String("component", "gateway_game"), slog.String("session_id", sessionID), slog.String("error", err.Error()))
return
}
token := resp.GetToken()
accessToken := strings.TrimSpace(token.GetAccessToken())
if accessToken == "" {
return
}
writer.Header().Set(hyappAccessTokenHeader, accessToken)
writer.Header().Set(hyappTokenTypeHeader, strings.TrimSpace(token.GetTokenType()))
writer.Header().Set(hyappExpiresInHeader, strconv.FormatInt(token.GetExpiresInSec(), 10))
}
func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) {
raw, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, "invalid request body", http.StatusBadRequest)
return
}
h.handleGameCallbackRaw(writer, request, raw, strings.TrimSpace(request.PathValue("platform_code")), strings.TrimSpace(request.PathValue("operation")))
}
func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *http.Request, raw []byte, platformCode string, operation string) {
if h.gameClient == nil {
http.Error(writer, "upstream service error", http.StatusBadGateway)
return
}
resp, err := h.gameClient.HandleCallback(request.Context(), &gamev1.CallbackRequest{
Meta: gameMeta(request),
PlatformCode: strings.TrimSpace(request.PathValue("platform_code")),
Operation: strings.TrimSpace(request.PathValue("operation")),
PlatformCode: strings.TrimSpace(platformCode),
Operation: strings.TrimSpace(operation),
RawBody: raw,
Headers: flattenedHeaders(request),
Query: flattenedQuery(request),

View File

@ -1,6 +1,10 @@
package gameapi
import gamev1 "hyapp.local/api/proto/game/v1"
import (
"fmt"
gamev1 "hyapp.local/api/proto/game/v1"
)
type gameListData struct {
Games []gameItemData `json:"games"`
@ -45,6 +49,90 @@ type gameBridgeScriptData struct {
ServerTimeMS int64 `json:"server_time_ms"`
}
type diceMatchData struct {
Match diceMatchItemData `json:"match"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type diceConfigData struct {
Config diceConfigItemData `json:"config"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type diceConfigItemData struct {
AppCode string `json:"app_code"`
GameID string `json:"game_id"`
Status string `json:"status"`
StakeOptions []diceStakeOptionData `json:"stake_options"`
FeeBPS int32 `json:"fee_bps"`
PoolBPS int32 `json:"pool_bps"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
RobotEnabled bool `json:"robot_enabled"`
RobotMatchWaitMS int64 `json:"robot_match_wait_ms"`
PoolBalanceCoin int64 `json:"pool_balance_coin"`
}
type diceStakeOptionData struct {
StakeCoin int64 `json:"stake_coin"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sort_order"`
}
type diceMatchItemData struct {
AppCode string `json:"app_code"`
MatchID string `json:"match_id"`
GameID string `json:"game_id"`
RoomID string `json:"room_id"`
RegionID int64 `json:"region_id"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
CurrentPlayers int32 `json:"current_players"`
StakeCoin int64 `json:"stake_coin"`
RoundNo int32 `json:"round_no"`
Status string `json:"status"`
Result string `json:"result"`
Participants []diceParticipantItemData `json:"participants"`
JoinDeadlineMS int64 `json:"join_deadline_ms"`
ReadyAtMS int64 `json:"ready_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
SettledAtMS int64 `json:"settled_at_ms"`
CanceledAtMS int64 `json:"canceled_at_ms"`
Phase string `json:"phase"`
PhaseDeadlineMS int64 `json:"phase_deadline_ms"`
FeeBPS int32 `json:"fee_bps"`
PoolBPS int32 `json:"pool_bps"`
MatchMode string `json:"match_mode"`
ForcedResult string `json:"forced_result"`
PoolDeltaCoin int64 `json:"pool_delta_coin"`
}
type diceParticipantItemData struct {
UserID string `json:"user_id"`
UserIDNumber int64 `json:"user_id_number"`
SeatNo int32 `json:"seat_no"`
Status string `json:"status"`
StakeCoin int64 `json:"stake_coin"`
DicePoints []int32 `json:"dice_points"`
Result string `json:"result"`
PayoutCoin int64 `json:"payout_coin"`
BalanceAfter int64 `json:"balance_after"`
JoinedAtMS int64 `json:"joined_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
ParticipantType string `json:"participant_type"`
IsRobot bool `json:"is_robot"`
User diceUserData `json:"user"`
AvatarFrame map[string]any `json:"avatar_frame"`
}
type diceUserData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
func gameListDataFromProto(resp *gamev1.ListGamesResponse) gameListData {
if resp == nil {
return gameListData{}
@ -87,6 +175,107 @@ func gameLaunchDataFromProto(resp *gamev1.LaunchGameResponse) gameLaunchData {
}
}
func diceConfigDataFromProto(resp *gamev1.DiceConfigResponse) diceConfigData {
if resp == nil {
return diceConfigData{}
}
config := resp.GetConfig()
options := make([]diceStakeOptionData, 0, len(config.GetStakeOptions()))
for _, option := range config.GetStakeOptions() {
options = append(options, diceStakeOptionData{
StakeCoin: option.GetStakeCoin(),
Enabled: option.GetEnabled(),
SortOrder: option.GetSortOrder(),
})
}
return diceConfigData{
Config: diceConfigItemData{
AppCode: config.GetAppCode(),
GameID: config.GetGameId(),
Status: config.GetStatus(),
StakeOptions: options,
FeeBPS: config.GetFeeBps(),
PoolBPS: config.GetPoolBps(),
MinPlayers: config.GetMinPlayers(),
MaxPlayers: config.GetMaxPlayers(),
RobotEnabled: config.GetRobotEnabled(),
RobotMatchWaitMS: config.GetRobotMatchWaitMs(),
PoolBalanceCoin: config.GetPoolBalanceCoin(),
},
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func diceMatchDataFromProto(resp *gamev1.DiceMatchResponse) diceMatchData {
if resp == nil {
return diceMatchData{}
}
return diceMatchData{
Match: diceMatchItemDataFromProto(resp.GetMatch()),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func diceMatchItemDataFromProto(match *gamev1.DiceMatch) diceMatchItemData {
if match == nil {
return diceMatchItemData{}
}
participants := make([]diceParticipantItemData, 0, len(match.GetParticipants()))
for _, participant := range match.GetParticipants() {
participants = append(participants, diceParticipantItemDataFromProto(participant))
}
return diceMatchItemData{
AppCode: match.GetAppCode(),
MatchID: match.GetMatchId(),
GameID: match.GetGameId(),
RoomID: match.GetRoomId(),
RegionID: match.GetRegionId(),
MinPlayers: match.GetMinPlayers(),
MaxPlayers: match.GetMaxPlayers(),
CurrentPlayers: match.GetCurrentPlayers(),
StakeCoin: match.GetStakeCoin(),
RoundNo: match.GetRoundNo(),
Status: match.GetStatus(),
Result: match.GetResult(),
Participants: participants,
JoinDeadlineMS: match.GetJoinDeadlineMs(),
ReadyAtMS: match.GetReadyAtMs(),
CreatedAtMS: match.GetCreatedAtMs(),
UpdatedAtMS: match.GetUpdatedAtMs(),
SettledAtMS: match.GetSettledAtMs(),
CanceledAtMS: match.GetCanceledAtMs(),
Phase: match.GetPhase(),
PhaseDeadlineMS: match.GetPhaseDeadlineMs(),
FeeBPS: match.GetFeeBps(),
PoolBPS: match.GetPoolBps(),
MatchMode: match.GetMatchMode(),
ForcedResult: match.GetForcedResult(),
PoolDeltaCoin: match.GetPoolDeltaCoin(),
}
}
func diceParticipantItemDataFromProto(participant *gamev1.DiceParticipant) diceParticipantItemData {
if participant == nil {
return diceParticipantItemData{}
}
return diceParticipantItemData{
UserID: int64String(participant.GetUserId()),
UserIDNumber: participant.GetUserId(),
SeatNo: participant.GetSeatNo(),
Status: participant.GetStatus(),
StakeCoin: participant.GetStakeCoin(),
DicePoints: participant.GetDicePoints(),
Result: participant.GetResult(),
PayoutCoin: participant.GetPayoutCoin(),
BalanceAfter: participant.GetBalanceAfter(),
JoinedAtMS: participant.GetJoinedAtMs(),
UpdatedAtMS: participant.GetUpdatedAtMs(),
ParticipantType: participant.GetParticipantType(),
IsRobot: participant.GetIsRobot(),
AvatarFrame: map[string]any{},
}
}
func gameBridgeScriptDataFromProto(resp *gamev1.GetBridgeScriptResponse) gameBridgeScriptData {
if resp == nil {
return gameBridgeScriptData{}
@ -102,3 +291,7 @@ func gameBridgeScriptDataFromProto(resp *gamev1.GetBridgeScriptResponse) gameBri
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func int64String(value int64) string {
return fmt.Sprintf("%d", value)
}

View File

@ -11,28 +11,46 @@ import (
// gateway only translates app/user context into game-service RPCs; game session state stays in game-service.
type Handler struct {
gameClient client.GameClient
userAuthClient client.UserAuthClient
userProfileClient client.UserProfileClient
walletClient client.WalletClient
// 热游固定回调地址先进入 hyapp gatewaychatapp3 老 token 需要原样转给 chatapp3hyapp token 继续落到 game-service。
hotgameCompatHTTPClient *http.Client
}
type Config struct {
GameClient client.GameClient
UserAuthClient client.UserAuthClient
UserProfileClient client.UserProfileClient
WalletClient client.WalletClient
}
func New(config Config) *Handler {
return &Handler{
gameClient: config.GameClient,
userProfileClient: config.UserProfileClient,
gameClient: config.GameClient,
userAuthClient: config.UserAuthClient,
userProfileClient: config.UserProfileClient,
walletClient: config.WalletClient,
hotgameCompatHTTPClient: hotgameCompatHTTPClient(),
}
}
func (h *Handler) Handlers() httproutes.GameHandlers {
return httproutes.GameHandlers{
ListGames: h.listGames,
ListRecentGames: h.listRecentGames,
GetBridgeScript: h.getBridgeScript,
LaunchGame: h.launchGame,
HandleCallback: h.handleGameCallback,
ListGames: h.listGames,
ListRecentGames: h.listRecentGames,
GetBridgeScript: h.getBridgeScript,
LaunchGame: h.launchGame,
GetDiceConfig: h.getDiceConfig,
MatchDice: h.matchDice,
CreateDiceMatch: h.createDiceMatch,
JoinDiceMatch: h.joinDiceMatch,
GetDiceMatch: h.getDiceMatch,
RollDiceMatch: h.rollDiceMatch,
CancelDiceMatch: h.cancelDiceMatch,
HandleCallback: h.handleGameCallback,
HandleHotgameGetUserInfo: h.handleHotgameGetUserInfo,
HandleHotgameUpdateBalance: h.handleHotgameUpdateBalance,
}
}
@ -52,6 +70,42 @@ func (h *Handler) LaunchGame(writer http.ResponseWriter, request *http.Request)
h.launchGame(writer, request)
}
func (h *Handler) GetDiceConfig(writer http.ResponseWriter, request *http.Request) {
h.getDiceConfig(writer, request)
}
func (h *Handler) MatchDice(writer http.ResponseWriter, request *http.Request) {
h.matchDice(writer, request)
}
func (h *Handler) CreateDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.createDiceMatch(writer, request)
}
func (h *Handler) JoinDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.joinDiceMatch(writer, request)
}
func (h *Handler) GetDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.getDiceMatch(writer, request)
}
func (h *Handler) RollDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.rollDiceMatch(writer, request)
}
func (h *Handler) CancelDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.cancelDiceMatch(writer, request)
}
func (h *Handler) HandleCallback(writer http.ResponseWriter, request *http.Request) {
h.handleGameCallback(writer, request)
}
func (h *Handler) HandleHotgameGetUserInfo(writer http.ResponseWriter, request *http.Request) {
h.handleHotgameGetUserInfo(writer, request)
}
func (h *Handler) HandleHotgameUpdateBalance(writer http.ResponseWriter, request *http.Request) {
h.handleHotgameUpdateBalance(writer, request)
}

View File

@ -0,0 +1,243 @@
package gameapi
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
hotgameCompatPlatformCode = "reyou"
hotgameCompatProxyDefaultBaseURL = "https://jvapi.haiyihy.com/go"
hotgameCompatProxyDefaultTimeout = 10 * time.Second
hotgameCompatGetUserInfoOperation = "getUserInfo"
hotgameCompatUpdateBalanceOperation = "updateBalance"
)
func (h *Handler) handleHotgameGetUserInfo(writer http.ResponseWriter, request *http.Request) {
h.handleHotgameCompatCallback(writer, request, hotgameCompatGetUserInfoOperation)
}
func (h *Handler) handleHotgameUpdateBalance(writer http.ResponseWriter, request *http.Request) {
h.handleHotgameCompatCallback(writer, request, hotgameCompatUpdateBalanceOperation)
}
func (h *Handler) handleHotgameCompatCallback(writer http.ResponseWriter, request *http.Request, operation string) {
raw, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, "invalid request body", http.StatusBadRequest)
return
}
// 热游固定回调地址无法按域名区分 hyapp 和 chatapp3这里用 token 结构做最小路由键。
// chatapp3 的用户 token 是 sign.base64(version:userId:sysOrigin:expire:release)hyapp 当前热游 token 是 JWT 或本服务 session。
if isChatapp3HotgameToken(hotgameCompatCallbackToken(raw)) {
h.proxyChatapp3HotgameCallback(writer, request, raw, operation)
return
}
// 非 chatapp3 token 全部继续走 hyapp 自己的 reyou 平台配置,避免 hyapp 自有热游游戏误转到 chatapp3。
h.handleGameCallbackRaw(writer, request, raw, hotgameCompatPlatformCode, operation)
}
func (h *Handler) proxyChatapp3HotgameCallback(writer http.ResponseWriter, request *http.Request, raw []byte, operation string) {
target, err := hotgameCompatProxyURL(operation, request.URL.RawQuery)
if err != nil {
http.Error(writer, "proxy config error", http.StatusBadGateway)
return
}
proxyReq, err := http.NewRequestWithContext(request.Context(), http.MethodPost, target, bytes.NewReader(raw))
if err != nil {
http.Error(writer, "proxy request error", http.StatusBadGateway)
return
}
copyHotgameProxyRequestHeaders(proxyReq, request)
appendHotgameForwardedHeaders(proxyReq, request)
resp, err := h.hotgameCompatHTTPClient.Do(proxyReq)
if err != nil {
http.Error(writer, "proxy upstream error", http.StatusBadGateway)
return
}
defer resp.Body.Close()
copyHotgameProxyResponseHeaders(writer.Header(), resp.Header)
writer.WriteHeader(resp.StatusCode)
_, _ = io.Copy(writer, resp.Body)
}
func hotgameCompatHTTPClient() *http.Client {
return &http.Client{Timeout: hotgameCompatProxyDefaultTimeout}
}
func hotgameCompatCallbackToken(raw []byte) string {
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
return ""
}
return hotgameCompatJSONText(payload["token"])
}
func hotgameCompatJSONText(raw json.RawMessage) string {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return ""
}
var decoded string
if err := json.Unmarshal(raw, &decoded); err == nil {
return strings.TrimSpace(decoded)
}
return text
}
func isChatapp3HotgameToken(token string) bool {
token = strings.TrimSpace(token)
if chatapp3HotgameTokenCandidateMatches(token) {
return true
}
if decoded, err := url.QueryUnescape(token); err == nil && strings.TrimSpace(decoded) != token {
return chatapp3HotgameTokenCandidateMatches(decoded)
}
return false
}
func chatapp3HotgameTokenCandidateMatches(token string) bool {
token = normalizeChatapp3HotgameTokenCandidate(token)
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return false
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
payload := strings.TrimSpace(string(decoded))
if unescaped, err := url.QueryUnescape(payload); err == nil {
payload = strings.TrimSpace(unescaped)
}
fields := strings.Split(payload, ":")
if len(fields) != 5 || strings.TrimSpace(fields[0]) == "" || strings.TrimSpace(fields[2]) == "" {
return false
}
userID, userErr := strconv.ParseInt(strings.TrimSpace(fields[1]), 10, 64)
_, expireErr := strconv.ParseInt(strings.TrimSpace(fields[3]), 10, 64)
_, releaseErr := strconv.ParseInt(strings.TrimSpace(fields[4]), 10, 64)
// 这里只判定归属,不判定过期;过期 token 仍必须转给 chatapp3让原项目按自己的错误码回复热游。
return userID > 0 && userErr == nil && expireErr == nil && releaseErr == nil
}
func normalizeChatapp3HotgameTokenCandidate(token string) string {
token = strings.TrimSpace(token)
if strings.HasPrefix(strings.ToLower(token), "bearer ") {
token = strings.TrimSpace(token[len("bearer "):])
}
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 || parts[1] == "" {
return token
}
switch len(parts[1]) % 4 {
case 2:
parts[1] += "=="
case 3:
parts[1] += "="
}
return strings.TrimSpace(parts[0]) + "." + strings.TrimSpace(parts[1])
}
func hotgameCompatProxyURL(operation string, rawQuery string) (string, error) {
parsed, err := url.Parse(hotgameCompatProxyDefaultBaseURL)
if err != nil {
return "", err
}
parsed.Path = singleJoiningSlash(parsed.Path, operation)
if strings.TrimSpace(rawQuery) != "" {
if parsed.RawQuery == "" {
parsed.RawQuery = rawQuery
} else {
parsed.RawQuery += "&" + rawQuery
}
}
return parsed.String(), nil
}
func singleJoiningSlash(left string, right string) string {
leftSlash := strings.HasSuffix(left, "/")
rightSlash := strings.HasPrefix(right, "/")
switch {
case leftSlash && rightSlash:
return left + strings.TrimLeft(right, "/")
case !leftSlash && !rightSlash:
return left + "/" + right
default:
return left + right
}
}
func copyHotgameProxyRequestHeaders(dst *http.Request, src *http.Request) {
for key, values := range src.Header {
if isHopByHopHeader(key) {
continue
}
for _, value := range values {
dst.Header.Add(key, value)
}
}
if dst.Header.Get("Content-Type") == "" {
dst.Header.Set("Content-Type", "application/json")
}
}
func appendHotgameForwardedHeaders(dst *http.Request, src *http.Request) {
if host := strings.TrimSpace(src.Host); host != "" {
dst.Header.Set("X-Forwarded-Host", host)
}
if proto := strings.TrimSpace(src.Header.Get("X-Forwarded-Proto")); proto != "" {
dst.Header.Set("X-Forwarded-Proto", proto)
} else if src.TLS != nil {
dst.Header.Set("X-Forwarded-Proto", "https")
} else {
dst.Header.Set("X-Forwarded-Proto", "http")
}
if ip := requestClientIP(src); ip != "" {
if prior := strings.TrimSpace(src.Header.Get("X-Forwarded-For")); prior != "" {
dst.Header.Set("X-Forwarded-For", prior+", "+ip)
} else {
dst.Header.Set("X-Forwarded-For", ip)
}
}
}
func requestClientIP(request *http.Request) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr))
if err == nil {
return host
}
return strings.TrimSpace(request.RemoteAddr)
}
func copyHotgameProxyResponseHeaders(dst http.Header, src http.Header) {
for key, values := range src {
if isHopByHopHeader(key) {
continue
}
for _, value := range values {
dst.Add(key, value)
}
}
if dst.Get("Content-Type") == "" {
dst.Set("Content-Type", "application/json")
}
}
func isHopByHopHeader(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
return true
default:
return false
}
}

View File

@ -95,6 +95,8 @@ type UserHandlers struct {
ChangeMyCountry http.HandlerFunc
ChangeMyDisplayUserID http.HandlerFunc
ApplyMyPrettyDisplayUserID http.HandlerFunc
ListAvailablePrettyDisplayIDs http.HandlerFunc
ApplyPrettyDisplayIDFromPool http.HandlerFunc
ListMyProfileVisitors http.HandlerFunc
ListMyFollowing http.HandlerFunc
ListMyFriends http.HandlerFunc
@ -205,6 +207,12 @@ type WalletHandlers struct {
GetWalletOverview http.HandlerFunc
GetMyBalances http.HandlerFunc
ListRechargeProducts http.HandlerFunc
GetH5RechargeContext http.HandlerFunc
ListH5RechargeOptions http.HandlerFunc
CreateH5RechargeOrder http.HandlerFunc
SubmitH5RechargeTx http.HandlerFunc
GetH5RechargeOrder http.HandlerFunc
HandleMifapayNotify http.HandlerFunc
ConfirmGooglePayment http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ListCoinTransactions http.HandlerFunc
@ -237,11 +245,20 @@ type VIPHandlers struct {
}
type GameHandlers struct {
ListGames http.HandlerFunc
ListRecentGames http.HandlerFunc
GetBridgeScript http.HandlerFunc
LaunchGame http.HandlerFunc
HandleCallback http.HandlerFunc
ListGames http.HandlerFunc
ListRecentGames http.HandlerFunc
GetBridgeScript http.HandlerFunc
LaunchGame http.HandlerFunc
GetDiceConfig http.HandlerFunc
MatchDice http.HandlerFunc
CreateDiceMatch http.HandlerFunc
JoinDiceMatch http.HandlerFunc
GetDiceMatch http.HandlerFunc
RollDiceMatch http.HandlerFunc
CancelDiceMatch http.HandlerFunc
HandleCallback http.HandlerFunc
HandleHotgameGetUserInfo http.HandlerFunc
HandleHotgameUpdateBalance http.HandlerFunc
}
type routes struct {
@ -275,6 +292,10 @@ func (r routes) public(path string, method string, next http.HandlerFunc) {
r.register(path, method, r.config.PublicWrap, next)
}
func (r routes) rootPublic(path string, method string, next http.HandlerFunc) {
r.registerRaw(path, method, r.config.PublicWrap, next)
}
func (r routes) auth(path string, method string, next http.HandlerFunc) {
r.register(path, method, r.config.AuthWrap, next)
}
@ -284,11 +305,15 @@ func (r routes) profile(path string, method string, next http.HandlerFunc) {
}
func (r routes) register(path string, method string, wrap Wrapper, next http.HandlerFunc) {
r.registerRaw(APIV1Prefix+path, method, wrap, next)
}
func (r routes) registerRaw(path string, method string, wrap Wrapper, next http.HandlerFunc) {
if method != "" {
// method 继续走 gateway envelope而不是使用 ServeMux 自动 405。
next = httpkit.RequireMethod(method, next)
}
r.mux.Handle(APIV1Prefix+path, wrap(next))
r.mux.Handle(path, wrap(next))
}
func (r routes) registerAuthRoutes() {
@ -361,6 +386,8 @@ func (r routes) registerUserRoutes() {
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
r.auth("/users/me/display-id/change", "", h.ChangeMyDisplayUserID)
r.auth("/users/me/display-id/pretty/apply", "", h.ApplyMyPrettyDisplayUserID)
r.auth("/users/pretty-ids/available", http.MethodGet, h.ListAvailablePrettyDisplayIDs)
r.auth("/users/pretty-ids/apply", http.MethodPost, h.ApplyPrettyDisplayIDFromPool)
r.profile("/users/me/visitors", http.MethodGet, h.ListMyProfileVisitors)
r.profile("/users/me/following", http.MethodGet, h.ListMyFollowing)
r.profile("/users/me/friends", http.MethodGet, h.ListMyFriends)
@ -477,6 +504,12 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/me/overview", http.MethodGet, h.GetWalletOverview)
r.profile("/wallet/me/balances", "", h.GetMyBalances)
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
r.public("/recharge/h5/context", http.MethodGet, h.GetH5RechargeContext)
r.public("/recharge/h5/options", http.MethodGet, h.ListH5RechargeOptions)
r.public("/recharge/h5/orders", http.MethodPost, h.CreateH5RechargeOrder)
r.public("/recharge/h5/orders/{order_id}/tx", http.MethodPost, h.SubmitH5RechargeTx)
r.public("/recharge/h5/orders/{order_id}", http.MethodGet, h.GetH5RechargeOrder)
r.public("/payment/mifapay/notify", http.MethodPost, h.HandleMifapayNotify)
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
@ -516,5 +549,15 @@ func (r routes) registerGameRoutes() {
r.profile("/games/recent", http.MethodGet, h.ListRecentGames)
r.profile("/games/bridge-script", http.MethodGet, h.GetBridgeScript)
r.profile("/games/{game_id}/launch", http.MethodPost, h.LaunchGame)
r.profile("/games/dice/config", http.MethodGet, h.GetDiceConfig)
r.profile("/games/dice/match", http.MethodPost, h.MatchDice)
r.profile("/games/dice/matches", http.MethodPost, h.CreateDiceMatch)
r.profile("/games/dice/matches/{match_id}/join", http.MethodPost, h.JoinDiceMatch)
r.profile("/games/dice/matches/{match_id}", http.MethodGet, h.GetDiceMatch)
r.profile("/games/dice/matches/{match_id}/roll", http.MethodPost, h.RollDiceMatch)
r.profile("/games/dice/matches/{match_id}/cancel", http.MethodPost, h.CancelDiceMatch)
// 热游后台只能配置根路径回调;这里仍复用 public wrapper让请求日志、request_id 和 app_code 解析保持一致。
r.rootPublic("/getUserInfo", http.MethodPost, h.HandleHotgameGetUserInfo)
r.rootPublic("/updateBalance", http.MethodPost, h.HandleHotgameUpdateBalance)
r.public("/game-callbacks/{platform_code}/{operation}", http.MethodPost, h.HandleCallback)
}

Some files were not shown because too many files have changed in this diff Show More