邀请活动修改
This commit is contained in:
parent
b025ac4dda
commit
f4eee4db4c
@ -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" +
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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 {
|
||||
|
||||
339
docs/H5自研猜拳接入接口.md
Normal file
339
docs/H5自研猜拳接入接口.md
Normal 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 | 用户 ID,H5 优先用这个字段。 |
|
||||
| `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 返回和详情轮询刷新。
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 '领取时已达成值',
|
||||
|
||||
@ -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 '领取时已达成值',
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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_gesture;roll 阶段只负责扣款后把固定结果落库。
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 是唯一会触发钱包改账的骰子 RPC;gRPC 层不拆分扣款/派奖,避免客户端绕过局状态机。
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
// 独立猜拳复用自研匹配局 ID;join 不从 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)) {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user