1324 lines
47 KiB
Go
1324 lines
47 KiB
Go
package roomrps
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"google.golang.org/protobuf/proto"
|
||
gamev1 "hyapp.local/api/proto/game/v1"
|
||
userv1 "hyapp.local/api/proto/user/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/tencentim"
|
||
"hyapp/pkg/xerr"
|
||
)
|
||
|
||
const (
|
||
GameID = "room_rps"
|
||
|
||
StatusPending = "pending"
|
||
StatusMatched = "matched"
|
||
StatusRevealing = "revealing"
|
||
StatusFinished = "finished"
|
||
StatusTimeout = "timeout"
|
||
StatusSettlementFailed = "settlement_failed"
|
||
StatusCancelled = "cancelled"
|
||
|
||
SettlementPending = "pending"
|
||
SettlementSettled = "settled"
|
||
SettlementRefunded = "refunded"
|
||
SettlementFailed = "failed"
|
||
|
||
GestureRock = "rock"
|
||
GesturePaper = "paper"
|
||
GestureScissors = "scissors"
|
||
|
||
ResultWin = "win"
|
||
ResultLose = "lose"
|
||
ResultDraw = "draw"
|
||
|
||
eventChallengeCreated = "room_rps_challenge_created"
|
||
eventChallengeAccepted = "room_rps_challenge_accepted"
|
||
eventRevealCountdown = "room_rps_reveal_countdown"
|
||
eventFinished = "room_rps_finished"
|
||
eventExpired = "room_rps_expired"
|
||
|
||
roomRPSWalletPlatform = "room_rps"
|
||
roomRPSOpDebit = "debit"
|
||
roomRPSOpRefund = "refund"
|
||
)
|
||
|
||
// UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。
|
||
type UserClient interface {
|
||
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
|
||
}
|
||
|
||
// GiftCatalogClient 只暴露 room-rps 配置补齐需要的礼物配置查询能力;礼物名称、图标和币价仍由 wallet-service 作为 owner 输出。
|
||
type GiftCatalogClient interface {
|
||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||
}
|
||
|
||
// WalletClient 只暴露 room-rps 需要的钱包能力;PK 状态仍由 game-service 管,金币事实和幂等改账由 wallet-service 管。
|
||
type WalletClient interface {
|
||
ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
|
||
}
|
||
|
||
// ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。
|
||
type ConfigStore interface {
|
||
GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error)
|
||
UpsertRoomRPSConfig(ctx context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error)
|
||
}
|
||
|
||
// Publisher 是房间 IM 投递边界;生产用腾讯 IM REST,测试可以注入 fake,service 不感知具体 SDK。
|
||
type Publisher interface {
|
||
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
|
||
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
|
||
}
|
||
|
||
type Config struct {
|
||
ChallengeTimeout time.Duration
|
||
RevealCountdown time.Duration
|
||
}
|
||
|
||
type Service struct {
|
||
config Config
|
||
user UserClient
|
||
gifts GiftCatalogClient
|
||
wallet WalletClient
|
||
pub Publisher
|
||
store ConfigStore
|
||
|
||
mu sync.Mutex
|
||
configs map[string]*gamev1.RoomRPSConfig
|
||
challenges map[string]*gamev1.RoomRPSChallenge
|
||
now func() time.Time
|
||
}
|
||
|
||
// New 创建房内猜拳用例层。
|
||
// 配置已经接入 MySQL 持久化;挑战事实仍是进程内状态,后续独立迁移时继续沿用当前状态机和 RPC 契约。
|
||
func New(config Config, user UserClient, publisher Publisher, extras ...any) *Service {
|
||
if config.ChallengeTimeout <= 0 {
|
||
// 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。
|
||
config.ChallengeTimeout = 10 * time.Minute
|
||
}
|
||
if config.RevealCountdown <= 0 {
|
||
// 揭晓动画固定 3 秒;IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。
|
||
config.RevealCountdown = 3 * time.Second
|
||
}
|
||
var gifts GiftCatalogClient
|
||
var wallet WalletClient
|
||
var store ConfigStore
|
||
for _, extra := range extras {
|
||
if typed, ok := extra.(GiftCatalogClient); ok {
|
||
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
||
gifts = typed
|
||
}
|
||
if typed, ok := extra.(WalletClient); ok {
|
||
// 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。
|
||
wallet = typed
|
||
}
|
||
if typed, ok := extra.(ConfigStore); ok {
|
||
// MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。
|
||
store = typed
|
||
}
|
||
}
|
||
return &Service{
|
||
config: config,
|
||
user: user,
|
||
gifts: gifts,
|
||
wallet: wallet,
|
||
pub: publisher,
|
||
store: store,
|
||
configs: make(map[string]*gamev1.RoomRPSConfig),
|
||
challenges: make(map[string]*gamev1.RoomRPSChallenge),
|
||
now: time.Now,
|
||
}
|
||
}
|
||
|
||
// NewTencentIMPublisher 创建真实腾讯 IM publisher。
|
||
// game-service 的房内 PK 发起接口是同步返回给客户端的入口,因此 IM 配置必须来自已校验的 YAML,而不是进程环境变量的隐式副作用。
|
||
func NewTencentIMPublisher(config tencentim.RESTConfig) (Publisher, error) {
|
||
return tencentim.NewRESTClient(config)
|
||
}
|
||
|
||
func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app = appcode.Normalize(app)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
|
||
if s.store != nil {
|
||
return s.getPersistentConfig(ctx, app)
|
||
}
|
||
|
||
s.mu.Lock()
|
||
config := s.configForAppLocked(app)
|
||
cloned := cloneConfig(config)
|
||
originalUpdatedAtMS := config.GetUpdatedAtMs()
|
||
s.mu.Unlock()
|
||
|
||
enriched, changed, err := s.enrichConfigGifts(ctx, app, cloned)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if changed {
|
||
s.mu.Lock()
|
||
// 线上已有旧配置可能只保存 gift_id;读接口补齐成功后同步回内存,后续发起挑战也能使用真实币价和展示快照。
|
||
if current := s.configs[app]; current != nil && current.GetUpdatedAtMs() == originalUpdatedAtMS {
|
||
s.configs[app] = cloneConfig(enriched)
|
||
}
|
||
s.mu.Unlock()
|
||
}
|
||
return enriched, s.now().UnixMilli(), nil
|
||
}
|
||
|
||
func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app = appcode.Normalize(app)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
|
||
normalized, err := s.normalizeConfig(ctx, app, config)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
|
||
if s.store != nil {
|
||
// 数据库保存时由唯一键保持 created_at_ms;service 每次提交只表达“这个时刻产生了新配置版本”。
|
||
normalized.CreatedAtMs = nowMS
|
||
normalized.UpdatedAtMs = nowMS
|
||
saved, err := s.store.UpsertRoomRPSConfig(ctx, normalized)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
s.mu.Lock()
|
||
delete(s.configs, app)
|
||
s.mu.Unlock()
|
||
return cloneConfig(saved), nowMS, nil
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 {
|
||
normalized.CreatedAtMs = existing.GetCreatedAtMs()
|
||
} else {
|
||
normalized.CreatedAtMs = nowMS
|
||
}
|
||
normalized.UpdatedAtMs = nowMS
|
||
s.configs[app] = normalized
|
||
return cloneConfig(normalized), nowMS, nil
|
||
}
|
||
|
||
func (s *Service) ListChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) ([]*gamev1.RoomRPSChallenge, string, int32, int64, error) {
|
||
if s == nil {
|
||
return nil, "", 0, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app := appcode.Normalize(req.GetMeta().GetAppCode())
|
||
ctx = appcode.WithContext(ctx, app)
|
||
pageSize := req.GetPageSize()
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
if pageSize > 100 {
|
||
// 后台列表和 App 房间补拉都走同一接口;这里硬限制每页 100,防止一次请求把进程内状态全量复制出去。
|
||
pageSize = 100
|
||
}
|
||
cursorOffset := 0
|
||
if raw := strings.TrimSpace(req.GetCursor()); raw != "" {
|
||
parsed, err := strconv.Atoi(raw)
|
||
if err != nil || parsed < 0 {
|
||
return nil, "", 0, 0, xerr.New(xerr.InvalidArgument, "room rps cursor is invalid")
|
||
}
|
||
cursorOffset = parsed
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
status := normalizeStatus(req.GetStatus())
|
||
items := make([]*gamev1.RoomRPSChallenge, 0, len(s.challenges))
|
||
for _, item := range s.challenges {
|
||
if item.GetAppCode() != app {
|
||
continue
|
||
}
|
||
if roomID := strings.TrimSpace(req.GetRoomId()); roomID != "" && item.GetRoomId() != roomID {
|
||
continue
|
||
}
|
||
if status != "" && item.GetStatus() != status {
|
||
continue
|
||
}
|
||
if req.GetInitiatorUserId() > 0 && item.GetInitiator().GetUserId() != req.GetInitiatorUserId() {
|
||
continue
|
||
}
|
||
if req.GetChallengerUserId() > 0 && item.GetChallenger().GetUserId() != req.GetChallengerUserId() {
|
||
continue
|
||
}
|
||
if req.GetStartTimeMs() > 0 && item.GetCreatedAtMs() < req.GetStartTimeMs() {
|
||
continue
|
||
}
|
||
if req.GetEndTimeMs() > 0 && item.GetCreatedAtMs() > req.GetEndTimeMs() {
|
||
continue
|
||
}
|
||
items = append(items, cloneChallenge(item))
|
||
}
|
||
sort.Slice(items, func(i, j int) bool {
|
||
if items[i].GetCreatedAtMs() == items[j].GetCreatedAtMs() {
|
||
return items[i].GetChallengeId() > items[j].GetChallengeId()
|
||
}
|
||
return items[i].GetCreatedAtMs() > items[j].GetCreatedAtMs()
|
||
})
|
||
|
||
if cursorOffset >= len(items) {
|
||
return []*gamev1.RoomRPSChallenge{}, "", pageSize, s.now().UnixMilli(), nil
|
||
}
|
||
end := cursorOffset + int(pageSize)
|
||
nextCursor := ""
|
||
if end < len(items) {
|
||
nextCursor = strconv.Itoa(end)
|
||
} else {
|
||
end = len(items)
|
||
}
|
||
return items[cursorOffset:end], nextCursor, pageSize, s.now().UnixMilli(), nil
|
||
}
|
||
|
||
func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app := appcode.Normalize(req.GetMeta().GetAppCode())
|
||
ctx = appcode.WithContext(ctx, app)
|
||
roomID := strings.TrimSpace(req.GetRoomId())
|
||
gesture, ok := normalizeGesture(req.GetGesture())
|
||
giftID := roomRPSRequestGiftID(req)
|
||
if req.GetUserId() <= 0 || roomID == "" || giftID == "" || !ok {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete")
|
||
}
|
||
|
||
config, err := s.runtimeConfig(ctx, app)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if config.GetStatus() != "active" {
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
|
||
}
|
||
gift, ok := stakeGiftByID(config, giftID)
|
||
if !ok || !gift.GetEnabled() {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
|
||
}
|
||
stakeCoin := gift.GetGiftPriceCoin()
|
||
if stakeCoin <= 0 {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid")
|
||
}
|
||
challengeID := newChallengeID(app)
|
||
initiatorBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
s.mu.Lock()
|
||
now := s.now()
|
||
nowMS := now.UnixMilli()
|
||
challenge := &gamev1.RoomRPSChallenge{
|
||
AppCode: app,
|
||
ChallengeId: challengeID,
|
||
RoomId: roomID,
|
||
RegionId: req.GetRegionId(),
|
||
Status: StatusPending,
|
||
StakeGiftId: legacyRoomRPSGiftID(giftID),
|
||
StakeGiftIdText: giftID,
|
||
StakeCoin: stakeCoin,
|
||
Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS},
|
||
SettlementStatus: SettlementPending,
|
||
TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(),
|
||
CreatedAtMs: nowMS,
|
||
UpdatedAtMs: nowMS,
|
||
}
|
||
s.challenges[challenge.GetChallengeId()] = challenge
|
||
cloned := cloneChallenge(challenge)
|
||
s.mu.Unlock()
|
||
|
||
if err := s.publishChallengeEvent(ctx, eventChallengeCreated, cloned); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return cloned, nowMS, nil
|
||
}
|
||
|
||
func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app := appcode.Normalize(req.GetMeta().GetAppCode())
|
||
ctx = appcode.WithContext(ctx, app)
|
||
challengeID := strings.TrimSpace(req.GetChallengeId())
|
||
gesture, ok := normalizeGesture(req.GetGesture())
|
||
if req.GetUserId() <= 0 || challengeID == "" || !ok {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps accept command is incomplete")
|
||
}
|
||
|
||
s.mu.Lock()
|
||
challenge, ok := s.challenges[challengeID]
|
||
if !ok || challenge.GetAppCode() != app {
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||
}
|
||
now := s.now()
|
||
nowMS := now.UnixMilli()
|
||
if challenge.GetStatus() != StatusPending {
|
||
// pending 是唯一可应战状态;matched/revealing/finished/timeout 都已经有明确归属,重复点击只能返回冲突。
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending")
|
||
}
|
||
if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() {
|
||
// 读到已超时但还未收敛的 pending 时,先在锁内改成 timeout,避免下一个用户还能应战同一单。
|
||
challenge.Status = StatusTimeout
|
||
challenge.SettlementStatus = SettlementRefunded
|
||
challenge.UpdatedAtMs = nowMS
|
||
cloned := cloneChallenge(challenge)
|
||
s.mu.Unlock()
|
||
refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned)
|
||
if refundErr != nil {
|
||
s.markSettlementFailed(app, challengeID, refundErr)
|
||
return nil, 0, refundErr
|
||
}
|
||
s.replaceChallengeSnapshot(app, challengeID, refunded)
|
||
cloned = refunded
|
||
_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout")
|
||
}
|
||
if challenge.GetInitiator().GetUserId() == req.GetUserId() {
|
||
// 发起人不能应战自己的单;这个判断必须在 service 锁内做,不能依赖前端按钮状态。
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge")
|
||
}
|
||
stakeCoin := challenge.GetStakeCoin()
|
||
roomID := challenge.GetRoomId()
|
||
s.mu.Unlock()
|
||
|
||
challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
s.mu.Lock()
|
||
challenge, ok = s.challenges[challengeID]
|
||
if !ok || challenge.GetAppCode() != app {
|
||
s.mu.Unlock()
|
||
_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||
}
|
||
now = s.now()
|
||
nowMS = now.UnixMilli()
|
||
if challenge.GetStatus() != StatusPending {
|
||
// 钱包扣款期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准,失败时立即退回刚扣的挑战方本金。
|
||
s.mu.Unlock()
|
||
_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending")
|
||
}
|
||
if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() {
|
||
// 二次校验也要收敛超时单,避免扣款耗时跨过 timeout 后仍然匹配成功。
|
||
challenge.Status = StatusTimeout
|
||
challenge.SettlementStatus = SettlementRefunded
|
||
challenge.UpdatedAtMs = nowMS
|
||
cloned := cloneChallenge(challenge)
|
||
s.mu.Unlock()
|
||
_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned)
|
||
if refundErr != nil {
|
||
s.markSettlementFailed(app, challengeID, refundErr)
|
||
return nil, 0, refundErr
|
||
}
|
||
s.replaceChallengeSnapshot(app, challengeID, refunded)
|
||
cloned = refunded
|
||
_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout")
|
||
}
|
||
if challenge.GetInitiator().GetUserId() == req.GetUserId() {
|
||
// 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。
|
||
s.mu.Unlock()
|
||
_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge")
|
||
}
|
||
|
||
challenge.Status = StatusFinished
|
||
challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS}
|
||
challenge.MatchedAtMs = nowMS
|
||
challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli()
|
||
challenge.SettledAtMs = nowMS
|
||
challenge.UpdatedAtMs = nowMS
|
||
applyResult(challenge)
|
||
cloned := cloneChallenge(challenge)
|
||
s.mu.Unlock()
|
||
|
||
settled, err := s.settleChallenge(ctx, req.GetMeta().GetRequestId(), cloned)
|
||
if err != nil {
|
||
s.mu.Lock()
|
||
if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app {
|
||
current.Status = StatusSettlementFailed
|
||
current.SettlementStatus = SettlementFailed
|
||
current.FailureReason = err.Error()
|
||
current.UpdatedAtMs = s.now().UnixMilli()
|
||
}
|
||
s.mu.Unlock()
|
||
return nil, 0, err
|
||
}
|
||
s.mu.Lock()
|
||
if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app {
|
||
current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter()
|
||
current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter()
|
||
current.SettlementStatus = settled.GetSettlementStatus()
|
||
current.FailureReason = ""
|
||
current.SettledAtMs = s.now().UnixMilli()
|
||
current.UpdatedAtMs = current.GetSettledAtMs()
|
||
cloned = cloneChallenge(current)
|
||
} else {
|
||
cloned = settled
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.publishChallengeEvent(ctx, eventRevealCountdown, cloned); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return cloned, nowMS, nil
|
||
}
|
||
|
||
func (s *Service) GetChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app = appcode.Normalize(app)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
challengeID = strings.TrimSpace(challengeID)
|
||
if challengeID == "" {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
challenge, ok := s.challenges[challengeID]
|
||
if !ok || challenge.GetAppCode() != app {
|
||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||
}
|
||
return cloneChallenge(challenge), s.now().UnixMilli(), nil
|
||
}
|
||
|
||
func (s *Service) RetrySettlement(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app = appcode.Normalize(app)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
challengeID = strings.TrimSpace(challengeID)
|
||
if challengeID == "" {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
|
||
}
|
||
|
||
s.mu.Lock()
|
||
challenge, ok := s.challenges[challengeID]
|
||
if !ok || challenge.GetAppCode() != app {
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||
}
|
||
if challenge.GetStatus() != StatusSettlementFailed && challenge.GetSettlementStatus() != SettlementFailed {
|
||
// 只有结算失败单能进入重试;已结算、已退款、pending 或 timeout 都不能被后台按钮强制改结果。
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not settlement_failed")
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
challenge.Status = StatusFinished
|
||
challenge.SettlementStatus = SettlementSettled
|
||
challenge.FailureReason = ""
|
||
challenge.SettledAtMs = nowMS
|
||
challenge.UpdatedAtMs = nowMS
|
||
cloned := cloneChallenge(challenge)
|
||
s.mu.Unlock()
|
||
|
||
if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return cloned, nowMS, nil
|
||
}
|
||
|
||
func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
|
||
if s == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||
}
|
||
app = appcode.Normalize(app)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
challengeID = strings.TrimSpace(challengeID)
|
||
if challengeID == "" {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
|
||
}
|
||
|
||
s.mu.Lock()
|
||
challenge, ok := s.challenges[challengeID]
|
||
if !ok || challenge.GetAppCode() != app {
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||
}
|
||
if challenge.GetStatus() != StatusPending || challenge.GetChallenger().GetUserId() > 0 {
|
||
// 手动过期只允许无人应战的 pending 单;一旦有 challenger,就必须走揭晓/结算链路,不能后台撤销。
|
||
s.mu.Unlock()
|
||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge can not expire")
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
challenge.Status = StatusTimeout
|
||
challenge.SettlementStatus = SettlementRefunded
|
||
challenge.UpdatedAtMs = nowMS
|
||
cloned := cloneChallenge(challenge)
|
||
s.mu.Unlock()
|
||
|
||
refunded, err := s.refundPendingChallenge(ctx, "room-rps-expire:"+challengeID, cloned)
|
||
if err != nil {
|
||
s.markSettlementFailed(app, challengeID, err)
|
||
return nil, 0, err
|
||
}
|
||
s.replaceChallengeSnapshot(app, challengeID, refunded)
|
||
cloned = refunded
|
||
|
||
if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return cloned, nowMS, nil
|
||
}
|
||
|
||
func (s *Service) getPersistentConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
|
||
nowMS := s.now().UnixMilli()
|
||
config, err := s.store.GetRoomRPSConfig(ctx, app, GameID)
|
||
persisted := true
|
||
if err != nil {
|
||
if !xerr.IsCode(err, xerr.NotFound) {
|
||
return nil, 0, err
|
||
}
|
||
// 首次部署或本地空库没有配置行时仍返回产品默认值;只有后台保存后才写入 MySQL,避免读接口偷偷覆盖运营数据。
|
||
config = defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
|
||
persisted = false
|
||
}
|
||
|
||
enriched, changed, err := s.enrichConfigGifts(ctx, app, cloneConfig(config))
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if changed && persisted {
|
||
// 历史行可能只保存 gift_id 或误把 mp4 写进 icon;读路径修正成功后回写,后续重启和发起挑战都使用同一份快照。
|
||
enriched.UpdatedAtMs = nowMS
|
||
saved, err := s.store.UpsertRoomRPSConfig(ctx, enriched)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
enriched = saved
|
||
}
|
||
return cloneConfig(enriched), nowMS, nil
|
||
}
|
||
|
||
func (s *Service) runtimeConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, error) {
|
||
if s.store != nil {
|
||
config, _, err := s.GetConfig(ctx, app)
|
||
return config, err
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return cloneConfig(s.configForAppLocked(app)), nil
|
||
}
|
||
|
||
func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig {
|
||
if config := s.configs[app]; config != nil {
|
||
return config
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
config := defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
|
||
s.configs[app] = config
|
||
return config
|
||
}
|
||
|
||
func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
|
||
if config == nil {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps config is required")
|
||
}
|
||
status := normalizeStatus(config.GetStatus())
|
||
if status == "" {
|
||
// 逻辑功能配置默认打开;后台未传 status 时按 active 保存,不能静默写成关闭。
|
||
status = "active"
|
||
}
|
||
if status != "active" && status != "disabled" {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps status is invalid")
|
||
}
|
||
timeoutMS := config.GetChallengeTimeoutMs()
|
||
if timeoutMS <= 0 {
|
||
timeoutMS = int64(s.config.ChallengeTimeout / time.Millisecond)
|
||
}
|
||
revealMS := config.GetRevealCountdownMs()
|
||
if revealMS <= 0 {
|
||
revealMS = int64(s.config.RevealCountdown / time.Millisecond)
|
||
}
|
||
if len(config.GetStakeGifts()) != 4 {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps requires exactly 4 stake gifts")
|
||
}
|
||
|
||
defaultsByID := defaultGiftMap()
|
||
gifts := make([]*gamev1.RoomRPSStakeGift, 0, 4)
|
||
seenGift := make(map[string]struct{}, 4)
|
||
seenSort := make(map[int32]struct{}, 4)
|
||
for index, gift := range config.GetStakeGifts() {
|
||
giftID := roomRPSStakeGiftID(gift)
|
||
if giftID == "" {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid")
|
||
}
|
||
if _, ok := seenGift[giftID]; ok {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is duplicated")
|
||
}
|
||
sortOrder := gift.GetSortOrder()
|
||
if sortOrder <= 0 {
|
||
sortOrder = int32(index + 1)
|
||
}
|
||
if _, ok := seenSort[sortOrder]; ok {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps gift sort_order is duplicated")
|
||
}
|
||
seenGift[giftID] = struct{}{}
|
||
seenSort[sortOrder] = struct{}{}
|
||
|
||
normalized := &gamev1.RoomRPSStakeGift{
|
||
GiftId: legacyRoomRPSGiftID(giftID),
|
||
GiftIdText: giftID,
|
||
GiftName: strings.TrimSpace(gift.GetGiftName()),
|
||
GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()),
|
||
GiftAnimationUrl: strings.TrimSpace(gift.GetGiftAnimationUrl()),
|
||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||
Enabled: gift.GetEnabled(),
|
||
SortOrder: sortOrder,
|
||
}
|
||
gifts = append(gifts, normalized)
|
||
}
|
||
if _, err := s.enrichStakeGifts(ctx, app, gifts); err != nil {
|
||
return nil, err
|
||
}
|
||
for _, normalized := range gifts {
|
||
if fallback := defaultsByID[roomRPSStakeGiftID(normalized)]; fallback != nil {
|
||
if normalized.GiftName == "" {
|
||
normalized.GiftName = fallback.GetGiftName()
|
||
}
|
||
if normalized.GiftIconUrl == "" {
|
||
normalized.GiftIconUrl = fallback.GetGiftIconUrl()
|
||
}
|
||
if normalized.GiftPriceCoin <= 0 {
|
||
normalized.GiftPriceCoin = fallback.GetGiftPriceCoin()
|
||
}
|
||
}
|
||
if normalized.GiftPriceCoin <= 0 {
|
||
// 运行时必须知道当前档位折算金币,后续接钱包托管也以这个金额作为订单事实。
|
||
normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100
|
||
}
|
||
}
|
||
sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() })
|
||
return &gamev1.RoomRPSConfig{
|
||
AppCode: app,
|
||
GameId: GameID,
|
||
Status: status,
|
||
ChallengeTimeoutMs: timeoutMS,
|
||
RevealCountdownMs: revealMS,
|
||
StakeGifts: gifts,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) enrichConfigGifts(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, bool, error) {
|
||
if config == nil {
|
||
return nil, false, nil
|
||
}
|
||
changed, err := s.enrichStakeGifts(ctx, app, config.GetStakeGifts())
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
return config, changed, nil
|
||
}
|
||
|
||
func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gamev1.RoomRPSStakeGift) (bool, error) {
|
||
if len(gifts) == 0 {
|
||
return false, nil
|
||
}
|
||
needGiftIDs := make([]string, 0, len(gifts))
|
||
seen := make(map[string]struct{}, len(gifts))
|
||
for _, gift := range gifts {
|
||
giftID := roomRPSStakeGiftID(gift)
|
||
if giftID == "" {
|
||
continue
|
||
}
|
||
if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftAnimationUrl() != "" && gift.GetGiftPriceCoin() > 0 && !roomRPSURLLooksAnimated(gift.GetGiftIconUrl()) {
|
||
continue
|
||
}
|
||
if _, ok := seen[giftID]; ok {
|
||
continue
|
||
}
|
||
// 只查询缺字段的档位,避免每次读配置都把 wallet-service 当成全量礼物列表使用。
|
||
seen[giftID] = struct{}{}
|
||
needGiftIDs = append(needGiftIDs, giftID)
|
||
}
|
||
if len(needGiftIDs) == 0 {
|
||
return false, nil
|
||
}
|
||
catalog, err := s.roomRPSGiftCatalog(ctx, app, needGiftIDs)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
changed := false
|
||
for _, gift := range gifts {
|
||
if gift == nil {
|
||
continue
|
||
}
|
||
item := catalog[roomRPSStakeGiftID(gift)]
|
||
if item == nil {
|
||
continue
|
||
}
|
||
if gift.GiftName == "" {
|
||
if name := roomRPSGiftDisplayName(item); name != "" {
|
||
// 后台保存 room-rps 时只提交 gift_id;运行时展示名必须来自 wallet-service 礼物配置,不能让前端猜。
|
||
gift.GiftName = name
|
||
changed = true
|
||
}
|
||
}
|
||
if gift.GiftIconUrl == "" || roomRPSURLLooksAnimated(gift.GiftIconUrl) {
|
||
if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
|
||
// gift_icon_url 只给静态封面;历史配置曾把 animation_url 写进这里,读配置时要用 wallet 资源重新纠正。
|
||
gift.GiftIconUrl = iconURL
|
||
changed = true
|
||
}
|
||
}
|
||
if gift.GiftAnimationUrl == "" {
|
||
if animationURL := roomRPSGiftAnimationURL(item); animationURL != "" {
|
||
// 动效单独返回给客户端按需播放,不能再混入 icon 字段,否则列表和选择面板会把 mp4 当图片加载。
|
||
gift.GiftAnimationUrl = animationURL
|
||
changed = true
|
||
}
|
||
}
|
||
if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 {
|
||
// 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。
|
||
gift.GiftPriceCoin = item.GetCoinPrice()
|
||
changed = true
|
||
}
|
||
}
|
||
return changed, nil
|
||
}
|
||
|
||
func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []string) (map[string]*walletv1.GiftConfig, error) {
|
||
result := make(map[string]*walletv1.GiftConfig, len(giftIDs))
|
||
if s.gifts == nil {
|
||
return result, nil
|
||
}
|
||
app = appcode.Normalize(app)
|
||
for _, giftID := range giftIDs {
|
||
giftID = strings.TrimSpace(giftID)
|
||
if giftID == "" {
|
||
continue
|
||
}
|
||
for page := int32(1); ; page++ {
|
||
resp, err := s.gifts.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
|
||
RequestId: fmt.Sprintf("room-rps-config:%s:%s:%d", app, giftID, page),
|
||
AppCode: app,
|
||
Keyword: giftID,
|
||
Page: page,
|
||
PageSize: 100,
|
||
})
|
||
if err != nil {
|
||
return nil, xerr.New(xerr.Unavailable, "room rps gift config lookup failed")
|
||
}
|
||
for _, item := range resp.GetGifts() {
|
||
if strings.TrimSpace(item.GetGiftId()) != giftID {
|
||
continue
|
||
}
|
||
result[giftID] = item
|
||
break
|
||
}
|
||
if result[giftID] != nil {
|
||
break
|
||
}
|
||
if len(resp.GetGifts()) < 100 {
|
||
break
|
||
}
|
||
if total := resp.GetTotal(); total > 0 && int64(page)*100 >= total {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) settleChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) {
|
||
if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetChallenger().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps settlement challenge is incomplete")
|
||
}
|
||
settled := cloneChallenge(challenge)
|
||
app := settled.GetAppCode()
|
||
challengeID := settled.GetChallengeId()
|
||
roomID := settled.GetRoomId()
|
||
stakeCoin := settled.GetStakeCoin()
|
||
|
||
if settled.GetInitiator().GetResult() == ResultDraw && settled.GetChallenger().GetResult() == ResultDraw {
|
||
// 平局没有赢家,也不应产生任何礼物流;双方前置扣款都按原金额退款,客户端只展示 refunded 事实。
|
||
initiatorBalance, initiatorErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
if initiatorErr == nil {
|
||
settled.Initiator.BalanceAfter = initiatorBalance
|
||
}
|
||
challengerBalance, challengerErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
if challengerErr == nil {
|
||
settled.Challenger.BalanceAfter = challengerBalance
|
||
}
|
||
if initiatorErr != nil {
|
||
return settled, initiatorErr
|
||
}
|
||
if challengerErr != nil {
|
||
return settled, challengerErr
|
||
}
|
||
settled.SettlementStatus = SettlementRefunded
|
||
return settled, nil
|
||
}
|
||
|
||
switch settled.GetWinnerUserId() {
|
||
case settled.GetInitiator().GetUserId():
|
||
// 胜者本金必须退回;败者在点击 PK 时已经扣款,这笔扣款保留为败者成本,不再追加第二次扣费。
|
||
balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
if err != nil {
|
||
return settled, err
|
||
}
|
||
settled.Initiator.BalanceAfter = balanceAfter
|
||
settled.SettlementStatus = SettlementSettled
|
||
return settled, nil
|
||
case settled.GetChallenger().GetUserId():
|
||
// 只退赢家本金;输家的余额保持前置扣款后的余额,避免“输了但金币没少”的账务错误。
|
||
balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund)
|
||
if err != nil {
|
||
return settled, err
|
||
}
|
||
settled.Challenger.BalanceAfter = balanceAfter
|
||
settled.SettlementStatus = SettlementSettled
|
||
return settled, nil
|
||
default:
|
||
return settled, xerr.New(xerr.InvalidArgument, "room rps winner is invalid")
|
||
}
|
||
}
|
||
|
||
func (s *Service) refundPendingChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) {
|
||
if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 {
|
||
return nil, xerr.New(xerr.InvalidArgument, "room rps pending refund challenge is incomplete")
|
||
}
|
||
refunded := cloneChallenge(challenge)
|
||
// 无人应战或已超时的 pending 单只存在发起人前置扣款;过期时退回发起人,不产生礼物或赢家收益。
|
||
balanceAfter, err := s.applyCoinChange(ctx, requestID, refunded.GetAppCode(), refunded.GetChallengeId(), refunded.GetRoomId(), refunded.GetInitiator().GetUserId(), refunded.GetStakeCoin(), roomRPSOpRefund)
|
||
if err != nil {
|
||
return refunded, err
|
||
}
|
||
refunded.Initiator.BalanceAfter = balanceAfter
|
||
refunded.SettlementStatus = SettlementRefunded
|
||
refunded.FailureReason = ""
|
||
refunded.UpdatedAtMs = s.now().UnixMilli()
|
||
return refunded, nil
|
||
}
|
||
|
||
func (s *Service) replaceChallengeSnapshot(app string, challengeID string, snapshot *gamev1.RoomRPSChallenge) {
|
||
if s == nil || snapshot == nil {
|
||
return
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
current := s.challenges[strings.TrimSpace(challengeID)]
|
||
if current == nil || current.GetAppCode() != appcode.Normalize(app) {
|
||
return
|
||
}
|
||
s.challenges[strings.TrimSpace(challengeID)] = cloneChallenge(snapshot)
|
||
}
|
||
|
||
func (s *Service) markSettlementFailed(app string, challengeID string, cause error) {
|
||
if s == nil || cause == nil {
|
||
return
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
current := s.challenges[strings.TrimSpace(challengeID)]
|
||
if current == nil || current.GetAppCode() != appcode.Normalize(app) {
|
||
return
|
||
}
|
||
current.Status = StatusSettlementFailed
|
||
current.SettlementStatus = SettlementFailed
|
||
current.FailureReason = cause.Error()
|
||
current.UpdatedAtMs = s.now().UnixMilli()
|
||
}
|
||
|
||
func (s *Service) applyCoinChange(ctx context.Context, requestID string, app string, challengeID string, roomID string, userID int64, amount int64, opType string) (int64, error) {
|
||
if amount <= 0 || userID <= 0 || strings.TrimSpace(challengeID) == "" {
|
||
return 0, xerr.New(xerr.InvalidArgument, "room rps coin command is incomplete")
|
||
}
|
||
if s.wallet == nil {
|
||
// 钱包不可用时必须拒绝进入 PK;否则房间状态会成功但 COIN 事实无法同步落账。
|
||
return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured")
|
||
}
|
||
app = appcode.Normalize(app)
|
||
opType = strings.TrimSpace(opType)
|
||
providerOrderID := fmt.Sprintf("room_rps:%s:%d:%s", strings.TrimSpace(challengeID), userID, opType)
|
||
requestHash := roomRPSStableHash(fmt.Sprintf("%s|%s|%s|%s|%d|%d", app, strings.TrimSpace(challengeID), strings.TrimSpace(roomID), opType, userID, amount))
|
||
resp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
|
||
RequestId: strings.TrimSpace(requestID),
|
||
AppCode: app,
|
||
CommandId: "game:" + providerOrderID,
|
||
UserId: userID,
|
||
PlatformCode: roomRPSWalletPlatform,
|
||
GameId: GameID,
|
||
ProviderOrderId: providerOrderID,
|
||
ProviderRoundId: strings.TrimSpace(challengeID),
|
||
OpType: opType,
|
||
CoinAmount: amount,
|
||
RoomId: strings.TrimSpace(roomID),
|
||
RequestHash: requestHash,
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return resp.GetBalanceAfter(), nil
|
||
}
|
||
|
||
func roomRPSStableHash(value string) string {
|
||
sum := sha256.Sum256([]byte(value))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string {
|
||
if gift == nil {
|
||
return ""
|
||
}
|
||
if name := strings.TrimSpace(gift.GetName()); name != "" {
|
||
return name
|
||
}
|
||
return strings.TrimSpace(gift.GetResource().GetName())
|
||
}
|
||
|
||
func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string {
|
||
if gift == nil || gift.GetResource() == nil {
|
||
return ""
|
||
}
|
||
candidates := []string{
|
||
gift.GetResource().GetAssetUrl(),
|
||
gift.GetResource().GetPreviewUrl(),
|
||
}
|
||
for _, candidate := range candidates {
|
||
if value := strings.TrimSpace(candidate); value != "" && !roomRPSURLLooksAnimated(value) {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func roomRPSGiftAnimationURL(gift *walletv1.GiftConfig) string {
|
||
if gift == nil || gift.GetResource() == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(gift.GetResource().GetAnimationUrl())
|
||
}
|
||
|
||
func roomRPSURLLooksAnimated(value string) bool {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, suffix := range []string{".mp4", ".svga", ".pag", ".webm", ".mov"} {
|
||
if strings.Contains(value, suffix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error {
|
||
if s.pub == nil || challenge == nil {
|
||
return nil
|
||
}
|
||
// 每次发业务 IM 前都幂等建群;真实腾讯云已存在返回成功,临时房间或测试群也不需要额外预置。
|
||
if err := s.pub.EnsureGroup(ctx, tencentim.GroupSpec{
|
||
GroupID: challenge.GetRoomId(),
|
||
Name: challenge.GetRoomId(),
|
||
Type: tencentim.DefaultGroupType,
|
||
ApplyJoinOption: "FreeAccess",
|
||
}); err != nil {
|
||
return xerr.New(xerr.Unavailable, "room rps im group ensure failed")
|
||
}
|
||
payload, err := s.challengeEventPayload(ctx, eventType, challenge)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
eventID := fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), s.now().UnixNano())
|
||
if err := s.pub.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
|
||
GroupID: challenge.GetRoomId(),
|
||
EventID: eventID,
|
||
Desc: eventType,
|
||
Ext: "room_rps",
|
||
PayloadJSON: payload,
|
||
}); err != nil {
|
||
return xerr.New(xerr.Unavailable, "room rps im publish failed")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) challengeEventPayload(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) (json.RawMessage, error) {
|
||
nowMS := s.now().UnixMilli()
|
||
payload := map[string]any{
|
||
"event_id": fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), nowMS),
|
||
"event_type": eventType,
|
||
"room_id": challenge.GetRoomId(),
|
||
"challenge_id": challenge.GetChallengeId(),
|
||
"server_time_ms": nowMS,
|
||
"challenge": s.challengeMap(ctx, challenge),
|
||
}
|
||
raw, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, xerr.New(xerr.Internal, "room rps im payload marshal failed")
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
func (s *Service) challengeMap(ctx context.Context, challenge *gamev1.RoomRPSChallenge) map[string]any {
|
||
return map[string]any{
|
||
"app_code": challenge.GetAppCode(),
|
||
"challenge_id": challenge.GetChallengeId(),
|
||
"room_id": challenge.GetRoomId(),
|
||
"region_id": challenge.GetRegionId(),
|
||
"status": challenge.GetStatus(),
|
||
"stake_gift_id": roomRPSChallengeGiftID(challenge),
|
||
"stake_gift_id_number": challenge.GetStakeGiftId(),
|
||
"stake_coin": challenge.GetStakeCoin(),
|
||
"initiator": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetInitiator()),
|
||
"challenger": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetChallenger()),
|
||
"winner_user_id": strconv.FormatInt(challenge.GetWinnerUserId(), 10),
|
||
"settlement_status": challenge.GetSettlementStatus(),
|
||
"failure_reason": challenge.GetFailureReason(),
|
||
"timeout_at_ms": challenge.GetTimeoutAtMs(),
|
||
"reveal_at_ms": challenge.GetRevealAtMs(),
|
||
"created_at_ms": challenge.GetCreatedAtMs(),
|
||
"matched_at_ms": challenge.GetMatchedAtMs(),
|
||
"settled_at_ms": challenge.GetSettledAtMs(),
|
||
"updated_at_ms": challenge.GetUpdatedAtMs(),
|
||
}
|
||
}
|
||
|
||
func (s *Service) playerMap(ctx context.Context, app string, player *gamev1.RoomRPSPlayer) map[string]any {
|
||
if player == nil || player.GetUserId() <= 0 {
|
||
return map[string]any{}
|
||
}
|
||
user := s.displayUser(ctx, app, player.GetUserId())
|
||
return map[string]any{
|
||
"user_id": strconv.FormatInt(player.GetUserId(), 10),
|
||
"user_id_number": player.GetUserId(),
|
||
"gesture": player.GetGesture(),
|
||
"result": player.GetResult(),
|
||
"balance_after": player.GetBalanceAfter(),
|
||
"joined_at_ms": player.GetJoinedAtMs(),
|
||
"user": user,
|
||
}
|
||
}
|
||
|
||
func (s *Service) displayUser(ctx context.Context, app string, userID int64) map[string]any {
|
||
user := map[string]any{
|
||
"user_id": strconv.FormatInt(userID, 10),
|
||
}
|
||
if s.user == nil || userID <= 0 {
|
||
return user
|
||
}
|
||
resp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
|
||
Meta: &userv1.RequestMeta{AppCode: appcode.Normalize(app), Caller: "game-service-room-rps", SentAtMs: s.now().UnixMilli()},
|
||
UserId: userID,
|
||
})
|
||
if err != nil || resp.GetUser() == nil {
|
||
// IM 资料补齐不能反向改挑战事实;失败时保留 user_id,HTTP 详情仍可由 gateway 再查一次资料。
|
||
return user
|
||
}
|
||
user["display_user_id"] = resp.GetUser().GetDisplayUserId()
|
||
user["nickname"] = resp.GetUser().GetUsername()
|
||
user["avatar"] = resp.GetUser().GetAvatar()
|
||
return user
|
||
}
|
||
|
||
func defaultConfig(app string, timeout time.Duration, reveal time.Duration, nowMS int64) *gamev1.RoomRPSConfig {
|
||
return &gamev1.RoomRPSConfig{
|
||
AppCode: app,
|
||
GameId: GameID,
|
||
Status: "active",
|
||
ChallengeTimeoutMs: int64(timeout / time.Millisecond),
|
||
RevealCountdownMs: int64(reveal / time.Millisecond),
|
||
StakeGifts: defaultGifts(),
|
||
CreatedAtMs: nowMS,
|
||
UpdatedAtMs: nowMS,
|
||
}
|
||
}
|
||
|
||
func defaultGifts() []*gamev1.RoomRPSStakeGift {
|
||
return []*gamev1.RoomRPSStakeGift{
|
||
{GiftId: 10001, GiftIdText: "10001", GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1},
|
||
{GiftId: 10002, GiftIdText: "10002", GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2},
|
||
{GiftId: 10003, GiftIdText: "10003", GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3},
|
||
{GiftId: 10004, GiftIdText: "10004", GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4},
|
||
}
|
||
}
|
||
|
||
func defaultGiftMap() map[string]*gamev1.RoomRPSStakeGift {
|
||
out := make(map[string]*gamev1.RoomRPSStakeGift, 4)
|
||
for _, gift := range defaultGifts() {
|
||
out[roomRPSStakeGiftID(gift)] = gift
|
||
}
|
||
return out
|
||
}
|
||
|
||
func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID string) (*gamev1.RoomRPSStakeGift, bool) {
|
||
giftID = strings.TrimSpace(giftID)
|
||
for _, gift := range config.GetStakeGifts() {
|
||
if roomRPSStakeGiftID(gift) == giftID {
|
||
return gift, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func roomRPSStakeGiftID(gift *gamev1.RoomRPSStakeGift) string {
|
||
if gift == nil {
|
||
return ""
|
||
}
|
||
if text := strings.TrimSpace(gift.GetGiftIdText()); text != "" {
|
||
return text
|
||
}
|
||
if gift.GetGiftId() > 0 {
|
||
return strconv.FormatInt(gift.GetGiftId(), 10)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func roomRPSRequestGiftID(req *gamev1.CreateRoomRPSChallengeRequest) string {
|
||
if req == nil {
|
||
return ""
|
||
}
|
||
if text := strings.TrimSpace(req.GetGiftIdText()); text != "" {
|
||
return text
|
||
}
|
||
if req.GetGiftId() > 0 {
|
||
return strconv.FormatInt(req.GetGiftId(), 10)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func roomRPSChallengeGiftID(challenge *gamev1.RoomRPSChallenge) string {
|
||
if challenge == nil {
|
||
return ""
|
||
}
|
||
if text := strings.TrimSpace(challenge.GetStakeGiftIdText()); text != "" {
|
||
return text
|
||
}
|
||
if challenge.GetStakeGiftId() > 0 {
|
||
return strconv.FormatInt(challenge.GetStakeGiftId(), 10)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func legacyRoomRPSGiftID(giftID string) int64 {
|
||
value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64)
|
||
if err != nil || value <= 0 {
|
||
return 0
|
||
}
|
||
return value
|
||
}
|
||
|
||
func applyResult(challenge *gamev1.RoomRPSChallenge) {
|
||
initiatorGesture := challenge.GetInitiator().GetGesture()
|
||
challengerGesture := challenge.GetChallenger().GetGesture()
|
||
switch {
|
||
case initiatorGesture == challengerGesture:
|
||
challenge.Initiator.Result = ResultDraw
|
||
challenge.Challenger.Result = ResultDraw
|
||
challenge.WinnerUserId = 0
|
||
challenge.SettlementStatus = SettlementRefunded
|
||
case beats(initiatorGesture, challengerGesture):
|
||
challenge.Initiator.Result = ResultWin
|
||
challenge.Challenger.Result = ResultLose
|
||
challenge.WinnerUserId = challenge.GetInitiator().GetUserId()
|
||
challenge.SettlementStatus = SettlementSettled
|
||
default:
|
||
challenge.Initiator.Result = ResultLose
|
||
challenge.Challenger.Result = ResultWin
|
||
challenge.WinnerUserId = challenge.GetChallenger().GetUserId()
|
||
challenge.SettlementStatus = SettlementSettled
|
||
}
|
||
}
|
||
|
||
func beats(left string, right string) bool {
|
||
return (left == GestureRock && right == GestureScissors) ||
|
||
(left == GesturePaper && right == GestureRock) ||
|
||
(left == GestureScissors && right == GesturePaper)
|
||
}
|
||
|
||
func normalizeGesture(value string) (string, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case GestureRock:
|
||
return GestureRock, true
|
||
case GesturePaper:
|
||
return GesturePaper, true
|
||
case GestureScissors:
|
||
return GestureScissors, true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func normalizeStatus(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "", "all":
|
||
return ""
|
||
case StatusPending, StatusMatched, StatusRevealing, StatusFinished, StatusTimeout, StatusSettlementFailed, StatusCancelled, "active", "disabled":
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func cloneConfig(config *gamev1.RoomRPSConfig) *gamev1.RoomRPSConfig {
|
||
if config == nil {
|
||
return nil
|
||
}
|
||
// protobuf message 内部带锁状态,不能用结构体赋值浅拷贝;proto.Clone 会复制业务字段并重建 message state。
|
||
cloned, ok := proto.Clone(config).(*gamev1.RoomRPSConfig)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return cloned
|
||
}
|
||
|
||
func cloneChallenge(challenge *gamev1.RoomRPSChallenge) *gamev1.RoomRPSChallenge {
|
||
if challenge == nil {
|
||
return nil
|
||
}
|
||
// 对局快照同样必须走 protobuf 深拷贝,避免 vet copylocks 和嵌套玩家资料被调用方意外共享。
|
||
cloned, ok := proto.Clone(challenge).(*gamev1.RoomRPSChallenge)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return cloned
|
||
}
|
||
|
||
func newChallengeID(app string) string {
|
||
var entropy [8]byte
|
||
if _, err := rand.Read(entropy[:]); err != nil {
|
||
return fmt.Sprintf("rps_%s_%d", appcode.Normalize(app), time.Now().UnixNano())
|
||
}
|
||
return fmt.Sprintf("rps_%s_%d_%s", appcode.Normalize(app), time.Now().UnixMilli(), hex.EncodeToString(entropy[:]))
|
||
}
|