邀请活动修改

This commit is contained in:
zhx 2026-06-11 18:14:04 +08:00
parent b025ac4dda
commit f4eee4db4c
32 changed files with 2199 additions and 331 deletions

View File

@ -7329,15 +7329,17 @@ func (x *InviteActivityRewardTier) GetUpdatedAtMs() int64 {
// InviteActivityRewardConfig 是当前 App 的邀请活动配置;关闭时保留档位但 App 不允许领取。
type InviteActivityRewardConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*InviteActivityRewardTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"`
UpdatedByAdminId int64 `protobuf:"varint,4,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,6,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*InviteActivityRewardTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"`
UpdatedByAdminId int64 `protobuf:"varint,4,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,6,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
PerInviteInviterRewardCoinAmount int64 `protobuf:"varint,7,opt,name=per_invite_inviter_reward_coin_amount,json=perInviteInviterRewardCoinAmount,proto3" json:"per_invite_inviter_reward_coin_amount,omitempty"`
PerInviteInviteeRewardCoinAmount int64 `protobuf:"varint,8,opt,name=per_invite_invitee_reward_coin_amount,json=perInviteInviteeRewardCoinAmount,proto3" json:"per_invite_invitee_reward_coin_amount,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InviteActivityRewardConfig) Reset() {
@ -7412,6 +7414,20 @@ func (x *InviteActivityRewardConfig) GetUpdatedAtMs() int64 {
return 0
}
func (x *InviteActivityRewardConfig) GetPerInviteInviterRewardCoinAmount() int64 {
if x != nil {
return x.PerInviteInviterRewardCoinAmount
}
return 0
}
func (x *InviteActivityRewardConfig) GetPerInviteInviteeRewardCoinAmount() int64 {
if x != nil {
return x.PerInviteInviteeRewardCoinAmount
}
return 0
}
// InviteActivityRewardProgress 是邀请人在 UTC 自然月内的充值和有效邀请累计。
type InviteActivityRewardProgress struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -8313,13 +8329,15 @@ func (x *GetInviteActivityRewardConfigResponse) GetConfig() *InviteActivityRewar
}
type UpdateInviteActivityRewardConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*InviteActivityRewardTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"`
OperatorAdminId int64 `protobuf:"varint,4,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*InviteActivityRewardTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"`
OperatorAdminId int64 `protobuf:"varint,4,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"`
PerInviteInviterRewardCoinAmount int64 `protobuf:"varint,5,opt,name=per_invite_inviter_reward_coin_amount,json=perInviteInviterRewardCoinAmount,proto3" json:"per_invite_inviter_reward_coin_amount,omitempty"`
PerInviteInviteeRewardCoinAmount int64 `protobuf:"varint,6,opt,name=per_invite_invitee_reward_coin_amount,json=perInviteInviteeRewardCoinAmount,proto3" json:"per_invite_invitee_reward_coin_amount,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateInviteActivityRewardConfigRequest) Reset() {
@ -8380,6 +8398,20 @@ func (x *UpdateInviteActivityRewardConfigRequest) GetOperatorAdminId() int64 {
return 0
}
func (x *UpdateInviteActivityRewardConfigRequest) GetPerInviteInviterRewardCoinAmount() int64 {
if x != nil {
return x.PerInviteInviterRewardCoinAmount
}
return 0
}
func (x *UpdateInviteActivityRewardConfigRequest) GetPerInviteInviteeRewardCoinAmount() int64 {
if x != nil {
return x.PerInviteInviteeRewardCoinAmount
}
return 0
}
type UpdateInviteActivityRewardConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Config *InviteActivityRewardConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"`
@ -18783,14 +18815,16 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"sort_order\x18\t \x01(\x05R\tsortOrder\x12\"\n" +
"\rcreated_at_ms\x18\n" +
" \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\x8b\x02\n" +
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\xad\x03\n" +
"\x1aInviteActivityRewardConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12A\n" +
"\x05tiers\x18\x03 \x03(\v2+.hyapp.activity.v1.InviteActivityRewardTierR\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\"\xbb\x02\n" +
"\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\x12O\n" +
"%per_invite_inviter_reward_coin_amount\x18\a \x01(\x03R perInviteInviterRewardCoinAmount\x12O\n" +
"%per_invite_invitee_reward_coin_amount\x18\b \x01(\x03R perInviteInviteeRewardCoinAmount\"\xbb\x02\n" +
"\x1cInviteActivityRewardProgress\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x1b\n" +
"\tcycle_key\x18\x02 \x01(\tR\bcycleKey\x12\x17\n" +
@ -18865,12 +18899,14 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"$GetInviteActivityRewardConfigRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\"n\n" +
"%GetInviteActivityRewardConfigResponse\x12E\n" +
"\x06config\x18\x01 \x01(\v2-.hyapp.activity.v1.InviteActivityRewardConfigR\x06config\"\xe6\x01\n" +
"\x06config\x18\x01 \x01(\v2-.hyapp.activity.v1.InviteActivityRewardConfigR\x06config\"\x88\x03\n" +
"'UpdateInviteActivityRewardConfigRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12A\n" +
"\x05tiers\x18\x03 \x03(\v2+.hyapp.activity.v1.InviteActivityRewardTierR\x05tiers\x12*\n" +
"\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"q\n" +
"\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\x12O\n" +
"%per_invite_inviter_reward_coin_amount\x18\x05 \x01(\x03R perInviteInviterRewardCoinAmount\x12O\n" +
"%per_invite_invitee_reward_coin_amount\x18\x06 \x01(\x03R perInviteInviteeRewardCoinAmount\"q\n" +
"(UpdateInviteActivityRewardConfigResponse\x12E\n" +
"\x06config\x18\x01 \x01(\v2-.hyapp.activity.v1.InviteActivityRewardConfigR\x06config\"\xfb\x01\n" +
"%ListInviteActivityRewardClaimsRequest\x122\n" +

View File

@ -839,6 +839,8 @@ message InviteActivityRewardConfig {
int64 updated_by_admin_id = 4;
int64 created_at_ms = 5;
int64 updated_at_ms = 6;
int64 per_invite_inviter_reward_coin_amount = 7;
int64 per_invite_invitee_reward_coin_amount = 8;
}
// InviteActivityRewardProgress UTC
@ -944,6 +946,8 @@ message UpdateInviteActivityRewardConfigRequest {
bool enabled = 2;
repeated InviteActivityRewardTier tiers = 3;
int64 operator_admin_id = 4;
int64 per_invite_inviter_reward_coin_amount = 5;
int64 per_invite_invitee_reward_coin_amount = 6;
}
message UpdateInviteActivityRewardConfigResponse {

View File

@ -1363,6 +1363,7 @@ type DiceParticipant struct {
UpdatedAtMs int64 `protobuf:"varint,10,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
ParticipantType string `protobuf:"bytes,11,opt,name=participant_type,json=participantType,proto3" json:"participant_type,omitempty"`
IsRobot bool `protobuf:"varint,12,opt,name=is_robot,json=isRobot,proto3" json:"is_robot,omitempty"`
RpsGesture string `protobuf:"bytes,13,opt,name=rps_gesture,json=rpsGesture,proto3" json:"rps_gesture,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1481,6 +1482,13 @@ func (x *DiceParticipant) GetIsRobot() bool {
return false
}
func (x *DiceParticipant) GetRpsGesture() string {
if x != nil {
return x.RpsGesture
}
return ""
}
type DiceMatch struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
@ -3179,6 +3187,7 @@ type CreateDiceMatchRequest struct {
StakeCoin int64 `protobuf:"varint,6,opt,name=stake_coin,json=stakeCoin,proto3" json:"stake_coin,omitempty"`
MinPlayers int32 `protobuf:"varint,7,opt,name=min_players,json=minPlayers,proto3" json:"min_players,omitempty"`
MaxPlayers int32 `protobuf:"varint,8,opt,name=max_players,json=maxPlayers,proto3" json:"max_players,omitempty"`
RpsGesture string `protobuf:"bytes,9,opt,name=rps_gesture,json=rpsGesture,proto3" json:"rps_gesture,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3269,11 +3278,20 @@ func (x *CreateDiceMatchRequest) GetMaxPlayers() int32 {
return 0
}
func (x *CreateDiceMatchRequest) GetRpsGesture() string {
if x != nil {
return x.RpsGesture
}
return ""
}
type JoinDiceMatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
MatchId string `protobuf:"bytes,3,opt,name=match_id,json=matchId,proto3" json:"match_id,omitempty"`
GameId string `protobuf:"bytes,4,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
RpsGesture string `protobuf:"bytes,5,opt,name=rps_gesture,json=rpsGesture,proto3" json:"rps_gesture,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3329,11 +3347,26 @@ func (x *JoinDiceMatchRequest) GetMatchId() string {
return ""
}
func (x *JoinDiceMatchRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
func (x *JoinDiceMatchRequest) GetRpsGesture() string {
if x != nil {
return x.RpsGesture
}
return ""
}
type GetDiceMatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
MatchId string `protobuf:"bytes,3,opt,name=match_id,json=matchId,proto3" json:"match_id,omitempty"`
GameId string `protobuf:"bytes,4,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3389,11 +3422,20 @@ func (x *GetDiceMatchRequest) GetMatchId() string {
return ""
}
func (x *GetDiceMatchRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
type RollDiceMatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
MatchId string `protobuf:"bytes,3,opt,name=match_id,json=matchId,proto3" json:"match_id,omitempty"`
GameId string `protobuf:"bytes,4,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
RpsGesture string `protobuf:"bytes,5,opt,name=rps_gesture,json=rpsGesture,proto3" json:"rps_gesture,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3449,6 +3491,20 @@ func (x *RollDiceMatchRequest) GetMatchId() string {
return ""
}
func (x *RollDiceMatchRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
func (x *RollDiceMatchRequest) GetRpsGesture() string {
if x != nil {
return x.RpsGesture
}
return ""
}
type MatchDiceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -3457,6 +3513,7 @@ type MatchDiceRequest struct {
RoomId string `protobuf:"bytes,4,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
StakeCoin int64 `protobuf:"varint,6,opt,name=stake_coin,json=stakeCoin,proto3" json:"stake_coin,omitempty"`
RpsGesture string `protobuf:"bytes,7,opt,name=rps_gesture,json=rpsGesture,proto3" json:"rps_gesture,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3533,11 +3590,19 @@ func (x *MatchDiceRequest) GetStakeCoin() int64 {
return 0
}
func (x *MatchDiceRequest) GetRpsGesture() string {
if x != nil {
return x.RpsGesture
}
return ""
}
type CancelDiceMatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
MatchId string `protobuf:"bytes,3,opt,name=match_id,json=matchId,proto3" json:"match_id,omitempty"`
GameId string `protobuf:"bytes,4,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3593,6 +3658,13 @@ func (x *CancelDiceMatchRequest) GetMatchId() string {
return ""
}
func (x *CancelDiceMatchRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
type DiceMatchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Match *DiceMatch `protobuf:"bytes,1,opt,name=match,proto3" json:"match,omitempty"`
@ -5628,7 +5700,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\vorientation\x18\x04 \x01(\tR\vorientation\x12$\n" +
"\x0eserver_time_ms\x18\x05 \x01(\x03R\fserverTimeMs\x12\x1f\n" +
"\vsafe_height\x18\x06 \x01(\x05R\n" +
"safeHeight\"\x85\x03\n" +
"safeHeight\"\xa6\x03\n" +
"\x0fDiceParticipant\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" +
"\aseat_no\x18\x02 \x01(\x05R\x06seatNo\x12\x16\n" +
@ -5646,7 +5718,9 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\rupdated_at_ms\x18\n" +
" \x01(\x03R\vupdatedAtMs\x12)\n" +
"\x10participant_type\x18\v \x01(\tR\x0fparticipantType\x12\x19\n" +
"\bis_robot\x18\f \x01(\bR\aisRobot\"\xe7\x06\n" +
"\bis_robot\x18\f \x01(\bR\aisRobot\x12\x1f\n" +
"\vrps_gesture\x18\r \x01(\tR\n" +
"rpsGesture\"\xe7\x06\n" +
"\tDiceMatch\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x19\n" +
"\bmatch_id\x18\x02 \x01(\tR\amatchId\x12\x17\n" +
@ -5810,7 +5884,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\fchallenge_id\x18\x02 \x01(\tR\vchallengeId\"r\n" +
"\x1dExpireRoomRPSChallengeRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12!\n" +
"\fchallenge_id\x18\x02 \x01(\tR\vchallengeId\"\x91\x02\n" +
"\fchallenge_id\x18\x02 \x01(\tR\vchallengeId\"\xb2\x02\n" +
"\x16CreateDiceMatchRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
@ -5822,19 +5896,28 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\vmin_players\x18\a \x01(\x05R\n" +
"minPlayers\x12\x1f\n" +
"\vmax_players\x18\b \x01(\x05R\n" +
"maxPlayers\"z\n" +
"maxPlayers\x12\x1f\n" +
"\vrps_gesture\x18\t \x01(\tR\n" +
"rpsGesture\"\xb4\x01\n" +
"\x14JoinDiceMatchRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\"y\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\x12\x17\n" +
"\agame_id\x18\x04 \x01(\tR\x06gameId\x12\x1f\n" +
"\vrps_gesture\x18\x05 \x01(\tR\n" +
"rpsGesture\"\x92\x01\n" +
"\x13GetDiceMatchRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\"z\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\x12\x17\n" +
"\agame_id\x18\x04 \x01(\tR\x06gameId\"\xb4\x01\n" +
"\x14RollDiceMatchRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\"\xc9\x01\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\x12\x17\n" +
"\agame_id\x18\x04 \x01(\tR\x06gameId\x12\x1f\n" +
"\vrps_gesture\x18\x05 \x01(\tR\n" +
"rpsGesture\"\xea\x01\n" +
"\x10MatchDiceRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
@ -5842,11 +5925,14 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1b\n" +
"\tregion_id\x18\x05 \x01(\x03R\bregionId\x12\x1d\n" +
"\n" +
"stake_coin\x18\x06 \x01(\x03R\tstakeCoin\"|\n" +
"stake_coin\x18\x06 \x01(\x03R\tstakeCoin\x12\x1f\n" +
"\vrps_gesture\x18\a \x01(\tR\n" +
"rpsGesture\"\x95\x01\n" +
"\x16CancelDiceMatchRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\"i\n" +
"\bmatch_id\x18\x03 \x01(\tR\amatchId\x12\x17\n" +
"\agame_id\x18\x04 \x01(\tR\x06gameId\"i\n" +
"\x11DiceMatchResponse\x12.\n" +
"\x05match\x18\x01 \x01(\v2\x18.hyapp.game.v1.DiceMatchR\x05match\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xbe\x03\n" +

View File

@ -164,6 +164,7 @@ message DiceParticipant {
int64 updated_at_ms = 10;
string participant_type = 11;
bool is_robot = 12;
string rps_gesture = 13;
}
message DiceMatch {
@ -356,24 +357,30 @@ message CreateDiceMatchRequest {
int64 stake_coin = 6;
int32 min_players = 7;
int32 max_players = 8;
string rps_gesture = 9;
}
message JoinDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
string rps_gesture = 5;
}
message GetDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
}
message RollDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
string rps_gesture = 5;
}
message MatchDiceRequest {
@ -383,12 +390,14 @@ message MatchDiceRequest {
string room_id = 4;
int64 region_id = 5;
int64 stake_coin = 6;
string rps_gesture = 7;
}
message CancelDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
}
message DiceMatchResponse {

View File

@ -0,0 +1,339 @@
# H5 自研猜拳接入接口
本文只记录独立自研猜拳 `rock/rps` 的 H5 接口。所有地址都有 `/api/v1` 前缀,所有接口都需要 `Authorization: Bearer <access_token>`
成功响应统一格式:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
时间字段都是 Unix epoch milliseconds。用户 ID 建议 H5 按字符串字段使用。
## 1. 获取游戏列表
地址:
```http
GET /api/v1/games
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `scene` | query | 否 | 场景,语音房传 `voice_room`。 |
| `room_id` | query | 否 | 房间 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `games` | array | 游戏列表。 |
| `games[].game_id` | string | 猜拳是 `rock`。 |
| `games[].name` | string | 游戏名称。 |
| `games[].launch_mode` | string | H5 打开方式。 |
| `games[].enabled` | bool | 是否可进入。 |
| `games[].maintenance` | bool | 是否维护中。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 2. 创建 H5 启动会话
地址:
```http
POST /api/v1/games/rock/launch
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `scene` | string | 否 | 场景,语音房传 `voice_room`。 |
| `room_id` | string | 否 | 房间 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `session_id` | string | H5 启动会话 ID。 |
| `launch_url` | string | H5 页面地址。真实地址由后台配置,不在客户端拼。 |
| `expires_at_ms` | number | 启动会话过期时间。 |
| `orientation` | string | 页面方向。 |
| `safe_height` | number | 安全高度。 |
| `safeHeight` | number | 同 `safe_height`。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 3. 查询猜拳配置
地址:
```http
GET /api/v1/games/rps/config
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `game_id` | query | 否 | 固定是 `rock`,不传也按 `rock` 处理。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `config.game_id` | string | 固定返回 `rock`。 |
| `config.status` | string | `active` 才能玩。 |
| `config.stake_options` | array | 可下注金币档位。 |
| `config.fee_bps` | number | 平台抽水,万分比。 |
| `config.pool_bps` | number | 奖池入池,万分比。 |
| `config.min_players` | number | 最少人数。 |
| `config.max_players` | number | 最多人数。 |
| `config.robot_enabled` | bool | 是否启用机器人补位。 |
| `config.robot_match_wait_ms` | number | 等待机器人补位时间。 |
| `config.pool_balance_coin` | number | 当前奖池余额。 |
| `server_time_ms` | number | 服务端时间。 |
`stake_options` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `stake_coin` | number | 下注金币。 |
| `enabled` | bool | 是否可选。 |
| `sort_order` | number | 排序。 |
相关 IM无。
## 4. 快速匹配
地址:
```http
POST /api/v1/games/rps/match
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `stake_coin` | number | 是 | 下注金币,必须是配置里的启用档位。 |
| `gesture` | string | 是 | 本人提前选择的手势:`rock``paper``scissors`。 |
| `rps_gesture` | string | 否 | 同 `gesture`,两个字段传一个即可。 |
| `game_id` | string | 否 | 固定是 `rock`。 |
| `room_id` | string | 否 | 房间 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。H5 可以轮询详情,也可以等后续接入 IM 后按 IM 刷新。
## 5. 创建等待局
地址:
```http
POST /api/v1/games/rps/matches
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `stake_coin` | number | 是 | 下注金币。 |
| `gesture` | string | 是 | 本人提前选择的手势。 |
| `rps_gesture` | string | 否 | 同 `gesture`。 |
| `game_id` | string | 否 | 固定是 `rock`。 |
| `room_id` | string | 否 | 房间 ID。 |
| `min_players` | number | 否 | 最少人数,不传用配置。 |
| `max_players` | number | 否 | 最多人数,不传用配置。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 6. 加入等待局
地址:
```http
POST /api/v1/games/rps/matches/{match_id}/join
Content-Type: application/json
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
| `gesture` | body | 是 | 本人提前选择的手势。 |
| `rps_gesture` | body | 否 | 同 `gesture`。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 7. 查询局详情
地址:
```http
GET /api/v1/games/rps/matches/{match_id}
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 8. 揭晓并结算
地址:
```http
POST /api/v1/games/rps/matches/{match_id}/roll
Content-Type: application/json
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
| `gesture` | body | 否 | 可以不传;如果传,必须等于创建或加入时的预选手势。 |
| `rps_gesture` | body | 否 | 同 `gesture`。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 已结算局详情。 |
| `server_time_ms` | number | 服务端时间。 |
说明:猜拳不会随机出拳。服务端用双方提前选择的 `gesture` 比输赢,然后扣款、派奖和更新余额。
相关 IM无。
## 9. 取消等待局
地址:
```http
POST /api/v1/games/rps/matches/{match_id}/cancel
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 取消后的局详情。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 10. 局详情字段
`match` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match_id` | string | 局 ID。 |
| `game_id` | string | 固定是 `rock`。 |
| `room_id` | string | 房间 ID。 |
| `region_id` | number | 用户区域 ID。 |
| `min_players` | number | 最少人数。 |
| `max_players` | number | 最多人数。 |
| `current_players` | number | 当前人数。 |
| `stake_coin` | number | 本局下注金币。 |
| `round_no` | number | 当前轮次。 |
| `status` | string | `created``ready``settled``canceled`。 |
| `phase` | string | `waiting``countdown``settled``canceled`。 |
| `phase_deadline_ms` | number | 当前阶段截止时间。 |
| `result` | string | 当前用户视角结果:`win``lose``draw`。 |
| `participants` | array | 参与者列表。 |
| `join_deadline_ms` | number | 等待加入截止时间。 |
| `ready_at_ms` | number | 匹配完成时间。 |
| `settled_at_ms` | number | 结算时间。 |
| `canceled_at_ms` | number | 取消时间。 |
| `fee_bps` | number | 平台抽水。 |
| `pool_bps` | number | 奖池入池。 |
| `match_mode` | string | `human``robot`。 |
| `forced_result` | string | 机器人奖池不足时的强制结果。 |
| `pool_delta_coin` | number | 本局奖池变化。 |
`participants` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户 IDH5 优先用这个字段。 |
| `user_id_number` | number | 用户 ID 数字值。 |
| `seat_no` | number | 座位号。 |
| `status` | string | `joined``settled``canceled`。 |
| `stake_coin` | number | 下注金币。 |
| `gesture` | string | 预选手势。 |
| `rps_gesture` | string | 同 `gesture`。 |
| `result` | string | `win``lose``draw`。 |
| `payout_coin` | number | 派彩金币。 |
| `balance_after` | number | 结算后余额。 |
| `joined_at_ms` | number | 加入时间。 |
| `updated_at_ms` | number | 更新时间。 |
| `participant_type` | string | `user``robot`。 |
| `is_robot` | bool | 是否机器人。 |
| `user.display_user_id` | string | 展示 ID。 |
| `user.nickname` | string | 昵称。 |
| `user.avatar` | string | 头像。 |
| `avatar_frame` | object | 头像框资源。 |
## 11. H5 推荐流程
1. App 调 `GET /api/v1/games`,看到 `game_id=rock` 后展示入口。
2. App 调 `POST /api/v1/games/rock/launch` 打开 H5。
3. H5 调 `GET /api/v1/games/rps/config` 展示下注档位。
4. 用户先选择 `rock``paper``scissors`
5. H5 调 `POST /api/v1/games/rps/match` 快速匹配。
6. 返回 `phase=countdown`H5 用 `phase_deadline_ms` 做倒计时。
7. 倒计时结束后H5 调 `POST /api/v1/games/rps/matches/{match_id}/roll` 揭晓并结算。
8. H5 读返回的 `participants[].gesture/result/payout_coin/balance_after` 展示结果。
相关 IM当前独立猜拳没有 IM。H5 用 HTTP 返回和详情轮询刷新。

View File

@ -27,17 +27,21 @@ func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationL
}
type configRequest struct {
Enabled bool `json:"enabled"`
Tiers []tierDTO `json:"tiers"`
Enabled bool `json:"enabled"`
PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"`
PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"`
Tiers []tierDTO `json:"tiers"`
}
type configDTO struct {
AppCode string `json:"app_code"`
Enabled bool `json:"enabled"`
Tiers []tierDTO `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
AppCode string `json:"app_code"`
Enabled bool `json:"enabled"`
PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"`
PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"`
Tiers []tierDTO `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type tierDTO struct {
@ -116,10 +120,12 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
})
}
resp, err := h.activity.UpdateInviteActivityRewardConfig(c.Request.Context(), &activityv1.UpdateInviteActivityRewardConfigRequest{
Meta: h.meta(c),
Enabled: req.Enabled,
Tiers: tiers,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
Meta: h.meta(c),
Enabled: req.Enabled,
PerInviteInviterRewardCoinAmount: req.PerInviteInviterRewardCoinAmount,
PerInviteInviteeRewardCoinAmount: req.PerInviteInviteeRewardCoinAmount,
Tiers: tiers,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
@ -264,12 +270,14 @@ func configFromProto(config *activityv1.InviteActivityRewardConfig) configDTO {
tiers = append(tiers, tierFromProto(tier))
}
return configDTO{
AppCode: config.GetAppCode(),
Enabled: config.GetEnabled(),
Tiers: tiers,
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
AppCode: config.GetAppCode(),
Enabled: config.GetEnabled(),
PerInviteInviterRewardCoinAmount: config.GetPerInviteInviterRewardCoinAmount(),
PerInviteInviteeRewardCoinAmount: config.GetPerInviteInviteeRewardCoinAmount(),
Tiers: tiers,
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
}
}

View File

@ -1286,6 +1286,8 @@ CREATE TABLE IF NOT EXISTS weekly_star_settlements (
CREATE TABLE IF NOT EXISTS invite_activity_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用邀请活动',
per_invite_inviter_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人邀请人即时奖励金币',
per_invite_invitee_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人被邀请人即时奖励金币',
updated_by_admin_id 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',
@ -1313,7 +1315,7 @@ CREATE TABLE IF NOT EXISTS invite_activity_reward_tiers (
CREATE TABLE IF NOT EXISTS invite_activity_reward_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
event_id VARCHAR(128) NOT NULL COMMENT '上游 outbox event_id',
event_type VARCHAR(64) NOT NULL COMMENT 'WalletRechargeRecorded/UserInviteBecameValid',
event_type VARCHAR(64) NOT NULL COMMENT 'WalletRechargeRecorded/UserInvited/UserInviteBecameValid',
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然月,如 2026-06',
invited_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '被邀请人用户 ID',
inviter_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '邀请人用户 ID',
@ -1349,11 +1351,11 @@ CREATE TABLE IF NOT EXISTS invite_activity_reward_claims (
claim_id VARCHAR(128) NOT NULL COMMENT '领取 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然月,如 2026-06',
user_id BIGINT NOT NULL COMMENT '邀请人用户 ID',
reward_type VARCHAR(32) NOT NULL COMMENT 'recharge/valid_invite',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 领取命令 ID',
tier_id BIGINT NOT NULL COMMENT '领取档位 ID',
tier_code VARCHAR(64) NOT NULL COMMENT '领取档位编码',
tier_name VARCHAR(128) NOT NULL COMMENT '领取档位名称',
reward_type VARCHAR(32) NOT NULL COMMENT 'recharge/valid_invite/invite_inviter/invite_invitee',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 领取或系统即时发奖命令 ID',
tier_id BIGINT NOT NULL COMMENT '领取档位 ID;即时邀请奖励使用对方用户 ID 做幂等维度',
tier_code VARCHAR(64) NOT NULL COMMENT '领取档位编码或即时奖励类型',
tier_name VARCHAR(128) NOT NULL COMMENT '领取档位名称或即时奖励名称',
threshold_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '累计充值金币门槛',
threshold_valid_invite_count BIGINT NOT NULL DEFAULT 0 COMMENT '有效邀请人数门槛',
reached_value BIGINT NOT NULL DEFAULT 0 COMMENT '领取时已达成值',

View File

@ -3,6 +3,8 @@ USE hyapp_activity;
CREATE TABLE IF NOT EXISTS invite_activity_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用邀请活动',
per_invite_inviter_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人邀请人即时奖励金币',
per_invite_invitee_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人被邀请人即时奖励金币',
updated_by_admin_id 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',
@ -30,7 +32,7 @@ CREATE TABLE IF NOT EXISTS invite_activity_reward_tiers (
CREATE TABLE IF NOT EXISTS invite_activity_reward_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
event_id VARCHAR(128) NOT NULL COMMENT '上游 outbox event_id',
event_type VARCHAR(64) NOT NULL COMMENT 'WalletRechargeRecorded/UserInviteBecameValid',
event_type VARCHAR(64) NOT NULL COMMENT 'WalletRechargeRecorded/UserInvited/UserInviteBecameValid',
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然月,如 2026-06',
invited_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '被邀请人用户 ID',
inviter_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '邀请人用户 ID',
@ -66,11 +68,11 @@ CREATE TABLE IF NOT EXISTS invite_activity_reward_claims (
claim_id VARCHAR(128) NOT NULL COMMENT '领取 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然月,如 2026-06',
user_id BIGINT NOT NULL COMMENT '邀请人用户 ID',
reward_type VARCHAR(32) NOT NULL COMMENT 'recharge/valid_invite',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 领取命令 ID',
tier_id BIGINT NOT NULL COMMENT '领取档位 ID',
tier_code VARCHAR(64) NOT NULL COMMENT '领取档位编码',
tier_name VARCHAR(128) NOT NULL COMMENT '领取档位名称',
reward_type VARCHAR(32) NOT NULL COMMENT 'recharge/valid_invite/invite_inviter/invite_invitee',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 领取或系统即时发奖命令 ID',
tier_id BIGINT NOT NULL COMMENT '领取档位 ID;即时邀请奖励使用对方用户 ID 做幂等维度',
tier_code VARCHAR(64) NOT NULL COMMENT '领取档位编码或即时奖励类型',
tier_name VARCHAR(128) NOT NULL COMMENT '领取档位名称或即时奖励名称',
threshold_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '累计充值金币门槛',
threshold_valid_invite_count BIGINT NOT NULL DEFAULT 0 COMMENT '有效邀请人数门槛',
reached_value BIGINT NOT NULL DEFAULT 0 COMMENT '领取时已达成值',

View File

@ -0,0 +1,25 @@
USE hyapp_activity;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'invite_activity_reward_configs'
AND COLUMN_NAME = 'per_invite_inviter_reward_coin_amount') = 0,
'ALTER TABLE invite_activity_reward_configs ADD COLUMN per_invite_inviter_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''每邀请一人邀请人即时奖励金币'' AFTER enabled',
'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 = 'invite_activity_reward_configs'
AND COLUMN_NAME = 'per_invite_invitee_reward_coin_amount') = 0,
'ALTER TABLE invite_activity_reward_configs ADD COLUMN per_invite_invitee_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''每邀请一人被邀请人即时奖励金币'' AFTER per_invite_inviter_reward_coin_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -207,12 +207,19 @@ func newInviteActivityWalletConsumer(cfg config.Config, services *serviceBundle)
}
func newInviteActivityUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
// 有效邀请人数来自 user-service 已提交事实;消费失败必须让同一 outbox 重试,仓储按 event_id 保证幂等。
// 邀请绑定和有效邀请都来自 user-service 已提交事实;消费失败必须让同一 outbox 重试,仓储和钱包命令共同保证幂等。
consumer, err := rocketmqx.NewConsumer(userOutboxInviteActivityConsumerConfig(cfg.RocketMQ))
if err != nil {
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
inviteEvent, inviteOK, err := inviteActivityInviteEventFromUserMessage(message.Body)
if err != nil {
return err
}
if inviteOK {
return services.inviteActivityReward.ConsumeInviteEvent(appcode.WithContext(ctx, inviteEvent.AppCode), inviteEvent)
}
event, ok, err := inviteActivityValidInviteEventFromUserMessage(message.Body)
if err != nil || !ok {
return err

View File

@ -35,3 +35,33 @@ func inviteActivityValidInviteEventFromUserMessage(body []byte) (inviteactivityd
OccurredAtMS: message.OccurredAtMS,
}, true, nil
}
func inviteActivityInviteEventFromUserMessage(body []byte) (inviteactivitydomain.InviteEvent, bool, error) {
message, err := usermq.DecodeUserOutboxMessage(body)
if err != nil {
return inviteactivitydomain.InviteEvent{}, false, err
}
if message.EventType != inviteactivitydomain.EventTypeUserInvited {
return inviteactivitydomain.InviteEvent{}, false, nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(message.PayloadJSON), &decoded); err != nil || decoded == nil {
decoded = map[string]any{}
}
invitedUserID := firstNonZeroInt64(int64FromDecoded(decoded, "invited_user_id"), message.AggregateID)
inviterUserID := int64FromDecoded(decoded, "inviter_user_id")
if invitedUserID <= 0 || inviterUserID <= 0 {
return inviteactivitydomain.InviteEvent{}, false, nil
}
return inviteactivitydomain.InviteEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
EventType: message.EventType,
InviterUserID: inviterUserID,
InvitedUserID: invitedUserID,
InviteCode: stringFromDecoded(decoded, "invite_code"),
Source: stringFromDecoded(decoded, "source"),
PayloadJSON: message.PayloadJSON,
OccurredAtMS: message.OccurredAtMS,
}, true, nil
}

View File

@ -4,8 +4,10 @@ const (
TierStatusActive = "active"
TierStatusInactive = "inactive"
RewardTypeRecharge = "recharge"
RewardTypeValidInvite = "valid_invite"
RewardTypeRecharge = "recharge"
RewardTypeValidInvite = "valid_invite"
RewardTypeInviteInviter = "invite_inviter"
RewardTypeInvitee = "invite_invitee"
ClaimStatusPending = "pending"
ClaimStatusGranted = "granted"
@ -17,6 +19,7 @@ const (
ReasonDuplicate = "duplicate"
EventTypeWalletRechargeRecorded = "WalletRechargeRecorded"
EventTypeUserInvited = "UserInvited"
EventTypeUserInviteBecameValid = "UserInviteBecameValid"
)
@ -38,12 +41,14 @@ type Tier struct {
// Config 是当前 App 的邀请活动配置。
type Config struct {
AppCode string
Enabled bool
Tiers []Tier
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
AppCode string
Enabled bool
PerInviteInviterRewardCoinAmount int64
PerInviteInviteeRewardCoinAmount int64
Tiers []Tier
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Progress 是邀请人在一个 UTC 自然月内的邀请活动累计。
@ -107,6 +112,19 @@ type ValidInviteEvent struct {
OccurredAtMS int64
}
// InviteEvent 是 user_outbox 里“邀请关系首次绑定”的事实;它不依赖被邀请人后续是否充值达标。
type InviteEvent struct {
AppCode string
EventID string
EventType string
InvitedUserID int64
InviterUserID int64
InviteCode string
Source string
PayloadJSON string
OccurredAtMS int64
}
// Cycle 表达 UTC 自然月周期边界。
type Cycle struct {
Key string

View File

@ -2,6 +2,8 @@ package inviteactivity
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"sort"
"strings"
@ -23,6 +25,7 @@ type Repository interface {
GetInviteActivityRewardProgress(ctx context.Context, cycleKey string, userID int64) (domain.Progress, bool, error)
ListInviteActivityRewardClaimsByUserCycle(ctx context.Context, cycleKey string, userID int64) ([]domain.Claim, error)
ConsumeInviteActivityRechargeEvent(ctx context.Context, event domain.RechargeEvent, cycle domain.Cycle, nowMS int64) (domain.ConsumeResult, error)
RecordInviteActivityInviteEvent(ctx context.Context, event domain.InviteEvent, cycle domain.Cycle, status string, skipReason string, nowMS int64) (domain.ConsumeResult, error)
ConsumeInviteActivityValidInviteEvent(ctx context.Context, event domain.ValidInviteEvent, cycle domain.Cycle, nowMS int64) (domain.ConsumeResult, error)
CreateInviteActivityRewardClaim(ctx context.Context, claim domain.Claim, nowMS int64) (domain.Claim, error)
MarkInviteActivityRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (domain.Claim, error)
@ -159,6 +162,60 @@ func (s *Service) ConsumeValidInviteEvent(ctx context.Context, event domain.Vali
return err
}
func (s *Service) ConsumeInviteEvent(ctx context.Context, event domain.InviteEvent) error {
if err := s.requireRepository(); err != nil {
return err
}
if event.EventType != domain.EventTypeUserInvited || event.EventID == "" || event.InviterUserID <= 0 || event.InvitedUserID <= 0 {
return nil
}
if event.OccurredAtMS <= 0 {
event.OccurredAtMS = s.now().UTC().UnixMilli()
}
event.AppCode = appcode.Normalize(event.AppCode)
event.InviteCode = strings.TrimSpace(event.InviteCode)
event.Source = strings.TrimSpace(event.Source)
cycle := CycleForTime(time.UnixMilli(event.OccurredAtMS).UTC())
eventCtx := appcode.WithContext(ctx, event.AppCode)
config, exists, err := s.repository.GetInviteActivityRewardConfig(eventCtx)
if err != nil {
return err
}
if !exists {
config = domain.Config{AppCode: event.AppCode, Enabled: true}
}
if !config.Enabled {
_, err := s.repository.RecordInviteActivityInviteEvent(eventCtx, event, cycle, "skipped", "invite activity is disabled", s.now().UTC().UnixMilli())
return err
}
if config.PerInviteInviterRewardCoinAmount <= 0 && config.PerInviteInviteeRewardCoinAmount <= 0 {
_, err := s.repository.RecordInviteActivityInviteEvent(eventCtx, event, cycle, "skipped", "per invite reward is not configured", s.now().UTC().UnixMilli())
return err
}
if s.wallet == nil {
return xerr.New(xerr.Unavailable, "wallet client is not configured")
}
// 事件表只做审计和去重观察;真正的钱包幂等落在 deterministic claim_id + wallet_command_id 上。
// 因此 MQ 重试时即使事件已存在,也会继续复用同一 claim 重试未完成的钱包入账。
if _, err := s.repository.RecordInviteActivityInviteEvent(eventCtx, event, cycle, "consumed", "", s.now().UTC().UnixMilli()); err != nil {
return err
}
if config.PerInviteInviterRewardCoinAmount > 0 {
claim := perInviteClaim(event, cycle, domain.RewardTypeInviteInviter, event.InviterUserID, event.InvitedUserID, config.PerInviteInviterRewardCoinAmount)
if _, err := s.grantClaim(eventCtx, claim); err != nil {
return err
}
}
if config.PerInviteInviteeRewardCoinAmount > 0 {
claim := perInviteClaim(event, cycle, domain.RewardTypeInvitee, event.InvitedUserID, event.InviterUserID, config.PerInviteInviteeRewardCoinAmount)
if _, err := s.grantClaim(eventCtx, claim); err != nil {
return err
}
}
return nil
}
func (s *Service) Claim(ctx context.Context, userID int64, rewardType string, tierID int64, commandID string) (domain.Claim, domain.StatusResult, error) {
if err := s.requireRepository(); err != nil {
return domain.Claim{}, domain.StatusResult{}, err
@ -166,7 +223,7 @@ func (s *Service) Claim(ctx context.Context, userID int64, rewardType string, ti
if s.wallet == nil {
return domain.Claim{}, domain.StatusResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
rewardType = normalizeRewardType(rewardType)
rewardType = normalizeTierRewardType(rewardType)
commandID = strings.TrimSpace(commandID)
if userID <= 0 || rewardType == "" || tierID <= 0 || commandID == "" {
return domain.Claim{}, domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "claim request is incomplete")
@ -206,32 +263,7 @@ func (s *Service) Claim(ctx context.Context, userID int64, rewardType string, ti
RewardCoinAmount: rewardAmount,
WalletCommandID: commandID,
}
created, err := s.repository.CreateInviteActivityRewardClaim(ctx, claim, s.now().UTC().UnixMilli())
if err != nil {
return domain.Claim{}, domain.StatusResult{}, err
}
if created.Status == domain.ClaimStatusGranted {
refreshed, refreshErr := s.GetStatus(ctx, userID)
return created, refreshed, refreshErr
}
resp, err := s.wallet.CreditInviteActivityReward(ctx, &walletv1.CreditInviteActivityRewardRequest{
CommandId: created.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: userID,
Amount: created.RewardCoinAmount,
ClaimId: created.ClaimID,
RewardType: created.RewardType,
TierId: created.TierID,
TierCode: created.TierCode,
CycleKey: created.CycleKey,
ReachedValue: created.ReachedValue,
Reason: grantReason,
})
if err != nil {
_ = s.repository.MarkInviteActivityRewardClaimFailed(ctx, created.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Claim{}, domain.StatusResult{}, err
}
updated, err := s.repository.MarkInviteActivityRewardClaimGranted(ctx, created.ClaimID, resp.GetTransactionId(), resp.GetGrantedAtMs())
updated, err := s.grantClaim(ctx, claim)
if err != nil {
return domain.Claim{}, domain.StatusResult{}, err
}
@ -299,7 +331,7 @@ func CycleForTime(value time.Time) domain.Cycle {
func normalizeTiers(tiers []domain.Tier) []domain.Tier {
items := make([]domain.Tier, 0, len(tiers))
for _, tier := range tiers {
tier.RewardType = normalizeRewardType(tier.RewardType)
tier.RewardType = normalizeTierRewardType(tier.RewardType)
tier.TierCode = strings.TrimSpace(tier.TierCode)
tier.TierName = strings.TrimSpace(tier.TierName)
tier.Status = strings.ToLower(strings.TrimSpace(tier.Status))
@ -324,8 +356,11 @@ func validateConfig(config domain.Config) error {
if config.UpdatedByAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
if config.Enabled && len(activeTiers(config.Tiers)) == 0 {
return xerr.New(xerr.InvalidArgument, "enabled invite activity requires active tiers")
if config.PerInviteInviterRewardCoinAmount < 0 || config.PerInviteInviteeRewardCoinAmount < 0 {
return xerr.New(xerr.InvalidArgument, "per invite reward coin amount must not be negative")
}
if config.Enabled && len(activeTiers(config.Tiers)) == 0 && config.PerInviteInviterRewardCoinAmount <= 0 && config.PerInviteInviteeRewardCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "enabled invite activity requires active tiers or per invite rewards")
}
seen := map[string]struct{}{}
for _, tier := range config.Tiers {
@ -347,6 +382,67 @@ func validateConfig(config domain.Config) error {
return nil
}
func (s *Service) grantClaim(ctx context.Context, claim domain.Claim) (domain.Claim, error) {
created, err := s.repository.CreateInviteActivityRewardClaim(ctx, claim, s.now().UTC().UnixMilli())
if err != nil {
return domain.Claim{}, err
}
if created.Status == domain.ClaimStatusGranted {
return created, nil
}
// 钱包 command_id 与 claim_id 都是稳定值;失败重试会复用同一 claim钱包已成功但回包丢失时会由钱包幂等回放交易。
resp, err := s.wallet.CreditInviteActivityReward(ctx, &walletv1.CreditInviteActivityRewardRequest{
CommandId: created.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: created.UserID,
Amount: created.RewardCoinAmount,
ClaimId: created.ClaimID,
RewardType: created.RewardType,
TierId: created.TierID,
TierCode: created.TierCode,
CycleKey: created.CycleKey,
ReachedValue: created.ReachedValue,
Reason: grantReason,
})
if err != nil {
_ = s.repository.MarkInviteActivityRewardClaimFailed(ctx, created.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Claim{}, err
}
return s.repository.MarkInviteActivityRewardClaimGranted(ctx, created.ClaimID, resp.GetTransactionId(), resp.GetGrantedAtMs())
}
func perInviteClaim(event domain.InviteEvent, cycle domain.Cycle, rewardType string, userID int64, peerUserID int64, amount int64) domain.Claim {
claimID := perInviteClaimID(event, rewardType)
return domain.Claim{
AppCode: event.AppCode,
ClaimID: claimID,
CycleKey: cycle.Key,
UserID: userID,
RewardType: rewardType,
CommandID: "wallet_" + claimID,
TierID: peerUserID,
TierCode: rewardType,
TierName: perInviteTierName(rewardType),
ThresholdCoinAmount: 0,
ThresholdValidInviteCount: 0,
ReachedValue: 1,
RewardCoinAmount: amount,
WalletCommandID: "wallet_" + claimID,
}
}
func perInviteClaimID(event domain.InviteEvent, rewardType string) string {
sum := sha1.Sum([]byte(fmt.Sprintf("%s|%s|%s|%d|%d", appcode.Normalize(event.AppCode), strings.TrimSpace(event.EventID), rewardType, event.InviterUserID, event.InvitedUserID)))
return fmt.Sprintf("IAR_INVITE_%s", hex.EncodeToString(sum[:])[:32])
}
func perInviteTierName(rewardType string) string {
if rewardType == domain.RewardTypeInvitee {
return "每邀请一人被邀请人奖励"
}
return "每邀请一人邀请人奖励"
}
func claimRewardAmount(claims []domain.Claim, tiers []domain.Tier, tier domain.Tier, reached int64) (int64, error) {
if tier.RewardType == domain.RewardTypeValidInvite {
for _, claim := range claims {
@ -384,6 +480,21 @@ func activeTiers(tiers []domain.Tier) []domain.Tier {
}
func normalizeRewardType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case domain.RewardTypeRecharge:
return domain.RewardTypeRecharge
case domain.RewardTypeValidInvite:
return domain.RewardTypeValidInvite
case domain.RewardTypeInviteInviter:
return domain.RewardTypeInviteInviter
case domain.RewardTypeInvitee:
return domain.RewardTypeInvitee
default:
return ""
}
}
func normalizeTierRewardType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case domain.RewardTypeRecharge:
return domain.RewardTypeRecharge

View File

@ -84,6 +84,53 @@ func TestClaimValidInviteEachReachedTier(t *testing.T) {
}
}
func TestConsumeInviteEventGrantsPerInviteRewardsIdempotently(t *testing.T) {
repo := newFakeInviteActivityRepo()
repo.config = domain.Config{
AppCode: "lalu",
Enabled: true,
PerInviteInviterRewardCoinAmount: 700,
PerInviteInviteeRewardCoinAmount: 200,
}
wallet := &fakeInviteActivityWallet{}
service := New(repo, wallet, nil)
service.SetClock(func() time.Time { return time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) })
ctx := appcode.WithContext(context.Background(), "lalu")
event := domain.InviteEvent{
AppCode: "lalu",
EventID: "evt-user-invited-1",
EventType: domain.EventTypeUserInvited,
InviterUserID: 42,
InvitedUserID: 99,
InviteCode: "ABC123",
Source: "invite_h5",
OccurredAtMS: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC).UnixMilli(),
}
if err := service.ConsumeInviteEvent(ctx, event); err != nil {
t.Fatalf("consume invite event: %v", err)
}
if len(wallet.requests) != 2 {
t.Fatalf("expected inviter and invitee wallet credits, got %d", len(wallet.requests))
}
if wallet.requests[0].GetTargetUserId() != 42 || wallet.requests[0].GetAmount() != 700 || wallet.requests[0].GetRewardType() != domain.RewardTypeInviteInviter {
t.Fatalf("inviter reward mismatch: %#v", wallet.requests[0])
}
if wallet.requests[1].GetTargetUserId() != 99 || wallet.requests[1].GetAmount() != 200 || wallet.requests[1].GetRewardType() != domain.RewardTypeInvitee {
t.Fatalf("invitee reward mismatch: %#v", wallet.requests[1])
}
if len(repo.claims) != 2 || repo.claims[0].Status != domain.ClaimStatusGranted || repo.claims[1].Status != domain.ClaimStatusGranted {
t.Fatalf("expected 2 granted claims, got %#v", repo.claims)
}
if err := service.ConsumeInviteEvent(ctx, event); err != nil {
t.Fatalf("consume duplicate invite event: %v", err)
}
if len(wallet.requests) != 2 || len(repo.claims) != 2 {
t.Fatalf("duplicate event should reuse existing claims: wallet=%d claims=%d", len(wallet.requests), len(repo.claims))
}
}
func TestListLeaderboardDefaultsCurrentCycleAndCapsPageSize(t *testing.T) {
repo := newFakeInviteActivityRepo()
repo.leaderboardEntries = []domain.LeaderboardEntry{
@ -140,6 +187,10 @@ func (r *fakeInviteActivityRepo) ConsumeInviteActivityRechargeEvent(context.Cont
return domain.ConsumeResult{}, nil
}
func (r *fakeInviteActivityRepo) RecordInviteActivityInviteEvent(context.Context, domain.InviteEvent, domain.Cycle, string, string, int64) (domain.ConsumeResult, error) {
return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, nil
}
func (r *fakeInviteActivityRepo) ConsumeInviteActivityValidInviteEvent(context.Context, domain.ValidInviteEvent, domain.Cycle, int64) (domain.ConsumeResult, error) {
return domain.ConsumeResult{}, nil
}

View File

@ -7,6 +7,8 @@ func (r *Repository) ensureInviteActivityRewardTables(ctx context.Context) error
`CREATE TABLE IF NOT EXISTS invite_activity_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用邀请活动',
per_invite_inviter_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人邀请人即时奖励金币',
per_invite_invitee_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人被邀请人即时奖励金币',
updated_by_admin_id 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',
@ -32,7 +34,7 @@ func (r *Repository) ensureInviteActivityRewardTables(ctx context.Context) error
`CREATE TABLE IF NOT EXISTS invite_activity_reward_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
event_id VARCHAR(128) NOT NULL COMMENT '上游 outbox event_id',
event_type VARCHAR(64) NOT NULL COMMENT 'WalletRechargeRecorded/UserInviteBecameValid',
event_type VARCHAR(64) NOT NULL COMMENT 'WalletRechargeRecorded/UserInvited/UserInviteBecameValid',
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然月 2026-06',
invited_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '被邀请人用户 ID',
inviter_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '邀请人用户 ID',
@ -66,11 +68,11 @@ func (r *Repository) ensureInviteActivityRewardTables(ctx context.Context) error
claim_id VARCHAR(128) NOT NULL COMMENT '领取 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然月 2026-06',
user_id BIGINT NOT NULL COMMENT '邀请人用户 ID',
reward_type VARCHAR(32) NOT NULL COMMENT 'recharge/valid_invite',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 领取命令 ID',
tier_id BIGINT NOT NULL COMMENT '领取档位 ID',
tier_code VARCHAR(64) NOT NULL COMMENT '领取档位编码',
tier_name VARCHAR(128) NOT NULL COMMENT '领取档位名称',
reward_type VARCHAR(32) NOT NULL COMMENT 'recharge/valid_invite/invite_inviter/invite_invitee',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 领取或系统即时发奖命令 ID',
tier_id BIGINT NOT NULL COMMENT '领取档位 ID即时邀请奖励使用对方用户 ID 做幂等维度',
tier_code VARCHAR(64) NOT NULL COMMENT '领取档位编码或即时奖励类型',
tier_name VARCHAR(128) NOT NULL COMMENT '领取档位名称或即时奖励名称',
threshold_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '累计充值金币门槛',
threshold_valid_invite_count BIGINT NOT NULL DEFAULT 0 COMMENT '有效邀请人数门槛',
reached_value BIGINT NOT NULL DEFAULT 0 COMMENT '领取时已达成值',
@ -94,5 +96,24 @@ func (r *Repository) ensureInviteActivityRewardTables(ctx context.Context) error
return err
}
}
additions := []struct {
column string
sql string
}{
{"per_invite_inviter_reward_coin_amount", `ALTER TABLE invite_activity_reward_configs ADD COLUMN per_invite_inviter_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人邀请人即时奖励金币' AFTER enabled`},
{"per_invite_invitee_reward_coin_amount", `ALTER TABLE invite_activity_reward_configs ADD COLUMN per_invite_invitee_reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '每邀请一人被邀请人即时奖励金币' AFTER per_invite_inviter_reward_coin_amount`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "invite_activity_reward_configs", addition.column)
if err != nil {
return err
}
if !exists {
// 邀请即时奖励必须由后台显式配置金额;老库补列默认 0避免迁移瞬间产生非预期发奖。
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}

View File

@ -40,13 +40,19 @@ func (r *Repository) UpdateInviteActivityRewardConfig(ctx context.Context, confi
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
INSERT INTO invite_activity_reward_configs (app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
INSERT INTO invite_activity_reward_configs (
app_code, enabled, per_invite_inviter_reward_coin_amount, per_invite_invitee_reward_coin_amount,
updated_by_admin_id, created_at_ms, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
per_invite_inviter_reward_coin_amount = VALUES(per_invite_inviter_reward_coin_amount),
per_invite_invitee_reward_coin_amount = VALUES(per_invite_invitee_reward_coin_amount),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appcode.FromContext(ctx), config.Enabled, config.UpdatedByAdminID, nowMS, nowMS,
appcode.FromContext(ctx), config.Enabled, config.PerInviteInviterRewardCoinAmount, config.PerInviteInviteeRewardCoinAmount,
config.UpdatedByAdminID, nowMS, nowMS,
); err != nil {
return domain.Config{}, err
}
@ -170,6 +176,45 @@ func (r *Repository) ConsumeInviteActivityRechargeEvent(ctx context.Context, eve
return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, tx.Commit()
}
func (r *Repository) RecordInviteActivityInviteEvent(ctx context.Context, event domain.InviteEvent, cycle domain.Cycle, status string, skipReason string, nowMS int64) (domain.ConsumeResult, error) {
if r == nil || r.db == nil {
return domain.ConsumeResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.ConsumeResult{}, err
}
defer func() { _ = tx.Rollback() }()
inserted, err := insertInviteActivityRewardEvent(ctx, tx, inviteActivityRewardEventRow{
AppCode: appcode.FromContext(ctx),
EventID: event.EventID,
EventType: event.EventType,
CycleKey: cycle.Key,
InvitedUserID: event.InvitedUserID,
InviterUserID: event.InviterUserID,
CommandID: event.Source,
RechargeType: event.InviteCode,
Status: strings.TrimSpace(status),
SkipReason: strings.TrimSpace(skipReason),
PayloadJSON: event.PayloadJSON,
OccurredAtMS: event.OccurredAtMS,
CreatedAtMS: nowMS,
})
if err != nil {
return domain.ConsumeResult{}, err
}
if err := tx.Commit(); err != nil {
return domain.ConsumeResult{}, err
}
if !inserted {
return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonAlreadyConsumed}, nil
}
if strings.TrimSpace(status) == "skipped" {
return domain.ConsumeResult{Consumed: false, Reason: strings.TrimSpace(skipReason)}, nil
}
return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, nil
}
func (r *Repository) ConsumeInviteActivityValidInviteEvent(ctx context.Context, event domain.ValidInviteEvent, cycle domain.Cycle, nowMS int64) (domain.ConsumeResult, error) {
if r == nil || r.db == nil {
return domain.ConsumeResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -266,12 +311,7 @@ func (r *Repository) MarkInviteActivityRewardClaimGranted(ctx context.Context, c
); err != nil {
return domain.Claim{}, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE invite_activity_reward_progress
SET claimed_reward_coin_amount = claimed_reward_coin_amount + ?, updated_at_ms = ?
WHERE app_code = ? AND cycle_key = ? AND user_id = ?`,
claim.RewardCoinAmount, grantedAtMS, appcode.FromContext(ctx), claim.CycleKey, claim.UserID,
); err != nil {
if err := upsertInviteActivityClaimedProgress(ctx, tx, claim.UserID, claim.CycleKey, claim.RewardCoinAmount, grantedAtMS); err != nil {
return domain.Claim{}, err
}
}
@ -458,14 +498,31 @@ func upsertInviteActivityValidProgress(ctx context.Context, tx *sql.Tx, userID i
return err
}
func upsertInviteActivityClaimedProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, rewardCoinAmount int64, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO invite_activity_reward_progress (
app_code, cycle_key, user_id, total_recharge_coin_amount, valid_invite_count, claimed_reward_coin_amount, updated_at_ms
) VALUES (?, ?, ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
claimed_reward_coin_amount = claimed_reward_coin_amount + VALUES(claimed_reward_coin_amount),
updated_at_ms = VALUES(updated_at_ms)`,
appcode.FromContext(ctx), cycleKey, userID, rewardCoinAmount, nowMS,
)
return err
}
func (r *Repository) getInviteActivityRewardConfig(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
queryer := queryerFromRepository(r, tx)
row := queryer.QueryRowContext(ctx, `
SELECT app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
SELECT app_code, enabled, per_invite_inviter_reward_coin_amount, per_invite_invitee_reward_coin_amount,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM invite_activity_reward_configs
WHERE app_code = ?`, appcode.FromContext(ctx))
var config domain.Config
if err := row.Scan(&config.AppCode, &config.Enabled, &config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS); err != nil {
if err := row.Scan(
&config.AppCode, &config.Enabled, &config.PerInviteInviterRewardCoinAmount, &config.PerInviteInviteeRewardCoinAmount,
&config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Config{}, false, nil
}

View File

@ -96,9 +96,11 @@ func (s *AdminInviteActivityRewardServer) UpdateInviteActivityRewardConfig(ctx c
})
}
config, err := s.svc.UpdateConfig(ctx, domain.Config{
Enabled: req.GetEnabled(),
Tiers: tiers,
UpdatedByAdminID: req.GetOperatorAdminId(),
Enabled: req.GetEnabled(),
PerInviteInviterRewardCoinAmount: req.GetPerInviteInviterRewardCoinAmount(),
PerInviteInviteeRewardCoinAmount: req.GetPerInviteInviteeRewardCoinAmount(),
Tiers: tiers,
UpdatedByAdminID: req.GetOperatorAdminId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -144,12 +146,14 @@ func inviteActivityRewardStatusToProto(status domain.StatusResult) *activityv1.I
func inviteActivityRewardConfigToProto(config domain.Config) *activityv1.InviteActivityRewardConfig {
resp := &activityv1.InviteActivityRewardConfig{
AppCode: config.AppCode,
Enabled: config.Enabled,
Tiers: make([]*activityv1.InviteActivityRewardTier, 0, len(config.Tiers)),
UpdatedByAdminId: config.UpdatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
AppCode: config.AppCode,
Enabled: config.Enabled,
PerInviteInviterRewardCoinAmount: config.PerInviteInviterRewardCoinAmount,
PerInviteInviteeRewardCoinAmount: config.PerInviteInviteeRewardCoinAmount,
Tiers: make([]*activityv1.InviteActivityRewardTier, 0, len(config.Tiers)),
UpdatedByAdminId: config.UpdatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
for _, tier := range config.Tiers {
resp.Tiers = append(resp.Tiers, inviteActivityRewardTierToProto(tier))

View File

@ -14,6 +14,7 @@ import (
"google.golang.org/grpc/credentials/insecure"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/inviteactivity"
inviteactivityservice "hyapp/services/activity-service/internal/service/inviteactivity"
@ -34,6 +35,7 @@ type walletEntrySummary struct {
func main() {
var (
activityAddr = flag.String("activity_addr", "127.0.0.1:14006", "activity-service gRPC address")
walletAddr = flag.String("wallet_addr", "127.0.0.1:13004", "wallet-service gRPC address")
activityDSN = flag.String("activity_dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC", "activity MySQL DSN")
walletDSN = flag.String("wallet_dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", "wallet MySQL DSN")
)
@ -48,6 +50,10 @@ func main() {
defer conn.Close()
adminClient := activityv1.NewAdminInviteActivityRewardServiceClient(conn)
rewardClient := activityv1.NewInviteActivityRewardServiceClient(conn)
walletConn, err := grpc.DialContext(ctx, *walletAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
must("dial wallet-service", err)
defer walletConn.Close()
walletClient := walletv1.NewWalletServiceClient(walletConn)
activityRepo, err := mysqlstorage.Open(ctx, *activityDSN)
must("open activity mysql", err)
@ -60,6 +66,7 @@ func main() {
runID := fmt.Sprintf("iar_%d", time.Now().UnixMilli())
inviterUserID := int64(880000000000) + time.Now().Unix()%10000000
invitedUserID := inviterUserID + 9001
now := time.Now().UTC()
cycle := inviteactivityservice.CycleForTime(now)
@ -67,7 +74,14 @@ func main() {
rechargeTier := findTier(config.GetTiers(), domain.RewardTypeRecharge, runID+"_recharge_160k")
validTier := findTier(config.GetTiers(), domain.RewardTypeValidInvite, runID+"_valid_1")
service := inviteactivityservice.New(activityRepo, nil, fakeInviteAttributionSource{inviterUserID: inviterUserID})
service := inviteactivityservice.New(activityRepo, walletClient, fakeInviteAttributionSource{inviterUserID: inviterUserID})
consumeInvite(ctx, service, runID, inviterUserID, invitedUserID, now)
afterInviteInviterStatus := getStatus(ctx, rewardClient, inviterUserID)
afterInviteInviteeStatus := getStatus(ctx, rewardClient, invitedUserID)
inviterInviteClaim := findClaim(afterInviteInviterStatus.GetClaims(), domain.RewardTypeInviteInviter)
inviteeInviteClaim := findClaim(afterInviteInviteeStatus.GetClaims(), domain.RewardTypeInvitee)
assert(inviterInviteClaim.GetRewardCoinAmount() == 700, "inviter per invite reward should be 700 coins")
assert(inviteeInviteClaim.GetRewardCoinAmount() == 200, "invitee per invite reward should be 200 coins")
consumeRecharge(ctx, service, runID, inviterUserID, 1, "google_play", 80000, now.Add(time.Minute))
consumeRecharge(ctx, service, runID, inviterUserID, 2, "mifapay", 40000, now.Add(2*time.Minute))
consumeRecharge(ctx, service, runID, inviterUserID, 3, "coin_seller_transfer", 40000, now.Add(3*time.Minute))
@ -85,16 +99,21 @@ func main() {
rechargeClaim := claim(ctx, rewardClient, inviterUserID, domain.RewardTypeRecharge, rechargeTier.GetTierId(), runID+"_claim_recharge")
validClaim := claim(ctx, rewardClient, inviterUserID, domain.RewardTypeValidInvite, validTier.GetTierId(), runID+"_claim_valid")
afterClaim := getStatus(ctx, rewardClient, inviterUserID)
assert(afterClaim.GetProgress().GetClaimedRewardCoinAmount() == 2800, "claimed reward progress should be 2800 coins")
assert(afterClaim.GetProgress().GetClaimedRewardCoinAmount() == 3500, "claimed reward progress should be 3500 coins")
walletInviteInviter := queryWalletEntry(ctx, walletDB, inviterInviteClaim.GetWalletTransactionId())
walletInvitee := queryWalletEntry(ctx, walletDB, inviteeInviteClaim.GetWalletTransactionId())
walletRecharge := queryWalletEntry(ctx, walletDB, rechargeClaim.GetWalletTransactionId())
walletValid := queryWalletEntry(ctx, walletDB, validClaim.GetWalletTransactionId())
assert(walletInviteInviter.Coins == 700, "wallet inviter per invite reward should credit 700 coins")
assert(walletInvitee.Coins == 200, "wallet invitee per invite reward should credit 200 coins")
assert(walletRecharge.Coins == 2500, "wallet recharge tier reward should credit 2500 coins")
assert(walletValid.Coins == 300, "wallet valid invite reward should credit 300 coins")
out := map[string]any{
"run_id": runID,
"inviter_user_id": inviterUserID,
"invited_user_id": invitedUserID,
"cycle": cycle.Key,
"progress_before_claim": map[string]int64{
"total_recharge_coin_amount": beforeClaim.GetProgress().GetTotalRechargeCoinAmount(),
@ -107,12 +126,16 @@ func main() {
"total_recharge_coin_amount": leaderboardEntry.GetTotalRechargeCoinAmount(),
},
"claims": []map[string]any{
{"reward_type": inviterInviteClaim.GetRewardType(), "tier_code": inviterInviteClaim.GetTierCode(), "reward_coin_amount": inviterInviteClaim.GetRewardCoinAmount(), "wallet_transaction_id": inviterInviteClaim.GetWalletTransactionId()},
{"reward_type": inviteeInviteClaim.GetRewardType(), "tier_code": inviteeInviteClaim.GetTierCode(), "reward_coin_amount": inviteeInviteClaim.GetRewardCoinAmount(), "wallet_transaction_id": inviteeInviteClaim.GetWalletTransactionId()},
{"reward_type": rechargeClaim.GetRewardType(), "tier_code": rechargeClaim.GetTierCode(), "reward_coin_amount": rechargeClaim.GetRewardCoinAmount(), "wallet_transaction_id": rechargeClaim.GetWalletTransactionId()},
{"reward_type": validClaim.GetRewardType(), "tier_code": validClaim.GetTierCode(), "reward_coin_amount": validClaim.GetRewardCoinAmount(), "wallet_transaction_id": validClaim.GetWalletTransactionId()},
},
"wallet_entries": map[string]walletEntrySummary{
"recharge": walletRecharge,
"valid_invite": walletValid,
"invite_inviter": walletInviteInviter,
"invite_invitee": walletInvitee,
"recharge": walletRecharge,
"valid_invite": walletValid,
},
"progress_after_claim": map[string]int64{
"claimed_reward_coin_amount": afterClaim.GetProgress().GetClaimedRewardCoinAmount(),
@ -124,9 +147,11 @@ func main() {
func updateConfig(ctx context.Context, client activityv1.AdminInviteActivityRewardServiceClient, runID string) *activityv1.InviteActivityRewardConfig {
resp, err := client.UpdateInviteActivityRewardConfig(ctx, &activityv1.UpdateInviteActivityRewardConfigRequest{
Meta: meta(runID + "_update_config"),
Enabled: true,
OperatorAdminId: operatorAdminID,
Meta: meta(runID + "_update_config"),
Enabled: true,
PerInviteInviterRewardCoinAmount: 700,
PerInviteInviteeRewardCoinAmount: 200,
OperatorAdminId: operatorAdminID,
Tiers: []*activityv1.InviteActivityRewardTier{
{RewardType: domain.RewardTypeRecharge, TierCode: runID + "_recharge_80k", TierName: "Local recharge 80k", ThresholdCoinAmount: 80000, RewardCoinAmount: 1000, Status: tierStatus, SortOrder: 1},
{RewardType: domain.RewardTypeRecharge, TierCode: runID + "_recharge_160k", TierName: "Local recharge 160k", ThresholdCoinAmount: 160000, RewardCoinAmount: 2500, Status: tierStatus, SortOrder: 2},
@ -138,6 +163,20 @@ func updateConfig(ctx context.Context, client activityv1.AdminInviteActivityRewa
return resp.GetConfig()
}
func consumeInvite(ctx context.Context, service *inviteactivityservice.Service, runID string, inviterUserID int64, invitedUserID int64, occurredAt time.Time) {
err := service.ConsumeInviteEvent(ctx, domain.InviteEvent{
AppCode: appCode,
EventID: runID + "_invite_bound_1",
EventType: domain.EventTypeUserInvited,
InvitedUserID: invitedUserID,
InviterUserID: inviterUserID,
InviteCode: runID,
Source: "invite_h5",
OccurredAtMS: occurredAt.UnixMilli(),
})
must("consume invite bound event", err)
}
func consumeRecharge(ctx context.Context, service *inviteactivityservice.Service, runID string, inviterUserID int64, index int64, rechargeType string, coins int64, occurredAt time.Time) {
err := service.ConsumeWalletRechargeEvent(ctx, domain.RechargeEvent{
AppCode: appCode,
@ -219,6 +258,16 @@ func findTier(tiers []*activityv1.InviteActivityRewardTier, rewardType string, t
return nil
}
func findClaim(claims []*activityv1.InviteActivityRewardClaim, rewardType string) *activityv1.InviteActivityRewardClaim {
for _, claim := range claims {
if claim.GetRewardType() == rewardType {
return claim
}
}
log.Fatalf("claim not found: %s", rewardType)
return nil
}
func findLeaderboardEntry(entries []*activityv1.InviteActivityLeaderboardEntry, userID int64) *activityv1.InviteActivityLeaderboardEntry {
for _, entry := range entries {
if entry.GetUserId() == userID {

View File

@ -153,6 +153,7 @@ CREATE TABLE IF NOT EXISTS game_dice_participants (
status VARCHAR(32) NOT NULL COMMENT '参与状态',
stake_coin BIGINT NOT NULL COMMENT '下注金币',
dice_points_json JSON NULL COMMENT '骰子点数',
rps_gesture VARCHAR(32) NOT NULL DEFAULT '' 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',
@ -241,6 +242,16 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- rock 真实猜拳复用自研局表,但胜负来自参与者预选手势;旧库补空值,后续新局强制写入。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_participants' AND COLUMN_NAME = 'rps_gesture') = 0,
'ALTER TABLE game_dice_participants ADD COLUMN rps_gesture VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''石头剪刀布预选手势'' AFTER dice_points_json',
'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,

View File

@ -18,6 +18,7 @@ const (
DefaultJoinTTLMillis = int64(60_000)
DefaultRobotWaitMS = int64(3_000)
DefaultGameID = "dice"
SelfGameIDRock = "rock"
// 首版禁止 1 人局避免单人场景下“自己赢自己”的派奖语义不清3 人局只是多一行 participant。
DefaultMinPlayers = int32(2)
DefaultMaxPlayers = int32(2)
@ -30,6 +31,10 @@ const (
ParticipantResultLose = "lose"
ParticipantResultDraw = "draw"
RPSGestureRock = "rock"
RPSGesturePaper = "paper"
RPSGestureScissors = "scissors"
MatchStatusCreated = "created"
MatchStatusJoining = "joining"
MatchStatusReady = "ready"
@ -116,7 +121,7 @@ type Robot struct {
UpdatedAtMS int64
}
// ExploreWinner 是 Explore Square 展示用的轻量获胜事实;首版用机器人池生成展示数据,后续真实结算只需要写同一张表
// ExploreWinner 是 Explore Square 展示用的轻量获胜事实;只由真实游戏结算写入,读接口不能主动制造默认播报
type ExploreWinner struct {
AppCode string
WinID string
@ -186,6 +191,7 @@ type Participant struct {
Status string
StakeCoin int64
DicePoints []int32
RPSGesture string
Result string
PayoutCoin int64
DebitOrderID string
@ -330,6 +336,133 @@ func Score(points []int32) (int32, error) {
return point, nil
}
// IsRPSGameID 标识复用 self-game match 表的真实石头剪刀布玩法room-rps 有独立领域模型,不走这里。
func IsRPSGameID(gameID string) bool {
return strings.TrimSpace(gameID) == SelfGameIDRock
}
// NormalizeRPSGesture 把 H5 输入收口为三种稳定英文枚举;后续落库、结算和响应都只使用这三个值。
func NormalizeRPSGesture(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case RPSGestureRock:
return RPSGestureRock, true
case RPSGesturePaper:
return RPSGesturePaper, true
case RPSGestureScissors:
return RPSGestureScissors, true
default:
return "", false
}
}
// RPSGesturePoint 只给 rps 结算事实写入 dice_points_json 做幂等标记和轻量展示兜底,不参与胜负判断。
func RPSGesturePoint(gesture string) (int32, bool) {
switch strings.TrimSpace(gesture) {
case RPSGestureRock:
return 1, true
case RPSGesturePaper:
return 2, true
case RPSGestureScissors:
return 3, true
default:
return 0, false
}
}
// RPSWinningGestureFor 返回能击败入参的手势;机器人奖池不足强制玩家输时只改机器人预选,不改真人事实。
func RPSWinningGestureFor(gesture string) (string, bool) {
switch strings.TrimSpace(gesture) {
case RPSGestureRock:
return RPSGesturePaper, true
case RPSGesturePaper:
return RPSGestureScissors, true
case RPSGestureScissors:
return RPSGestureRock, true
default:
return "", false
}
}
// rpsCompare 返回 left 相对 right 的胜负1 表示 left 赢,-1 表示 left 输0 表示和局。
func rpsCompare(left string, right string) (int, error) {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if _, ok := RPSGesturePoint(left); !ok {
return 0, xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
if _, ok := RPSGesturePoint(right); !ok {
return 0, xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
if left == right {
return 0, nil
}
if left == RPSGestureRock && right == RPSGestureScissors ||
left == RPSGesturePaper && right == RPSGestureRock ||
left == RPSGestureScissors && right == RPSGesturePaper {
return 1, nil
}
return -1, nil
}
// SettleRPSByGestureWithPool 使用双方已经落库的预选手势结算rock 不随机、不重选,和局直接退还双方本金并结束本局。
func SettleRPSByGestureWithPool(participants []Participant, feeBPS int64, poolBPS int64) ([]Participant, string, error) {
// 真实石头剪刀布当前产品只开放 2 人对战;多人局没有明确胜负和分账语义,必须在规则层拒绝。
if len(participants) != int(DefaultMinPlayers) {
return nil, "", xerr.New(xerr.Conflict, "rps match requires two 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...)
sort.SliceStable(settled, func(left int, right int) bool {
return settled[left].SeatNo < settled[right].SeatNo
})
var totalStake int64
for index := range settled {
// rps 的手势必须来自 match/create/join 阶段的预选事实roll 阶段不能补造或重随机。
gesture, ok := NormalizeRPSGesture(settled[index].RPSGesture)
if !ok {
return nil, "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
if settled[index].StakeCoin <= 0 {
return nil, "", xerr.New(xerr.InvalidArgument, "dice stake_coin is invalid")
}
point, _ := RPSGesturePoint(gesture)
settled[index].RPSGesture = gesture
settled[index].DicePoints = []int32{point}
settled[index].Result = ParticipantResultLose
settled[index].PayoutCoin = 0
totalStake += settled[index].StakeCoin
}
compare, err := rpsCompare(settled[0].RPSGesture, settled[1].RPSGesture)
if err != nil {
return nil, "", err
}
if compare == 0 {
// 预选手势相同就是本局和局;双方已扣本金后各退回自己的 stake_coin不进入平台费或奖池抽成。
for index := range settled {
settled[index].Result = ParticipantResultDraw
settled[index].PayoutCoin = settled[index].StakeCoin
}
return settled, ParticipantResultDraw, nil
}
winnerIndex := 0
if compare < 0 {
winnerIndex = 1
}
// 非和局沿用 self-game 经济模型:总奖池先扣平台费和入池比例,剩余整数金额一次性给赢家。
payoutPool := totalStake * (10_000 - feeBPS - poolBPS) / 10_000
settled[winnerIndex].Result = ParticipantResultWin
settled[winnerIndex].PayoutCoin = payoutPool
return settled, ParticipantResultWin, nil
}
// SettleByHighestScore 使用单骰最高点数结算;全部平局只标记 draw由 service 保持同一局继续重投。
func SettleByHighestScore(participants []Participant, feeBPS int64) ([]Participant, string, error) {
return SettleByHighestScoreWithPool(participants, feeBPS, 0)

View File

@ -25,7 +25,7 @@ type Repository interface {
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)
JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, rpsGesture string, 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)
@ -39,8 +39,8 @@ type Repository interface {
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)
FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, rpsGesture string, nowMs int64) (dicedomain.Match, error)
JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, rpsGesture 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)
@ -48,7 +48,7 @@ type Repository interface {
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
EnsureExploreWinners(ctx context.Context, appCode string, nowMs int64) error
RecordExploreWinner(ctx context.Context, winner dicedomain.ExploreWinner) error
ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, error)
}
@ -83,36 +83,43 @@ type CreateMatchCommand struct {
StakeCoin int64
MinPlayers int32
MaxPlayers int32
RPSGesture string
}
type JoinMatchCommand struct {
AppCode string
RequestID string
UserID int64
MatchID string
AppCode string
RequestID string
UserID int64
MatchID string
GameID string
RPSGesture string
}
type GetMatchCommand struct {
AppCode string
UserID int64
MatchID string
GameID string
}
type RollMatchCommand struct {
AppCode string
RequestID string
UserID int64
MatchID string
AppCode string
RequestID string
UserID int64
MatchID string
GameID string
RPSGesture string
}
type MatchCommand struct {
AppCode string
RequestID string
UserID int64
GameID string
RoomID string
RegionID int64
StakeCoin int64
AppCode string
RequestID string
UserID int64
GameID string
RoomID string
RegionID int64
StakeCoin int64
RPSGesture string
}
type CancelMatchCommand struct {
@ -120,6 +127,7 @@ type CancelMatchCommand struct {
RequestID string
UserID int64
MatchID string
GameID string
}
// New 创建骰子游戏用例层;随机数、时间和费率都集中在这里,便于测试和后续按房间配置扩展。
@ -173,6 +181,10 @@ func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (
if err != nil {
return dicedomain.Match{}, 0, err
}
rpsGesture, err := normalizeRPSGestureForGame(game.GameID, command.RPSGesture, true)
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")
}
@ -226,6 +238,7 @@ func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (
SeatNo: 1,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: command.StakeCoin,
RPSGesture: rpsGesture,
JoinedAtMS: nowMS,
UpdatedAtMS: nowMS,
}},
@ -318,7 +331,7 @@ func (s *Service) ListRobots(ctx context.Context, appCode string, gameID string,
return items, next, s.now().UnixMilli(), err
}
// ListExploreWinners 给 Explore Square 提供获胜播报列表;当前版本先用现有机器人池兜底生成 20 条数据库事实
// ListExploreWinners 给 Explore Square 提供真实获胜播报列表;读接口只读已有结算事实,不生成默认 seed 数据
func (s *Service) ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
@ -332,10 +345,6 @@ func (s *Service) ListExploreWinners(ctx context.Context, appCode string, pageSi
if pageSize > 50 {
pageSize = 50
}
// 读接口先做一次幂等补种:机器人列表是当前唯一稳定来源,写入后客户端每 5 秒轮询只需要覆盖展示列表。
if err := s.repository.EnsureExploreWinners(ctx, app, nowMS); err != nil {
return nil, 0, err
}
items, err := s.repository.ListExploreWinners(ctx, app, pageSize)
return items, nowMS, err
}
@ -400,11 +409,15 @@ func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.M
if !dicedomain.StakeEnabled(config, command.StakeCoin) {
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
}
rpsGesture, err := normalizeRPSGestureForGame(config.GameID, command.RPSGesture, true)
if err != nil {
return dicedomain.Match{}, 0, err
}
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)
match, err := s.repository.FindAndJoinWaitingDiceMatch(ctx, app, config.GameID, command.UserID, command.StakeCoin, rpsGesture, nowMS)
if err == nil {
return s.attachRobotIfDue(ctx, command.RequestID, match, nowMS)
}
@ -422,6 +435,7 @@ func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.M
StakeCoin: command.StakeCoin,
MinPlayers: config.MinPlayers,
MaxPlayers: config.MaxPlayers,
RPSGesture: rpsGesture,
})
}
@ -436,8 +450,19 @@ func (s *Service) JoinMatch(ctx context.Context, command JoinMatchCommand) (dice
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
matchForJoin, err := s.expectedMatch(ctx, app, command.MatchID, command.GameID)
if err != nil {
return dicedomain.Match{}, 0, err
}
rpsGesture, err := normalizeRPSGestureForGame(matchForJoin.GameID, command.RPSGesture, true)
if err != nil {
return dicedomain.Match{}, 0, err
}
if err := validateRPSParticipantGesture(matchForJoin, command.UserID, rpsGesture); err != nil {
return dicedomain.Match{}, 0, err
}
// 加入只修改参与者事实;如果用户重复点击 join仓储按唯一参与者返回已有局快照。
match, err := s.repository.JoinDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
match, err := s.repository.JoinDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, rpsGesture, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
@ -459,6 +484,9 @@ func (s *Service) GetMatch(ctx context.Context, command GetMatchCommand) (dicedo
if err != nil {
return dicedomain.Match{}, 0, err
}
if err := expectedGameIDMatches(command.GameID, match); err != nil {
return dicedomain.Match{}, 0, err
}
return s.attachRobotIfDue(ctx, "", match, nowMS)
}
@ -473,6 +501,9 @@ func (s *Service) CancelMatch(ctx context.Context, command CancelMatchCommand) (
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
if err := s.ensureMatchGame(ctx, app, command.MatchID, command.GameID); err != nil {
return dicedomain.Match{}, 0, err
}
match, err := s.repository.CancelDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
return match, nowMS, err
}
@ -491,6 +522,17 @@ func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dice
app := appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, app)
nowMS := s.now().UnixMilli()
matchForRoll, err := s.expectedMatch(ctx, app, command.MatchID, command.GameID)
if err != nil {
return dicedomain.Match{}, 0, err
}
rpsGesture, err := normalizeRPSGestureForGame(matchForRoll.GameID, command.RPSGesture, false)
if err != nil {
return dicedomain.Match{}, 0, err
}
if err := validateOptionalRPSRollGesture(matchForRoll, command.UserID, rpsGesture); err != nil {
return dicedomain.Match{}, 0, err
}
// ClaimDiceMatchForRoll 用 match 行锁把局切到 settling锁提交后才调用钱包减少数据库锁持有时间。
match, err := s.repository.ClaimDiceMatchForRoll(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
@ -504,6 +546,9 @@ func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dice
if len(match.Participants) < int(match.MinPlayers) {
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice match does not have enough participants")
}
if err := validateOptionalRPSRollGesture(match, command.UserID, rpsGesture); err != nil {
return dicedomain.Match{}, 0, err
}
normalizeMatchEconomicSnapshot(&match)
for index := range match.Participants {
@ -533,7 +578,29 @@ func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dice
}
}
if !dicedomain.HasRolls(match.Participants) {
if dicedomain.IsRPSGameID(match.GameID) {
if !dicedomain.HasRolls(match.Participants) {
// rock 的胜负只来自 match/create/join 阶段已经保存的 rps_gestureroll 阶段只负责扣款后把固定结果落库。
settledParticipants, result, err := dicedomain.SettleRPSByGestureWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
if err != nil {
return dicedomain.Match{}, 0, err
}
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 == "" {
// 兼容 rps 结果点数已写入但 match.result 空的半完成状态,仍按手势重新推导同一份派奖/退款金额。
settledParticipants, result, err := dicedomain.SettleRPSByGestureWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
if err != nil {
return dicedomain.Match{}, 0, err
}
match.Participants = settledParticipants
match.Result = result
}
} else if !dicedomain.HasRolls(match.Participants) {
// 只有首次结算可以随机点数;点数一旦落库,所有后续重试必须复用旧点数,避免改变输赢结果。
if err := s.rollParticipants(match.Participants); err != nil {
return dicedomain.Match{}, 0, err
@ -615,10 +682,132 @@ func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dice
if err := s.repository.MarkDiceMatchSettled(ctx, app, match.MatchID, match.Result, nowMS); err != nil {
return dicedomain.Match{}, 0, err
}
// Square 播报是结算后的附属展示事实,只记录真人赢家且用 match_id+user_id 做幂等键;写失败不能让已经完成的钱包结算回滚成失败局。
_ = s.recordExploreWinners(ctx, match, nowMS)
saved, err := s.repository.GetDiceMatch(ctx, app, match.MatchID)
return saved, nowMS, err
}
func (s *Service) recordExploreWinners(ctx context.Context, match dicedomain.Match, nowMS int64) error {
gameCode := exploreGameCodeForMatch(match.GameID)
if gameCode == "" {
return nil
}
for _, participant := range match.Participants {
if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
// 机器人只是匹配补位和奖池对手,不应该出现在 Explore Square 的“用户获胜”播报里。
continue
}
if participant.Result != dicedomain.ParticipantResultWin || participant.PayoutCoin <= 0 {
// 输家、和局和没有派奖金额的半完成状态都不能生成获胜播报,避免客户端展示假的中奖金额。
continue
}
winner := dicedomain.ExploreWinner{
AppCode: match.AppCode,
WinID: fmt.Sprintf("self_%s_%d", match.MatchID, participant.UserID),
GameCode: gameCode,
GameID: strings.TrimSpace(match.GameID),
UserID: participant.UserID,
CoinAmount: participant.PayoutCoin,
WonAtMS: nowMS,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
if err := s.repository.RecordExploreWinner(ctx, winner); err != nil {
return err
}
}
return nil
}
func exploreGameCodeForMatch(gameID string) string {
switch strings.TrimSpace(gameID) {
case "", dicedomain.DefaultGameID:
return dicedomain.ExploreGameCodeDice
case dicedomain.SelfGameIDRock:
return dicedomain.ExploreGameCodeRock
default:
return ""
}
}
func (s *Service) ensureMatchGame(ctx context.Context, appCode string, matchID string, expectedGameID string) error {
_, err := s.expectedMatch(ctx, appCode, matchID, expectedGameID)
return err
}
func (s *Service) expectedMatch(ctx context.Context, appCode string, matchID string, expectedGameID string) (dicedomain.Match, error) {
expectedGameID = strings.TrimSpace(expectedGameID)
if expectedGameID == "" {
return s.repository.GetDiceMatch(ctx, appCode, strings.TrimSpace(matchID))
}
match, err := s.repository.GetDiceMatch(ctx, appCode, strings.TrimSpace(matchID))
if err != nil {
return dicedomain.Match{}, err
}
if err := expectedGameIDMatches(expectedGameID, match); err != nil {
return dicedomain.Match{}, err
}
return match, nil
}
func expectedGameIDMatches(expectedGameID string, match dicedomain.Match) error {
expectedGameID = strings.TrimSpace(expectedGameID)
if expectedGameID == "" {
return nil
}
if strings.TrimSpace(match.GameID) == expectedGameID {
return nil
}
// dice 和 rps 共用自研 match 表HTTP 路径必须用 game_id 再切一刀;返回 not found避免向客户端泄露其它游戏局存在。
return xerr.New(xerr.NotFound, "self game match not found")
}
func normalizeRPSGestureForGame(gameID string, rawGesture string, required bool) (string, error) {
if !dicedomain.IsRPSGameID(gameID) {
return "", nil
}
if strings.TrimSpace(rawGesture) == "" && !required {
return "", nil
}
gesture, ok := dicedomain.NormalizeRPSGesture(rawGesture)
if !ok {
return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
return gesture, nil
}
func validateRPSParticipantGesture(match dicedomain.Match, userID int64, gesture string) error {
if !dicedomain.IsRPSGameID(match.GameID) {
return nil
}
if strings.TrimSpace(gesture) == "" {
return xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
for _, participant := range match.Participants {
if participant.UserID != userID {
continue
}
selected, ok := dicedomain.NormalizeRPSGesture(participant.RPSGesture)
if !ok {
return xerr.New(xerr.Conflict, "rps selected gesture is missing")
}
if selected != gesture {
// 同一个用户重复 join/roll 只能确认原手势,不能通过重试把已经排队的拳换掉。
return xerr.New(xerr.Conflict, "rps gesture does not match selected gesture")
}
return nil
}
return nil
}
func validateOptionalRPSRollGesture(match dicedomain.Match, userID int64, gesture string) error {
if !dicedomain.IsRPSGameID(match.GameID) || strings.TrimSpace(gesture) == "" {
return nil
}
return validateRPSParticipantGesture(match, userID, gesture)
}
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 {
@ -689,13 +878,55 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match
if config.PoolBalanceCoin < requiredPool {
forcedResult = dicedomain.ForcedResultPlayerLose
}
joined, err := s.repository.JoinDiceRobot(ctx, match.AppCode, match.MatchID, robot.UserID, forcedResult, nowMS)
robotRPSGesture := ""
if dicedomain.IsRPSGameID(match.GameID) {
robotRPSGesture, err = s.robotRPSGestureForMatch(match, forcedResult)
if err != nil {
return dicedomain.Match{}, 0, err
}
}
joined, err := s.repository.JoinDiceRobot(ctx, match.AppCode, match.MatchID, robot.UserID, forcedResult, robotRPSGesture, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
return joined, nowMS, nil
}
func (s *Service) robotRPSGestureForMatch(match dicedomain.Match, forcedResult string) (string, error) {
var humanGesture string
for _, participant := range match.Participants {
if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
continue
}
gesture, ok := dicedomain.NormalizeRPSGesture(participant.RPSGesture)
if !ok {
return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
humanGesture = gesture
break
}
if humanGesture == "" {
return "", xerr.New(xerr.Conflict, "rps match does not have human participant")
}
if forcedResult == dicedomain.ForcedResultPlayerLose {
// 奖池不足时不能随机出玩家赢的结果;机器人只选择能击败真人预选手势的那一拳。
gesture, ok := dicedomain.RPSWinningGestureFor(humanGesture)
if !ok {
return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
}
return gesture, nil
}
index, err := s.randomInt(3)
if err != nil {
return "", err
}
gestures := []string{dicedomain.RPSGestureRock, dicedomain.RPSGesturePaper, dicedomain.RPSGestureScissors}
if index < 0 || index >= len(gestures) {
return "", xerr.New(xerr.InvalidArgument, "random rps gesture is invalid")
}
return gestures[index], nil
}
func (s *Service) forceHumanLose(participants []dicedomain.Participant) {
for index := range participants {
if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {

View File

@ -75,6 +75,16 @@ func TestRollMatchDebitsRollsAndPaysWinner(t *testing.T) {
if repo.levelDebitOrders != 2 {
t.Fatalf("debit orders should still feed game order success path, got %d", repo.levelDebitOrders)
}
if len(repo.exploreWinners) != 1 {
t.Fatalf("explore winner records = %d, want 1", len(repo.exploreWinners))
}
exploreWinner := repo.exploreWinners[0]
if exploreWinner.GameCode != dicedomain.ExploreGameCodeDice || exploreWinner.GameID != "dice" || exploreWinner.UserID != 101 || exploreWinner.CoinAmount != 188 {
t.Fatalf("explore winner mismatch: %+v", exploreWinner)
}
if exploreWinner.WinID == "" || exploreWinner.WonAtMS != 1700000000000 {
t.Fatalf("explore winner identity/time mismatch: %+v", exploreWinner)
}
}
func TestRollMatchRequiresMinimumParticipantsBeforeWalletDebit(t *testing.T) {
@ -171,6 +181,285 @@ func TestRollMatchDrawRerollsUntilWinner(t *testing.T) {
}
}
func TestRPSRollUsesPreselectedGesturesWithoutRandomRoll(t *testing.T) {
repo := newFakeDiceRepository()
configureFakeRock(repo)
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) }
svc.randomInt = func(max int) (int, error) {
t.Fatalf("rock settlement must not call randomInt, max=%d", max)
return 0, nil
}
created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
AppCode: "lalu",
RequestID: "req-create-rps",
UserID: 101,
GameID: "rock",
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 2,
RPSGesture: "rock",
})
if err != nil {
t.Fatalf("CreateMatch failed: %v", err)
}
if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join-rps", UserID: 202, MatchID: created.MatchID, GameID: "rock", RPSGesture: "scissors"}); err != nil {
t.Fatalf("JoinMatch failed: %v", err)
}
settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-rps", UserID: 101, MatchID: created.MatchID, GameID: "rock", RPSGesture: "rock"})
if err != nil {
t.Fatalf("RollMatch failed: %v", err)
}
if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin {
t.Fatalf("rock match should settle with winner: %+v", settled)
}
winner := participantByUser(settled.Participants, 101)
loser := participantByUser(settled.Participants, 202)
if winner.RPSGesture != "rock" || len(winner.DicePoints) != 1 || winner.DicePoints[0] != 1 || winner.Result != dicedomain.ParticipantResultWin || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 {
t.Fatalf("winner rps settlement mismatch: %+v", winner)
}
if loser.RPSGesture != "scissors" || len(loser.DicePoints) != 1 || loser.DicePoints[0] != 3 || loser.Result != dicedomain.ParticipantResultLose || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 {
t.Fatalf("loser rps settlement mismatch: %+v", loser)
}
if wallet.calls != 3 {
t.Fatalf("rock win should debit both and pay winner, wallet calls=%d", wallet.calls)
}
if len(repo.exploreWinners) != 1 || repo.exploreWinners[0].GameCode != dicedomain.ExploreGameCodeRock || repo.exploreWinners[0].UserID != 101 {
t.Fatalf("rock explore winner mismatch: %+v", repo.exploreWinners)
}
}
func TestRPSDrawRefundsBothAndSettles(t *testing.T) {
repo := newFakeDiceRepository()
configureFakeRock(repo)
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) }
svc.randomInt = func(max int) (int, error) {
t.Fatalf("rock draw settlement must not call randomInt, max=%d", max)
return 0, nil
}
created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
AppCode: "lalu",
RequestID: "req-create-rps-draw",
UserID: 101,
GameID: "rock",
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 2,
RPSGesture: "rock",
})
if err != nil {
t.Fatalf("CreateMatch failed: %v", err)
}
if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join-rps-draw", UserID: 202, MatchID: created.MatchID, GameID: "rock", RPSGesture: "rock"}); err != nil {
t.Fatalf("JoinMatch failed: %v", err)
}
settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-rps-draw", UserID: 101, MatchID: created.MatchID, GameID: "rock"})
if err != nil {
t.Fatalf("RollMatch failed: %v", err)
}
if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultDraw || settled.RoundNo != 1 {
t.Fatalf("rock draw should settle without reroll: %+v", settled)
}
for _, participant := range settled.Participants {
if participant.Result != dicedomain.ParticipantResultDraw || participant.PayoutCoin != participant.StakeCoin || participant.BalanceAfter != 1000 || participant.RPSGesture != "rock" {
t.Fatalf("draw participant should be refunded: %+v", participant)
}
}
if wallet.calls != 4 {
t.Fatalf("rock draw should debit and refund both users, wallet calls=%d", wallet.calls)
}
if len(repo.exploreWinners) != 0 {
t.Fatalf("draw must not record explore winners: %+v", repo.exploreWinners)
}
}
func TestRPSRequiresGestureBeforeMatchmaking(t *testing.T) {
tests := []struct {
name string
call func(*Service) (dicedomain.Match, int64, error)
}{
{
name: "create",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.CreateMatch(context.Background(), CreateMatchCommand{AppCode: "lalu", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2})
},
},
{
name: "match",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.Match(context.Background(), MatchCommand{AppCode: "lalu", UserID: 101, GameID: "rock", StakeCoin: 100})
},
},
{
name: "join",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", UserID: 202, MatchID: "match-rock-1", GameID: "rock"})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newFakeDiceRepository()
configureFakeRock(repo)
repo.match = dicedomain.Match{
AppCode: "lalu",
MatchID: "match-rock-1",
GameID: "rock",
PlatformCode: "dice",
ProviderGameID: "rock",
MinPlayers: 2,
MaxPlayers: 2,
CurrentPlayers: 1,
StakeCoin: 100,
Status: dicedomain.MatchStatusCreated,
Participants: []dicedomain.Participant{{
AppCode: "lalu",
MatchID: "match-rock-1",
UserID: 101,
SeatNo: 1,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: 100,
RPSGesture: "paper",
}},
}
wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
svc := New(Config{}, repo, wallet)
_, _, err := tt.call(svc)
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("%s should reject missing rps gesture, got %v", tt.name, err)
}
if wallet.calls != 0 {
t.Fatalf("%s should reject before wallet mutation, calls=%d", tt.name, wallet.calls)
}
})
}
}
func TestRPSRollRejectsGestureMismatchBeforeMutation(t *testing.T) {
repo := newFakeDiceRepository()
configureFakeRock(repo)
repo.match = dicedomain.Match{
AppCode: "lalu",
MatchID: "match-rock-1",
GameID: "rock",
PlatformCode: "dice",
ProviderGameID: "rock",
MinPlayers: 2,
MaxPlayers: 2,
CurrentPlayers: 2,
StakeCoin: 100,
Status: dicedomain.MatchStatusReady,
Participants: []dicedomain.Participant{
{AppCode: "lalu", MatchID: "match-rock-1", UserID: 101, SeatNo: 1, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "rock"},
{AppCode: "lalu", MatchID: "match-rock-1", UserID: 202, SeatNo: 2, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "paper"},
},
}
wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
svc := New(Config{}, repo, wallet)
_, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "rock", RPSGesture: "scissors"})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("roll should reject changed rps gesture, got %v", err)
}
if repo.match.Status != dicedomain.MatchStatusReady || wallet.calls != 0 {
t.Fatalf("gesture mismatch must not claim roll or call wallet: match=%+v wallet=%d", repo.match, wallet.calls)
}
}
func TestExploreGameCodeForMatchUsesSelfGameIDs(t *testing.T) {
tests := []struct {
gameID string
want string
}{
{gameID: "dice", want: dicedomain.ExploreGameCodeDice},
{gameID: "rock", want: dicedomain.ExploreGameCodeRock},
{gameID: "room_rps", want: ""},
{gameID: "ocean_hunt", want: ""},
}
for _, tt := range tests {
if got := exploreGameCodeForMatch(tt.gameID); got != tt.want {
t.Fatalf("exploreGameCodeForMatch(%q) = %q, want %q", tt.gameID, got, tt.want)
}
}
}
func TestSelfGameOperationsRejectWrongGameIDBeforeMutation(t *testing.T) {
tests := []struct {
name string
call func(*Service) (dicedomain.Match, int64, error)
}{
{
name: "join",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", UserID: 202, MatchID: "match-rock-1", GameID: "dice"})
},
},
{
name: "get",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"})
},
},
{
name: "roll",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"})
},
},
{
name: "cancel",
call: func(svc *Service) (dicedomain.Match, int64, error) {
return svc.CancelMatch(context.Background(), CancelMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newFakeDiceRepository()
repo.match = dicedomain.Match{
AppCode: "lalu",
MatchID: "match-rock-1",
GameID: "rock",
PlatformCode: "dice",
ProviderGameID: "rock",
MinPlayers: 2,
MaxPlayers: 2,
CurrentPlayers: 1,
StakeCoin: 100,
Status: dicedomain.MatchStatusCreated,
Participants: []dicedomain.Participant{{
AppCode: "lalu",
MatchID: "match-rock-1",
UserID: 101,
SeatNo: 1,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: 100,
}},
}
svc := New(Config{}, repo, &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}})
_, _, err := tt.call(svc)
if !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("%s should hide cross-game match as not found, got %v", tt.name, err)
}
if repo.match.Status != dicedomain.MatchStatusCreated || len(repo.match.Participants) != 1 {
t.Fatalf("%s must not mutate mismatched game match: %+v", tt.name, repo.match)
}
})
}
}
type fakeDiceRepository struct {
game gamedomain.LaunchableGame
match dicedomain.Match
@ -178,6 +467,7 @@ type fakeDiceRepository struct {
poolBalance int64
orders map[string]gamedomain.GameOrder
levelDebitOrders int
exploreWinners []dicedomain.ExploreWinner
}
func newFakeDiceRepository() *fakeDiceRepository {
@ -209,6 +499,13 @@ func newFakeDiceRepository() *fakeDiceRepository {
}
}
func configureFakeRock(repo *fakeDiceRepository) {
repo.game.CatalogItem.GameID = "rock"
repo.game.CatalogItem.ProviderGameID = "rock"
repo.game.CatalogItem.GameName = "Rock Paper Scissors"
repo.config.GameID = "rock"
}
func (r *fakeDiceRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
return r.game, nil
}
@ -252,7 +549,7 @@ func (r *fakeDiceRepository) CreateDiceMatch(_ context.Context, match dicedomain
return cloneDiceMatch(r.match), nil
}
func (r *fakeDiceRepository) JoinDiceMatch(_ context.Context, _ string, _ string, userID int64, nowMs int64) (dicedomain.Match, error) {
func (r *fakeDiceRepository) JoinDiceMatch(_ context.Context, _ string, _ string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
for _, participant := range r.match.Participants {
if participant.UserID == userID {
return cloneDiceMatch(r.match), nil
@ -266,6 +563,7 @@ func (r *fakeDiceRepository) JoinDiceMatch(_ context.Context, _ string, _ string
SeatNo: seatNo,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: r.match.StakeCoin,
RPSGesture: strings.TrimSpace(rpsGesture),
JoinedAtMS: nowMs,
UpdatedAtMS: nowMs,
})
@ -305,6 +603,7 @@ func (r *fakeDiceRepository) SaveDiceRolls(_ context.Context, match dicedomain.M
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].RPSGesture = source.RPSGesture
r.match.Participants[index].Result = source.Result
r.match.Participants[index].PayoutCoin = source.PayoutCoin
r.match.Participants[index].UpdatedAtMS = nowMs
@ -322,6 +621,7 @@ func (r *fakeDiceRepository) SaveDiceDrawForReroll(_ context.Context, match dice
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].RPSGesture = source.RPSGesture
r.match.Participants[index].Result = dicedomain.ParticipantResultDraw
r.match.Participants[index].PayoutCoin = 0
r.match.Participants[index].UpdatedAtMS = nowMs
@ -402,11 +702,11 @@ func (r *fakeDiceRepository) UpsertDiceConfig(_ context.Context, config dicedoma
return config, nil
}
func (r *fakeDiceRepository) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, int64) (dicedomain.Match, error) {
func (r *fakeDiceRepository) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, string, 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) {
func (r *fakeDiceRepository) JoinDiceRobot(context.Context, string, string, int64, string, string, int64) (dicedomain.Match, error) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found")
}
@ -445,7 +745,8 @@ func (r *fakeDiceRepository) DeleteDiceRobot(context.Context, string, string, in
return nil
}
func (r *fakeDiceRepository) EnsureExploreWinners(context.Context, string, int64) error {
func (r *fakeDiceRepository) RecordExploreWinner(_ context.Context, winner dicedomain.ExploreWinner) error {
r.exploreWinners = append(r.exploreWinners, winner)
return nil
}

View File

@ -8,6 +8,7 @@ import (
"fmt"
"math/big"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
@ -41,7 +42,7 @@ func (r *Repository) GetDiceConfig(ctx context.Context, appCode string, gameID s
return dicedomain.NormalizeConfig(config)
}
// ListSelfGameConfigs 当前只有 dice 一个自研游戏;仍返回数组,方便后台后续追加更多自研游戏
// ListSelfGameConfigs 返回 dice、rock 等自研游戏配置;后台列表直接以配置表为准,避免依赖第三方 catalog
func (r *Repository) ListSelfGameConfigs(ctx context.Context, appCode string) ([]dicedomain.Config, error) {
app := appcode.Normalize(appCode)
rows, err := r.db.QueryContext(ctx,
@ -129,7 +130,7 @@ func (r *Repository) UpsertDiceConfig(ctx context.Context, config dicedomain.Con
}
// FindAndJoinWaitingDiceMatch 在全服等待队列里原子抢一个真人局;没有命中时返回 NotFound让 service 创建新局。
func (r *Repository) FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, nowMs int64) (dicedomain.Match, error) {
func (r *Repository) FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
@ -172,7 +173,7 @@ func (r *Repository) FindAndJoinWaitingDiceMatch(ctx context.Context, appCode st
if match.MatchMode == "" {
match.MatchMode = dicedomain.MatchModeHuman
}
joined, err := insertDiceParticipantLocked(ctx, tx, match, userID, dicedomain.ParticipantTypeUser, nowMs)
joined, err := insertDiceParticipantLocked(ctx, tx, match, userID, dicedomain.ParticipantTypeUser, rpsGesture, nowMs)
if err != nil {
return dicedomain.Match{}, err
}
@ -186,7 +187,7 @@ func (r *Repository) FindAndJoinWaitingDiceMatch(ctx context.Context, appCode st
}
// JoinDiceRobot 把一个已登记真实 App 用户作为机器人补位;机器人只参与局事实,不触发钱包扣款。
func (r *Repository) JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, nowMs int64) (dicedomain.Match, error) {
func (r *Repository) JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
@ -204,7 +205,7 @@ func (r *Repository) JoinDiceRobot(ctx context.Context, appCode string, matchID
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)
joined, err := insertDiceParticipantLocked(ctx, tx, match, robotUserID, dicedomain.ParticipantTypeRobot, rpsGesture, nowMs)
if err != nil {
return dicedomain.Match{}, err
}
@ -440,91 +441,57 @@ func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID
return robots, nextCursor, nil
}
// EnsureExploreWinners 用机器人池补足 Explore Square 首批获胜事实;已有 20 条时不重写,避免轮询接口制造无意义写放大。
func (r *Repository) EnsureExploreWinners(ctx context.Context, appCode string, nowMs int64) error {
app := appcode.Normalize(appCode)
var existing int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM game_explore_winners
WHERE app_code = ?`,
// RecordExploreWinner 写入真实结算后的 Square 播报事实win_id 由 match_id+user_id 派生,重试结算只会覆盖同一条记录。
func (r *Repository) RecordExploreWinner(ctx context.Context, winner dicedomain.ExploreWinner) error {
app := appcode.Normalize(winner.AppCode)
winID := strings.TrimSpace(winner.WinID)
gameCode := strings.TrimSpace(winner.GameCode)
gameID := strings.TrimSpace(winner.GameID)
if winID == "" || gameCode == "" || gameID == "" || winner.UserID <= 0 || winner.CoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "explore winner is incomplete")
}
nowMs := winner.UpdatedAtMS
if nowMs <= 0 {
nowMs = winner.WonAtMS
}
if nowMs <= 0 {
nowMs = time.Now().UnixMilli()
}
createdAtMS := winner.CreatedAtMS
if createdAtMS <= 0 {
createdAtMS = nowMs
}
wonAtMS := winner.WonAtMS
if wonAtMS <= 0 {
wonAtMS = nowMs
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_explore_winners (
app_code, win_id, game_code, game_id, user_id, display_name, avatar_url,
coin_amount, won_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
game_code = VALUES(game_code),
game_id = VALUES(game_id),
user_id = VALUES(user_id),
display_name = VALUES(display_name),
avatar_url = VALUES(avatar_url),
coin_amount = VALUES(coin_amount),
won_at_ms = VALUES(won_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
app,
).Scan(&existing); err != nil {
return err
}
if existing >= int(dicedomain.DefaultExploreWinnerPageSize) {
return nil
}
rows, err := r.db.QueryContext(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 status = ?
ORDER BY updated_at_ms DESC, user_id DESC
LIMIT ?`,
app, dicedomain.RobotStatusActive, int(dicedomain.DefaultExploreWinnerPageSize),
winID,
gameCode,
gameID,
winner.UserID,
strings.TrimSpace(winner.DisplayName),
strings.TrimSpace(winner.AvatarURL),
winner.CoinAmount,
wonAtMS,
createdAtMS,
nowMs,
)
if err != nil {
return err
}
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 {
_ = rows.Close()
return err
}
robots = append(robots, robot)
}
if err := rows.Close(); err != nil {
return err
}
if len(robots) == 0 {
return nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// 半成品数据直接重建,保证当前版本始终给客户端一组完整、按时间倒序的 20 条播报。
if _, err := tx.ExecContext(ctx, `DELETE FROM game_explore_winners WHERE app_code = ?`, app); err != nil {
return err
}
for index := 0; index < int(dicedomain.DefaultExploreWinnerPageSize); index++ {
robot := robots[index%len(robots)]
gameCode := dicedomain.ExploreGameCodeDice
gameID := dicedomain.DefaultGameID
if index%2 == 1 {
gameCode = dicedomain.ExploreGameCodeRock
gameID = "room_rps"
}
if strings.TrimSpace(robot.GameID) != "" && gameCode == dicedomain.ExploreGameCodeDice {
gameID = strings.TrimSpace(robot.GameID)
}
wonAtMS := nowMs - int64(index)*5_000
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_explore_winners (
app_code, win_id, game_code, game_id, user_id, display_name, avatar_url,
coin_amount, won_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
app,
fmt.Sprintf("explore_seed_%d_%02d", nowMs, index+1),
gameCode,
gameID,
robot.UserID,
"name",
"",
int64(306000),
wonAtMS,
nowMs,
nowMs,
); err != nil {
return err
}
}
return tx.Commit()
return err
}
// ListExploreWinners 只返回当前 app 最新播报;客户端每次轮询直接用返回值替换旧列表。
@ -549,7 +516,7 @@ func (r *Repository) ListExploreWinners(ctx context.Context, appCode string, pag
return nil, err
}
defer rows.Close()
items := make([]dicedomain.ExploreWinner, 0, pageSize)
items := make([]dicedomain.ExploreWinner, 0, int(pageSize))
for rows.Next() {
var item dicedomain.ExploreWinner
if err := rows.Scan(
@ -650,7 +617,7 @@ func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID
return nil
}
func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, userID int64, participantType string, nowMs int64) (dicedomain.Match, error) {
func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, userID int64, participantType string, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
var seatNo int32
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(seat_no), 0) + 1
@ -662,11 +629,11 @@ func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedoma
}
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,
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, 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, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, '', 0, '', '', '', 0, ?, ?)`,
match.AppCode, match.MatchID, userID, normalizeParticipantType(participantType), seatNo,
dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", nowMs, nowMs,
dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", strings.TrimSpace(rpsGesture), nowMs, nowMs,
); err != nil {
return dicedomain.Match{}, err
}
@ -682,6 +649,7 @@ func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedoma
SeatNo: seatNo,
Status: dicedomain.ParticipantStatusJoined,
StakeCoin: match.StakeCoin,
RPSGesture: strings.TrimSpace(rpsGesture),
JoinedAtMS: nowMs,
UpdatedAtMS: nowMs,
})

View File

@ -42,14 +42,14 @@ func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match
return dicedomain.Match{}, err
}
for _, participant := range match.Participants {
// dice_points_json 创建阶段固定写 [],后续 SaveDiceRolls 才允许写真实点数
// 创建阶段只保存用户的座位和预选手势dice_points_json 仍等到 roll/settlement 阶段才写入不可变结果标记
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), ?, ?, ?, ?, ?, ?, ?, ?)`,
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, 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,
dicePointsJSON(participant.DicePoints), strings.TrimSpace(participant.RPSGesture), participant.Result, participant.PayoutCoin, participant.DebitOrderID,
participant.PayoutOrderID, participant.RefundOrderID, participant.BalanceAfter, participant.JoinedAtMS, participant.UpdatedAtMS,
); err != nil {
return dicedomain.Match{}, err
@ -63,7 +63,7 @@ func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match
}
// JoinDiceMatch 只在 match 行锁内分配座位和增加人数;外部钱包调用不会持有这把锁。
func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) {
func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return dicedomain.Match{}, err
@ -115,10 +115,10 @@ func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID
}
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,
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, 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,
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, '', 0, '', '', '', 0, ?, ?)`,
app, match.MatchID, userID, dicedomain.ParticipantTypeUser, seatNo, dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", strings.TrimSpace(rpsGesture), nowMs, nowMs,
); err != nil {
return dicedomain.Match{}, err
}
@ -432,7 +432,7 @@ func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID
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,
COALESCE(CAST(dice_points_json AS CHAR), '[]'), rps_gesture, 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 = ?
@ -450,7 +450,7 @@ func queryDiceParticipants(ctx context.Context, q diceQuerier, appCode string, m
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 {
if err := rows.Scan(&participant.AppCode, &participant.MatchID, &participant.UserID, &participant.ParticipantType, &participant.SeatNo, &participant.Status, &participant.StakeCoin, &pointsJSON, &participant.RPSGesture, &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)

View File

@ -188,10 +188,11 @@ func (r *Repository) Migrate(ctx context.Context) error {
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 '',
status VARCHAR(32) NOT NULL,
stake_coin BIGINT NOT NULL,
dice_points_json JSON NULL,
rps_gesture VARCHAR(32) NOT NULL DEFAULT '',
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 '',
@ -204,6 +205,22 @@ func (r *Repository) Migrate(ctx context.Context) error {
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_explore_winners (
app_code VARCHAR(32) NOT NULL,
win_id VARCHAR(96) NOT NULL,
game_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
avatar_url VARCHAR(512) NOT NULL DEFAULT '',
coin_amount BIGINT NOT NULL,
won_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, win_id),
KEY idx_game_explore_winners_time(app_code, won_at_ms, win_id),
KEY idx_game_explore_winners_game(app_code, game_code, won_at_ms)
) 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,
@ -307,6 +324,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_dice_participants", "rps_gesture", "rps_gesture VARCHAR(32) NOT NULL DEFAULT '' AFTER dice_points_json"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
return err
}

View File

@ -162,13 +162,14 @@ func (s *Server) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*
}
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(),
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
GameID: req.GetGameId(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
StakeCoin: req.GetStakeCoin(),
RPSGesture: req.GetRpsGesture(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -192,6 +193,7 @@ func (s *Server) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatc
StakeCoin: req.GetStakeCoin(),
MinPlayers: req.GetMinPlayers(),
MaxPlayers: req.GetMaxPlayers(),
RPSGesture: req.GetRpsGesture(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -206,10 +208,12 @@ func (s *Server) JoinDiceMatch(ctx context.Context, req *gamev1.JoinDiceMatchReq
// 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(),
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
GameID: req.GetGameId(),
RPSGesture: req.GetRpsGesture(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -227,6 +231,7 @@ func (s *Server) GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchReque
AppCode: req.GetMeta().GetAppCode(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
GameID: req.GetGameId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -241,10 +246,12 @@ func (s *Server) RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchReq
// 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(),
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
GameID: req.GetGameId(),
RPSGesture: req.GetRpsGesture(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -262,6 +269,7 @@ func (s *Server) CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatc
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
MatchID: req.GetMatchId(),
GameID: req.GetGameId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -738,6 +746,7 @@ func diceParticipantToProto(participant dicedomain.Participant) *gamev1.DicePart
UpdatedAtMs: participant.UpdatedAtMS,
ParticipantType: participant.ParticipantType,
IsRobot: participant.ParticipantType == dicedomain.ParticipantTypeRobot,
RpsGesture: participant.RPSGesture,
}
}

View File

@ -62,13 +62,15 @@ type inviteActivityRewardClaimData struct {
}
type inviteActivityRewardStatusData struct {
Enabled bool `json:"enabled"`
CycleKey string `json:"cycle_key"`
Period cumulativeRechargeRewardPeriodData `json:"period"`
Tiers []inviteActivityRewardTierData `json:"tiers"`
Progress inviteActivityRewardProgressData `json:"progress"`
Claims []inviteActivityRewardClaimData `json:"claims"`
ServerTimeMS int64 `json:"server_time_ms"`
Enabled bool `json:"enabled"`
PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"`
PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"`
CycleKey string `json:"cycle_key"`
Period cumulativeRechargeRewardPeriodData `json:"period"`
Tiers []inviteActivityRewardTierData `json:"tiers"`
Progress inviteActivityRewardProgressData `json:"progress"`
Claims []inviteActivityRewardClaimData `json:"claims"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type inviteActivityRewardClaimBody struct {
@ -226,8 +228,10 @@ func inviteActivityRewardStatusFromProto(item *activityv1.InviteActivityRewardSt
}
}
return inviteActivityRewardStatusData{
Enabled: item.GetConfig().GetEnabled(),
CycleKey: item.GetCycleKey(),
Enabled: item.GetConfig().GetEnabled(),
PerInviteInviterRewardCoinAmount: item.GetConfig().GetPerInviteInviterRewardCoinAmount(),
PerInviteInviteeRewardCoinAmount: item.GetConfig().GetPerInviteInviteeRewardCoinAmount(),
CycleKey: item.GetCycleKey(),
Period: cumulativeRechargeRewardPeriodData{
StartMS: item.GetPeriodStartMs(),
EndMS: item.GetPeriodEndMs(),

View File

@ -141,6 +141,9 @@ func TestListExploreWinnersReturnsSquareItems(t *testing.T) {
if first["game_code"] != "game_dice" || first["user_id"] != "7001" || first["coin_amount"].(float64) != 306000 {
t.Fatalf("explore winner response mismatch: %+v", first)
}
if first["display_name"] != "user-7001" || first["avatar_url"] != "https://cdn.example/avatar.png" {
t.Fatalf("explore winner response mismatch: %+v", first)
}
}
func TestGetBridgeScriptReturnsUniversalScriptConfig(t *testing.T) {
@ -264,11 +267,166 @@ func TestRPSMatchUsesRockSelfGameAndAuthenticatedRegion(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastMatchDice == nil || gameClient.lastMatchDice.GetGameId() != "rock" || gameClient.lastMatchDice.GetUserId() != 42 || gameClient.lastMatchDice.GetRegionId() != 1001 || gameClient.lastMatchDice.GetStakeCoin() != 8000 {
if gameClient.lastMatchDice == nil || gameClient.lastMatchDice.GetGameId() != "rock" || gameClient.lastMatchDice.GetUserId() != 42 || gameClient.lastMatchDice.GetRegionId() != 1001 || gameClient.lastMatchDice.GetStakeCoin() != 8000 || gameClient.lastMatchDice.GetRpsGesture() != "paper" {
t.Fatalf("rps match request mismatch: %+v", gameClient.lastMatchDice)
}
}
func TestRPSCreateUsesPreselectedGesture(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/rps/matches", bytes.NewReader([]byte(`{"game_id":"rock","stake_coin":500,"gesture":"Scissors"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
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 gameClient.lastCreateDice == nil || gameClient.lastCreateDice.GetGameId() != "rock" || gameClient.lastCreateDice.GetRpsGesture() != "scissors" || gameClient.lastCreateDice.GetRegionId() != 1001 {
t.Fatalf("rps create request mismatch: %+v", gameClient.lastCreateDice)
}
}
func TestRPSMatchOperationsCarryRockBoundary(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
token := "Bearer " + signGatewayToken(t, "secret", 42)
// rps 的后续操作路径没有 body 里的 game_id 可依赖,必须由 gateway 根据路由固定传 rock 给 game-service 做 match 归属复核。
requests := []struct {
method string
path string
body string
check func()
}{
{
method: http.MethodPost,
path: "/api/v1/games/rps/matches/match-rock-1/join",
body: `{"gesture":"scissors"}`,
check: func() {
if gameClient.lastJoinDice == nil || gameClient.lastJoinDice.GetGameId() != "rock" || gameClient.lastJoinDice.GetRpsGesture() != "scissors" {
t.Fatalf("rps join must carry rock game_id: %+v", gameClient.lastJoinDice)
}
},
},
{
method: http.MethodGet,
path: "/api/v1/games/rps/matches/match-rock-1",
check: func() {
if gameClient.lastGetDice == nil || gameClient.lastGetDice.GetGameId() != "rock" {
t.Fatalf("rps get must carry rock game_id: %+v", gameClient.lastGetDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/rps/matches/match-rock-1/roll",
body: `{"gesture":"paper","game_id":"dice"}`,
check: func() {
if gameClient.lastRollDice == nil || gameClient.lastRollDice.GetGameId() != "rock" || gameClient.lastRollDice.GetRpsGesture() != "paper" {
t.Fatalf("rps roll must ignore body game_id and carry rock: %+v", gameClient.lastRollDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/rps/matches/match-rock-1/cancel",
check: func() {
if gameClient.lastCancelDice == nil || gameClient.lastCancelDice.GetGameId() != "rock" {
t.Fatalf("rps cancel must carry rock game_id: %+v", gameClient.lastCancelDice)
}
},
},
}
for _, item := range requests {
body := io.Reader(bytes.NewReader(nil))
if item.body != "" {
body = bytes.NewReader([]byte(item.body))
}
request := httptest.NewRequest(item.method, item.path, body)
request.Header.Set("Authorization", token)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s %s status mismatch: got %d body=%s", item.method, item.path, recorder.Code, recorder.Body.String())
}
item.check()
}
}
func TestDiceMatchOperationsCarryDiceBoundary(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
token := "Bearer " + signGatewayToken(t, "secret", 42)
requests := []struct {
method string
path string
check func()
}{
{
method: http.MethodPost,
path: "/api/v1/games/dice/matches/match-dice-1/join",
check: func() {
if gameClient.lastJoinDice == nil || gameClient.lastJoinDice.GetGameId() != "dice" {
t.Fatalf("dice join must carry dice game_id: %+v", gameClient.lastJoinDice)
}
},
},
{
method: http.MethodGet,
path: "/api/v1/games/dice/matches/match-dice-1",
check: func() {
if gameClient.lastGetDice == nil || gameClient.lastGetDice.GetGameId() != "dice" {
t.Fatalf("dice get must carry dice game_id: %+v", gameClient.lastGetDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/dice/matches/match-dice-1/roll",
check: func() {
if gameClient.lastRollDice == nil || gameClient.lastRollDice.GetGameId() != "dice" {
t.Fatalf("dice roll must carry dice game_id: %+v", gameClient.lastRollDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/dice/matches/match-dice-1/cancel",
check: func() {
if gameClient.lastCancelDice == nil || gameClient.lastCancelDice.GetGameId() != "dice" {
t.Fatalf("dice cancel must carry dice game_id: %+v", gameClient.lastCancelDice)
}
},
},
}
for _, item := range requests {
request := httptest.NewRequest(item.method, item.path, nil)
request.Header.Set("Authorization", token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s %s status mismatch: got %d body=%s", item.method, item.path, recorder.Code, recorder.Body.String())
}
item.check()
}
}
func TestCreateRoomRPSChallengeUsesAuthenticatedUserAndRegion(t *testing.T) {
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
Challenge: &gamev1.RoomRPSChallenge{
@ -491,6 +649,11 @@ type fakeGatewayGameClient struct {
lastCallback *gamev1.CallbackRequest
lastGetDiceConfig *gamev1.GetDiceConfigRequest
lastMatchDice *gamev1.MatchDiceRequest
lastCreateDice *gamev1.CreateDiceMatchRequest
lastJoinDice *gamev1.JoinDiceMatchRequest
lastGetDice *gamev1.GetDiceMatchRequest
lastRollDice *gamev1.RollDiceMatchRequest
lastCancelDice *gamev1.CancelDiceMatchRequest
roomRPSChallengeResp *gamev1.RoomRPSChallengeResponse
lastListRoomRPS *gamev1.ListRoomRPSChallengesRequest
lastCreateRoomRPS *gamev1.CreateRoomRPSChallengeRequest
@ -548,23 +711,28 @@ func (f *fakeGatewayGameClient) MatchDice(_ context.Context, req *gamev1.MatchDi
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CreateDiceMatch(context.Context, *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
func (f *fakeGatewayGameClient) CreateDiceMatch(_ context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastCreateDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) JoinDiceMatch(context.Context, *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
func (f *fakeGatewayGameClient) JoinDiceMatch(_ context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastJoinDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) GetDiceMatch(context.Context, *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
func (f *fakeGatewayGameClient) GetDiceMatch(_ context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastGetDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) RollDiceMatch(context.Context, *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
func (f *fakeGatewayGameClient) RollDiceMatch(_ context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastRollDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CancelDiceMatch(context.Context, *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
func (f *fakeGatewayGameClient) CancelDiceMatch(_ context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastCancelDice = req
return &gamev1.DiceMatchResponse{}, nil
}

View File

@ -57,6 +57,46 @@ func (h *Handler) enrichRoomRPSChallenges(request *http.Request, challenges []*r
}
}
// enrichExploreWinners 只补 Square 播报展示资料game_code、金额和时间仍以 game-service 的获胜事实为准。
func (h *Handler) enrichExploreWinners(request *http.Request, winners []exploreWinnerItemData) {
if h == nil || len(winners) == 0 {
return
}
userIDs := exploreWinnerUserIDs(winners)
users := h.diceUsersByID(request, userIDs)
for index := range winners {
userID := winners[index].UserIDNumber
if userID <= 0 {
continue
}
if user, ok := users[userID]; ok {
// user-service 是昵称头像的唯一事实来源game-service 表里的 display_name/avatar_url 只是播报创建时的兜底快照。
if user.Nickname != "" {
winners[index].DisplayName = user.Nickname
}
if user.Avatar != "" {
winners[index].AvatarURL = user.Avatar
}
}
}
}
func exploreWinnerUserIDs(winners []exploreWinnerItemData) []int64 {
seen := make(map[int64]struct{}, len(winners))
out := make([]int64, 0, len(winners))
for _, winner := range winners {
if winner.UserIDNumber <= 0 {
continue
}
if _, ok := seen[winner.UserIDNumber]; ok {
continue
}
seen[winner.UserIDNumber] = struct{}{}
out = append(out, winner.UserIDNumber)
}
return out
}
func roomRPSChallengeUserIDs(challenges []*roomRPSChallengeItemData) []int64 {
seen := make(map[int64]struct{}, len(challenges)*2)
out := make([]int64, 0, len(challenges)*2)

View File

@ -1,6 +1,7 @@
package gameapi
import (
"bytes"
"io"
"log/slog"
"net/http"
@ -101,7 +102,10 @@ func (h *Handler) listExploreWinners(writer http.ResponseWriter, request *http.R
Meta: gameMeta(request),
PageSize: pageSize,
})
httpkit.Write(writer, request, exploreWinnerListDataFromProto(resp), err)
data := exploreWinnerListDataFromProto(resp)
// 获胜事实归 game-service头像昵称归 user-service资料补全失败时仍返回游戏事实Flutter 用 user_id 和本地图做兜底。
h.enrichExploreWinners(request, data.Items)
httpkit.Write(writer, request, data, err)
}
func (h *Handler) getBridgeScript(writer http.ResponseWriter, request *http.Request) {
@ -196,10 +200,11 @@ func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Reques
return
}
var body struct {
GameID string `json:"game_id"`
RoomID string `json:"room_id"`
StakeCoin int64 `json:"stake_coin"`
Gesture string `json:"gesture"`
GameID string `json:"game_id"`
RoomID string `json:"room_id"`
StakeCoin int64 `json:"stake_coin"`
Gesture string `json:"gesture"`
RPSGesture string `json:"rps_gesture"`
}
if !httpkit.Decode(writer, request, &body) {
return
@ -212,6 +217,10 @@ func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Reques
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
rpsGesture, ok := selfGameRPSGestureFromInput(writer, request, gameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true)
if !ok {
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
@ -222,12 +231,13 @@ func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Reques
return
}
resp, err := h.gameClient.MatchDice(request.Context(), &gamev1.MatchDiceRequest{
Meta: gameMeta(request),
UserId: userID,
GameId: gameID,
RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(),
StakeCoin: body.StakeCoin,
Meta: gameMeta(request),
UserId: userID,
GameId: gameID,
RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(),
StakeCoin: body.StakeCoin,
RpsGesture: rpsGesture,
})
h.writeDiceMatch(writer, request, resp, err)
}
@ -252,6 +262,8 @@ func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.
StakeCoin int64 `json:"stake_coin"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
Gesture string `json:"gesture"`
RPSGesture string `json:"rps_gesture"`
}
if !httpkit.Decode(writer, request, &body) {
return
@ -264,6 +276,10 @@ func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
rpsGesture, ok := selfGameRPSGestureFromInput(writer, request, gameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true)
if !ok {
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
@ -283,11 +299,20 @@ func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.
StakeCoin: body.StakeCoin,
MinPlayers: body.MinPlayers,
MaxPlayers: body.MaxPlayers,
RpsGesture: rpsGesture,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) joinDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.joinSelfGameMatch(writer, request, selfGameIDDice)
}
func (h *Handler) joinRPSMatch(writer http.ResponseWriter, request *http.Request) {
h.joinSelfGameMatch(writer, request, selfGameIDRock)
}
func (h *Handler) joinSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
@ -297,21 +322,42 @@ func (h *Handler) joinDiceMatch(writer http.ResponseWriter, request *http.Reques
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
rpsGesture := ""
if expectedGameID == selfGameIDRock {
var body struct {
Gesture string `json:"gesture"`
RPSGesture string `json:"rps_gesture"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
var ok bool
rpsGesture, ok = selfGameRPSGestureFromInput(writer, request, expectedGameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true)
if !ok {
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,
// rps 入口复用 self-game match 表,但必须把路径期望的 game_id 传给 game-service 复核,避免拿 dice match_id 串到 rock 流程。
GameId: expectedGameID,
RpsGesture: rpsGesture,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) joinRPSMatch(writer http.ResponseWriter, request *http.Request) {
// 独立猜拳复用自研匹配局 IDjoin 不从 body 读取 game_id因此只需要走同一条参与者身份校验链路。
h.joinDiceMatch(writer, request)
func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.getSelfGameMatch(writer, request, selfGameIDDice)
}
func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request) {
func (h *Handler) getRPSMatch(writer http.ResponseWriter, request *http.Request) {
h.getSelfGameMatch(writer, request, selfGameIDRock)
}
func (h *Handler) getSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
@ -326,16 +372,21 @@ func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
// 查询同样带上路径 game_id防止 rps 页面仅凭 match_id 读到 dice 局详情并继续轮询。
GameId: expectedGameID,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) getRPSMatch(writer http.ResponseWriter, request *http.Request) {
// match_id 已经由 game-service 全局唯一约束HTTP 层不再反查 game_id避免刷新路径多一次数据库读。
h.getDiceMatch(writer, request)
func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.rollSelfGameMatch(writer, request, selfGameIDDice)
}
func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Request) {
func (h *Handler) rollRPSMatch(writer http.ResponseWriter, request *http.Request) {
h.rollSelfGameMatch(writer, request, selfGameIDRock)
}
func (h *Handler) rollSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
@ -345,21 +396,42 @@ func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Reques
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
rpsGesture := ""
if expectedGameID == selfGameIDRock {
var body struct {
Gesture string `json:"gesture"`
RPSGesture string `json:"rps_gesture"`
}
if !decodeOptionalJSONBody(writer, request, &body) {
return
}
var ok bool
rpsGesture, ok = selfGameRPSGestureFromInput(writer, request, expectedGameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), false)
if !ok {
return
}
}
// roll 不接收骰子点数、结果或派奖金额;这些全部由 game-service 服务端随机和结算。
resp, err := h.gameClient.RollDiceMatch(request.Context(), &gamev1.RollDiceMatchRequest{
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
// roll 是资金入口,必须在 game-service 抢结算锁前验证当前路径只能操作自己的自研游戏局。
GameId: expectedGameID,
RpsGesture: rpsGesture,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) rollRPSMatch(writer http.ResponseWriter, request *http.Request) {
// H5 会带 gesture 做展示态上报;当前服务端结算仍以自研匹配局事实为准,客户端不能提交结果或派奖金额。
h.rollDiceMatch(writer, request)
func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Request) {
h.cancelSelfGameMatch(writer, request, selfGameIDDice)
}
func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Request) {
func (h *Handler) cancelRPSMatch(writer http.ResponseWriter, request *http.Request) {
h.cancelSelfGameMatch(writer, request, selfGameIDRock)
}
func (h *Handler) cancelSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
@ -373,15 +445,12 @@ func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Requ
Meta: gameMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MatchId: matchID,
// 取消等待局也要校验 game_id否则用户可以从 rps 路径取消自己创建的 dice 等待局。
GameId: expectedGameID,
})
h.writeDiceMatch(writer, request, resp, err)
}
func (h *Handler) cancelRPSMatch(writer http.ResponseWriter, request *http.Request) {
// 取消只允许等待局创建者撤销,权限和状态仍在 game-service 内部用同一把 match 锁判断。
h.cancelDiceMatch(writer, request)
}
func (h *Handler) getRoomRPSConfig(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -552,6 +621,57 @@ func selfGameIDFromInput(writer http.ResponseWriter, request *http.Request, rawG
return gameID, true
}
func firstRPSGestureInput(gesture string, rpsGesture string) string {
if strings.TrimSpace(gesture) != "" {
return gesture
}
return rpsGesture
}
func selfGameRPSGestureFromInput(writer http.ResponseWriter, request *http.Request, gameID string, rawGesture string, required bool) (string, bool) {
if gameID != selfGameIDRock {
return "", true
}
if strings.TrimSpace(rawGesture) == "" && !required {
return "", true
}
gesture, ok := normalizeSelfRPSGesture(rawGesture)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return "", false
}
return gesture, true
}
func normalizeSelfRPSGesture(value string) (string, bool) {
// self-rps 的手势在入 game-service 前统一成小写英文枚举dice 路径不会调用这里,避免污染骰子玩法。
switch strings.ToLower(strings.TrimSpace(value)) {
case "rock", "paper", "scissors":
return strings.ToLower(strings.TrimSpace(value)), true
default:
return "", false
}
}
func decodeOptionalJSONBody(writer http.ResponseWriter, request *http.Request, out any) bool {
if request.Body == nil {
return true
}
raw, err := io.ReadAll(request.Body)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return false
}
if strings.TrimSpace(string(raw)) == "" {
return true
}
if err := httpkit.DecodeJSON(bytes.NewReader(raw), out); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return false
}
return true
}
func normalizeRoomRPSGesture(value string) (string, bool) {
// 手势在 gateway 层归一为小写英文枚举game-service 只接收稳定值,避免 Flutter 大小写或本地化文案污染订单事实。
switch strings.ToLower(strings.TrimSpace(value)) {

View File

@ -169,6 +169,8 @@ type diceParticipantItemData struct {
Status string `json:"status"`
StakeCoin int64 `json:"stake_coin"`
DicePoints []int32 `json:"dice_points"`
RPSGesture string `json:"rps_gesture"`
Gesture string `json:"gesture"`
Result string `json:"result"`
PayoutCoin int64 `json:"payout_coin"`
BalanceAfter int64 `json:"balance_after"`
@ -408,6 +410,8 @@ func diceParticipantItemDataFromProto(participant *gamev1.DiceParticipant) diceP
Status: participant.GetStatus(),
StakeCoin: participant.GetStakeCoin(),
DicePoints: participant.GetDicePoints(),
RPSGesture: participant.GetRpsGesture(),
Gesture: participant.GetRpsGesture(),
Result: participant.GetResult(),
PayoutCoin: participant.GetPayoutCoin(),
BalanceAfter: participant.GetBalanceAfter(),

View File

@ -565,7 +565,7 @@ func (s *Service) CreditInviteActivityReward(ctx context.Context, command ledger
return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward command is incomplete")
}
command.RewardType = strings.ToLower(strings.TrimSpace(command.RewardType))
if command.RewardType != "recharge" && command.RewardType != "valid_invite" {
if command.RewardType != "recharge" && command.RewardType != "valid_invite" && command.RewardType != "invite_inviter" && command.RewardType != "invite_invitee" {
return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)