Compare commits
3 Commits
87b3ebf167
...
c59317c6d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c59317c6d2 | ||
|
|
d9433647d3 | ||
|
|
2a488daeed |
@ -8368,6 +8368,8 @@ type ListRoomsRequest struct {
|
|||||||
Query string `protobuf:"bytes,7,opt,name=query,proto3" json:"query,omitempty"`
|
Query string `protobuf:"bytes,7,opt,name=query,proto3" json:"query,omitempty"`
|
||||||
// country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。
|
// country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。
|
||||||
CountryCode string `protobuf:"bytes,8,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
|
CountryCode string `protobuf:"bytes,8,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
|
||||||
|
// viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。
|
||||||
|
ViewerCountryCode string `protobuf:"bytes,9,opt,name=viewer_country_code,json=viewerCountryCode,proto3" json:"viewer_country_code,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -8458,6 +8460,13 @@ func (x *ListRoomsRequest) GetCountryCode() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ListRoomsRequest) GetViewerCountryCode() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ViewerCountryCode
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。
|
// ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。
|
||||||
// 它和公共房间发现列表分离,避免把用户关系流误建模成房间全集过滤。
|
// 它和公共房间发现列表分离,避免把用户关系流误建模成房间全集过滤。
|
||||||
type ListRoomFeedsRequest struct {
|
type ListRoomFeedsRequest struct {
|
||||||
@ -10948,7 +10957,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"\x1aVerifyRoomPresenceResponse\x12\x18\n" +
|
"\x1aVerifyRoomPresenceResponse\x12\x18\n" +
|
||||||
"\apresent\x18\x01 \x01(\bR\apresent\x12\x16\n" +
|
"\apresent\x18\x01 \x01(\bR\apresent\x12\x16\n" +
|
||||||
"\x06reason\x18\x02 \x01(\tR\x06reason\x12!\n" +
|
"\x06reason\x18\x02 \x01(\tR\x06reason\x12!\n" +
|
||||||
"\froom_version\x18\x03 \x01(\x03R\vroomVersion\"\x8d\x02\n" +
|
"\froom_version\x18\x03 \x01(\x03R\vroomVersion\"\xbd\x02\n" +
|
||||||
"\x10ListRoomsRequest\x12.\n" +
|
"\x10ListRoomsRequest\x12.\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
||||||
"\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" +
|
"\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" +
|
||||||
@ -10957,7 +10966,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"\x06cursor\x18\x05 \x01(\tR\x06cursor\x12\x14\n" +
|
"\x06cursor\x18\x05 \x01(\tR\x06cursor\x12\x14\n" +
|
||||||
"\x05limit\x18\x06 \x01(\x05R\x05limit\x12\x14\n" +
|
"\x05limit\x18\x06 \x01(\x05R\x05limit\x12\x14\n" +
|
||||||
"\x05query\x18\a \x01(\tR\x05query\x12!\n" +
|
"\x05query\x18\a \x01(\tR\x05query\x12!\n" +
|
||||||
"\fcountry_code\x18\b \x01(\tR\vcountryCode\"\xb7\x02\n" +
|
"\fcountry_code\x18\b \x01(\tR\vcountryCode\x12.\n" +
|
||||||
|
"\x13viewer_country_code\x18\t \x01(\tR\x11viewerCountryCode\"\xb7\x02\n" +
|
||||||
"\x14ListRoomFeedsRequest\x12.\n" +
|
"\x14ListRoomFeedsRequest\x12.\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
||||||
"\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" +
|
"\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" +
|
||||||
|
|||||||
@ -1003,6 +1003,8 @@ message ListRoomsRequest {
|
|||||||
string query = 7;
|
string query = 7;
|
||||||
// country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。
|
// country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。
|
||||||
string country_code = 8;
|
string country_code = 8;
|
||||||
|
// viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。
|
||||||
|
string viewer_country_code = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。
|
// ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const (
|
|||||||
roomPinStatusAll = "all"
|
roomPinStatusAll = "all"
|
||||||
roomPinTypeRegion = "region"
|
roomPinTypeRegion = "region"
|
||||||
roomPinTypeGlobal = "global"
|
roomPinTypeGlobal = "global"
|
||||||
|
roomPinTypeCountry = "country"
|
||||||
defaultRoomPinDays = int64(30)
|
defaultRoomPinDays = int64(30)
|
||||||
maxRoomPinDays = int64(3650)
|
maxRoomPinDays = int64(3650)
|
||||||
roomPinDayMillis = int64(24 * 60 * 60 * 1000)
|
roomPinDayMillis = int64(24 * 60 * 60 * 1000)
|
||||||
@ -142,6 +143,8 @@ func normalizeRoomPinType(pinType string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(pinType)) {
|
switch strings.ToLower(strings.TrimSpace(pinType)) {
|
||||||
case roomPinTypeGlobal:
|
case roomPinTypeGlobal:
|
||||||
return roomPinTypeGlobal
|
return roomPinTypeGlobal
|
||||||
|
case roomPinTypeCountry:
|
||||||
|
return roomPinTypeCountry
|
||||||
case roomPinTypeRegion:
|
case roomPinTypeRegion:
|
||||||
return roomPinTypeRegion
|
return roomPinTypeRegion
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package roomrps
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -50,6 +51,10 @@ const (
|
|||||||
eventRevealCountdown = "room_rps_reveal_countdown"
|
eventRevealCountdown = "room_rps_reveal_countdown"
|
||||||
eventFinished = "room_rps_finished"
|
eventFinished = "room_rps_finished"
|
||||||
eventExpired = "room_rps_expired"
|
eventExpired = "room_rps_expired"
|
||||||
|
|
||||||
|
roomRPSWalletPlatform = "room_rps"
|
||||||
|
roomRPSOpDebit = "debit"
|
||||||
|
roomRPSOpRefund = "refund"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。
|
// UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。
|
||||||
@ -62,6 +67,11 @@ type GiftCatalogClient interface {
|
|||||||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
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 仓储层。
|
// ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。
|
||||||
type ConfigStore interface {
|
type ConfigStore interface {
|
||||||
GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error)
|
GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error)
|
||||||
@ -83,6 +93,7 @@ type Service struct {
|
|||||||
config Config
|
config Config
|
||||||
user UserClient
|
user UserClient
|
||||||
gifts GiftCatalogClient
|
gifts GiftCatalogClient
|
||||||
|
wallet WalletClient
|
||||||
pub Publisher
|
pub Publisher
|
||||||
store ConfigStore
|
store ConfigStore
|
||||||
|
|
||||||
@ -104,13 +115,18 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se
|
|||||||
config.RevealCountdown = 3 * time.Second
|
config.RevealCountdown = 3 * time.Second
|
||||||
}
|
}
|
||||||
var gifts GiftCatalogClient
|
var gifts GiftCatalogClient
|
||||||
|
var wallet WalletClient
|
||||||
var store ConfigStore
|
var store ConfigStore
|
||||||
for _, extra := range extras {
|
for _, extra := range extras {
|
||||||
switch typed := extra.(type) {
|
if typed, ok := extra.(GiftCatalogClient); ok {
|
||||||
case GiftCatalogClient:
|
|
||||||
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
||||||
gifts = typed
|
gifts = typed
|
||||||
case ConfigStore:
|
}
|
||||||
|
if typed, ok := extra.(WalletClient); ok {
|
||||||
|
// 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。
|
||||||
|
wallet = typed
|
||||||
|
}
|
||||||
|
if typed, ok := extra.(ConfigStore); ok {
|
||||||
// MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。
|
// MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。
|
||||||
store = typed
|
store = typed
|
||||||
}
|
}
|
||||||
@ -119,6 +135,7 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se
|
|||||||
config: config,
|
config: config,
|
||||||
user: user,
|
user: user,
|
||||||
gifts: gifts,
|
gifts: gifts,
|
||||||
|
wallet: wallet,
|
||||||
pub: publisher,
|
pub: publisher,
|
||||||
store: store,
|
store: store,
|
||||||
configs: make(map[string]*gamev1.RoomRPSConfig),
|
configs: make(map[string]*gamev1.RoomRPSConfig),
|
||||||
@ -301,20 +318,29 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS
|
|||||||
if !ok || !gift.GetEnabled() {
|
if !ok || !gift.GetEnabled() {
|
||||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
|
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()
|
s.mu.Lock()
|
||||||
now := s.now()
|
now := s.now()
|
||||||
nowMS := now.UnixMilli()
|
nowMS := now.UnixMilli()
|
||||||
challenge := &gamev1.RoomRPSChallenge{
|
challenge := &gamev1.RoomRPSChallenge{
|
||||||
AppCode: app,
|
AppCode: app,
|
||||||
ChallengeId: newChallengeID(app),
|
ChallengeId: challengeID,
|
||||||
RoomId: roomID,
|
RoomId: roomID,
|
||||||
RegionId: req.GetRegionId(),
|
RegionId: req.GetRegionId(),
|
||||||
Status: StatusPending,
|
Status: StatusPending,
|
||||||
StakeGiftId: legacyRoomRPSGiftID(giftID),
|
StakeGiftId: legacyRoomRPSGiftID(giftID),
|
||||||
StakeGiftIdText: giftID,
|
StakeGiftIdText: giftID,
|
||||||
StakeCoin: gift.GetGiftPriceCoin(),
|
StakeCoin: stakeCoin,
|
||||||
Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS},
|
Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS},
|
||||||
SettlementStatus: SettlementPending,
|
SettlementStatus: SettlementPending,
|
||||||
TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(),
|
TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(),
|
||||||
CreatedAtMs: nowMS,
|
CreatedAtMs: nowMS,
|
||||||
@ -362,6 +388,13 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS
|
|||||||
challenge.UpdatedAtMs = nowMS
|
challenge.UpdatedAtMs = nowMS
|
||||||
cloned := cloneChallenge(challenge)
|
cloned := cloneChallenge(challenge)
|
||||||
s.mu.Unlock()
|
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)
|
_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
|
||||||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout")
|
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout")
|
||||||
}
|
}
|
||||||
@ -370,9 +403,57 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge")
|
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.Status = StatusFinished
|
||||||
challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS}
|
challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS}
|
||||||
challenge.MatchedAtMs = nowMS
|
challenge.MatchedAtMs = nowMS
|
||||||
challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli()
|
challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli()
|
||||||
challenge.SettledAtMs = nowMS
|
challenge.SettledAtMs = nowMS
|
||||||
@ -381,6 +462,32 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS
|
|||||||
cloned := cloneChallenge(challenge)
|
cloned := cloneChallenge(challenge)
|
||||||
s.mu.Unlock()
|
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 {
|
if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -480,6 +587,14 @@ func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID s
|
|||||||
cloned := cloneChallenge(challenge)
|
cloned := cloneChallenge(challenge)
|
||||||
s.mu.Unlock()
|
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 {
|
if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -745,6 +860,143 @@ func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []
|
|||||||
return result, nil
|
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 {
|
func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string {
|
||||||
if gift == nil {
|
if gift == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package roomrps
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -129,7 +130,8 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) {
|
|||||||
"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1),
|
"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1),
|
||||||
"lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111),
|
"lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111),
|
||||||
})
|
})
|
||||||
svc := New(Config{}, nil, nil, catalog)
|
wallet := newFakeRoomRPSWallet(map[int64]int64{42: 10})
|
||||||
|
svc := New(Config{}, nil, nil, catalog, wallet)
|
||||||
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
|
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
|
||||||
@ -161,6 +163,53 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) {
|
|||||||
if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 {
|
if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 {
|
||||||
t.Fatalf("string gift challenge mismatch: %+v", challenge)
|
t.Fatalf("string gift challenge mismatch: %+v", challenge)
|
||||||
}
|
}
|
||||||
|
if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 {
|
||||||
|
t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) {
|
||||||
|
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
||||||
|
"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 100),
|
||||||
|
"Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 200),
|
||||||
|
"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 300),
|
||||||
|
"lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 400),
|
||||||
|
})
|
||||||
|
wallet := newFakeRoomRPSWallet(map[int64]int64{42: 99})
|
||||||
|
svc := New(Config{}, nil, nil, catalog, wallet)
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
|
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
|
||||||
|
Status: "active",
|
||||||
|
ChallengeTimeoutMs: 600000,
|
||||||
|
RevealCountdownMs: 3000,
|
||||||
|
StakeGifts: []*gamev1.RoomRPSStakeGift{
|
||||||
|
{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
|
||||||
|
{GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2},
|
||||||
|
{GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3},
|
||||||
|
{GiftIdText: "lw1", Enabled: true, SortOrder: 4},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-low-balance"},
|
||||||
|
UserId: 42,
|
||||||
|
RoomId: "room-1",
|
||||||
|
GiftIdText: "Gifi-3",
|
||||||
|
Gesture: "rock",
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
||||||
|
t.Fatalf("CreateChallenge() error = %v, want InsufficientBalance", err)
|
||||||
|
}
|
||||||
|
if len(svc.challenges) != 0 {
|
||||||
|
t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges)
|
||||||
|
}
|
||||||
|
if wallet.balances[42] != 99 || len(wallet.applies) != 1 {
|
||||||
|
t.Fatalf("insufficient create must not mutate wallet balance: balance=%d applies=%d", wallet.balances[42], len(wallet.applies))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) {
|
func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) {
|
||||||
@ -214,7 +263,8 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) {
|
|||||||
CreatedAtMs: 1000,
|
CreatedAtMs: 1000,
|
||||||
UpdatedAtMs: 2000,
|
UpdatedAtMs: 2000,
|
||||||
}
|
}
|
||||||
svc := New(Config{}, nil, nil, store)
|
wallet := newFakeRoomRPSWallet(map[int64]int64{42: 1000})
|
||||||
|
svc := New(Config{}, nil, nil, store, wallet)
|
||||||
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||||
@ -230,6 +280,155 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) {
|
|||||||
if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 {
|
if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 {
|
||||||
t.Fatalf("challenge did not use persistent config: %+v", challenge)
|
t.Fatalf("challenge did not use persistent config: %+v", challenge)
|
||||||
}
|
}
|
||||||
|
if challenge.GetInitiator().GetBalanceAfter() != 778 || wallet.balances[42] != 778 {
|
||||||
|
t.Fatalf("persistent config challenge did not debit stake: challenge=%+v wallet=%d", challenge, wallet.balances[42])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) {
|
||||||
|
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 99})
|
||||||
|
svc := New(Config{}, nil, nil, wallet)
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
|
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
|
||||||
|
UserId: 41,
|
||||||
|
RoomId: "room-1",
|
||||||
|
GiftId: 10001,
|
||||||
|
Gesture: "rock",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-low-balance"},
|
||||||
|
UserId: 42,
|
||||||
|
ChallengeId: created.GetChallengeId(),
|
||||||
|
Gesture: "paper",
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
||||||
|
t.Fatalf("AcceptChallenge() error = %v, want InsufficientBalance", err)
|
||||||
|
}
|
||||||
|
current, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 {
|
||||||
|
t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current)
|
||||||
|
}
|
||||||
|
if wallet.balances[41] != 900 || wallet.balances[42] != 99 {
|
||||||
|
t.Fatalf("accept failure should keep initiator debit and avoid challenger debit: balances=%+v", wallet.balances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) {
|
||||||
|
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
|
||||||
|
svc := New(Config{}, nil, nil, wallet)
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
|
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
|
||||||
|
UserId: 41,
|
||||||
|
RoomId: "room-1",
|
||||||
|
GiftId: 10001,
|
||||||
|
Gesture: "rock",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept"},
|
||||||
|
UserId: 42,
|
||||||
|
ChallengeId: created.GetChallengeId(),
|
||||||
|
Gesture: "paper",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementSettled || accepted.GetInitiator().GetBalanceAfter() != 900 || accepted.GetChallenger().GetBalanceAfter() != 500 {
|
||||||
|
t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted)
|
||||||
|
}
|
||||||
|
if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
|
||||||
|
t.Fatalf("winner refund and loser debit mismatch: balances=%+v", wallet.balances)
|
||||||
|
}
|
||||||
|
if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund]" {
|
||||||
|
t.Fatalf("winner settlement should only debit both and refund winner, got ops=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) {
|
||||||
|
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
|
||||||
|
svc := New(Config{}, nil, nil, wallet)
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
|
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-draw"},
|
||||||
|
UserId: 41,
|
||||||
|
RoomId: "room-1",
|
||||||
|
GiftId: 10001,
|
||||||
|
Gesture: "rock",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-draw"},
|
||||||
|
UserId: 42,
|
||||||
|
ChallengeId: created.GetChallengeId(),
|
||||||
|
Gesture: "rock",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementRefunded || accepted.GetWinnerUserId() != 0 {
|
||||||
|
t.Fatalf("draw challenge settlement mismatch: %+v", accepted)
|
||||||
|
}
|
||||||
|
if accepted.GetInitiator().GetBalanceAfter() != 1000 || accepted.GetChallenger().GetBalanceAfter() != 500 {
|
||||||
|
t.Fatalf("draw must refund both players in response: %+v", accepted)
|
||||||
|
}
|
||||||
|
if wallet.balances[41] != 1000 || wallet.balances[42] != 500 {
|
||||||
|
t.Fatalf("draw must refund both players in wallet: balances=%+v", wallet.balances)
|
||||||
|
}
|
||||||
|
if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund refund]" {
|
||||||
|
t.Fatalf("draw should only debit both and refund both without gift op, got ops=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireChallengeRefundsInitiatorDebit(t *testing.T) {
|
||||||
|
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000})
|
||||||
|
svc := New(Config{}, nil, nil, wallet)
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||||
|
|
||||||
|
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expire"},
|
||||||
|
UserId: 41,
|
||||||
|
RoomId: "room-1",
|
||||||
|
GiftId: 10001,
|
||||||
|
Gesture: "rock",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
if wallet.balances[41] != 900 || created.GetInitiator().GetBalanceAfter() != 900 {
|
||||||
|
t.Fatalf("create should debit initiator before pending challenge: challenge=%+v wallet=%d", created, wallet.balances[41])
|
||||||
|
}
|
||||||
|
|
||||||
|
expired, _, err := svc.ExpireChallenge(context.Background(), "lalu", created.GetChallengeId())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExpireChallenge() error = %v", err)
|
||||||
|
}
|
||||||
|
if expired.GetStatus() != StatusTimeout || expired.GetSettlementStatus() != SettlementRefunded || expired.GetInitiator().GetBalanceAfter() != 1000 {
|
||||||
|
t.Fatalf("expired challenge should refund initiator: %+v", expired)
|
||||||
|
}
|
||||||
|
if wallet.balances[41] != 1000 {
|
||||||
|
t.Fatalf("expired challenge wallet balance mismatch: %d", wallet.balances[41])
|
||||||
|
}
|
||||||
|
if got := wallet.ops(); fmt.Sprint(got) != "[debit refund]" {
|
||||||
|
t.Fatalf("expire should only debit then refund initiator, got ops=%v", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeGiftCatalog struct {
|
type fakeGiftCatalog struct {
|
||||||
@ -249,6 +448,58 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG
|
|||||||
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil
|
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeRoomRPSWallet struct {
|
||||||
|
balances map[int64]int64
|
||||||
|
requests []*walletv1.GetBalancesRequest
|
||||||
|
applies []*walletv1.ApplyGameCoinChangeRequest
|
||||||
|
replays map[string]*walletv1.ApplyGameCoinChangeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet {
|
||||||
|
return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||||
|
f.requests = append(f.requests, req)
|
||||||
|
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{
|
||||||
|
AppCode: req.GetAppCode(),
|
||||||
|
AssetType: "COIN",
|
||||||
|
AvailableAmount: f.balances[req.GetUserId()],
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRoomRPSWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
|
||||||
|
f.applies = append(f.applies, req)
|
||||||
|
if replay := f.replays[req.GetCommandId()]; replay != nil {
|
||||||
|
return replay, nil
|
||||||
|
}
|
||||||
|
switch req.GetOpType() {
|
||||||
|
case roomRPSOpDebit:
|
||||||
|
if f.balances[req.GetUserId()] < req.GetCoinAmount() {
|
||||||
|
return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||||
|
}
|
||||||
|
f.balances[req.GetUserId()] -= req.GetCoinAmount()
|
||||||
|
case roomRPSOpRefund:
|
||||||
|
f.balances[req.GetUserId()] += req.GetCoinAmount()
|
||||||
|
default:
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "unsupported op_type")
|
||||||
|
}
|
||||||
|
resp := &walletv1.ApplyGameCoinChangeResponse{
|
||||||
|
WalletTransactionId: "wtx-" + req.GetCommandId(),
|
||||||
|
BalanceAfter: f.balances[req.GetUserId()],
|
||||||
|
}
|
||||||
|
f.replays[req.GetCommandId()] = resp
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRoomRPSWallet) ops() []string {
|
||||||
|
out := make([]string, 0, len(f.applies))
|
||||||
|
for _, req := range f.applies {
|
||||||
|
out = append(out, req.GetOpType())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig {
|
func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig {
|
||||||
return &walletv1.GiftConfig{
|
return &walletv1.GiftConfig{
|
||||||
AppCode: "lalu",
|
AppCode: "lalu",
|
||||||
|
|||||||
@ -2641,7 +2641,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
|||||||
if queryClient.lastList == nil {
|
if queryClient.lastList == nil {
|
||||||
t.Fatal("room query client was not called")
|
t.Fatal("room query client was not called")
|
||||||
}
|
}
|
||||||
if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 {
|
if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 || queryClient.lastList.GetViewerCountryCode() != "US" {
|
||||||
t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList)
|
t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList)
|
||||||
}
|
}
|
||||||
if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" || queryClient.lastList.GetCountryCode() != "US" {
|
if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" || queryClient.lastList.GetCountryCode() != "US" {
|
||||||
|
|||||||
@ -111,6 +111,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
|||||||
Meta: httpkit.RoomMeta(request, "", ""),
|
Meta: httpkit.RoomMeta(request, "", ""),
|
||||||
ViewerUserId: viewerUserID,
|
ViewerUserId: viewerUserID,
|
||||||
VisibleRegionId: userResp.GetUser().GetRegionId(),
|
VisibleRegionId: userResp.GetUser().GetRegionId(),
|
||||||
|
ViewerCountryCode: userResp.GetUser().GetCountry(),
|
||||||
Tab: tab,
|
Tab: tab,
|
||||||
Cursor: request.URL.Query().Get("cursor"),
|
Cursor: request.URL.Query().Get("cursor"),
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
|
|||||||
@ -183,7 +183,7 @@ CREATE TABLE IF NOT EXISTS room_region_pins (
|
|||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
visible_region_id BIGINT NOT NULL COMMENT '可见区域 ID',
|
visible_region_id BIGINT NOT NULL COMMENT '可见区域 ID',
|
||||||
pin_type VARCHAR(32) NOT NULL DEFAULT 'region' COMMENT '置顶类型:region 区域置顶,global 全区置顶',
|
pin_type VARCHAR(32) NOT NULL DEFAULT 'region' COMMENT '置顶类型:region 区域置顶,country 国家置顶,global 历史全区置顶',
|
||||||
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
||||||
weight BIGINT NOT NULL DEFAULT 0 COMMENT '权重',
|
weight BIGINT NOT NULL DEFAULT 0 COMMENT '权重',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||||
|
|||||||
@ -15,6 +15,7 @@ var adminSeatCandidates = []int32{10, 15, 20, 25, 30}
|
|||||||
const (
|
const (
|
||||||
roomPinTypeRegion = "region"
|
roomPinTypeRegion = "region"
|
||||||
roomPinTypeGlobal = "global"
|
roomPinTypeGlobal = "global"
|
||||||
|
roomPinTypeCountry = "country"
|
||||||
roomPinDayMillis = int64(24 * 60 * 60 * 1000)
|
roomPinDayMillis = int64(24 * 60 * 60 * 1000)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -156,6 +157,8 @@ func normalizeRoomPinType(pinType string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(pinType)) {
|
switch strings.ToLower(strings.TrimSpace(pinType)) {
|
||||||
case roomPinTypeGlobal:
|
case roomPinTypeGlobal:
|
||||||
return roomPinTypeGlobal
|
return roomPinTypeGlobal
|
||||||
|
case roomPinTypeCountry:
|
||||||
|
return roomPinTypeCountry
|
||||||
default:
|
default:
|
||||||
// 空值和未知值都回落到区域置顶,兼容旧后台只传 room_id/duration_days 的请求。
|
// 空值和未知值都回落到区域置顶,兼容旧后台只传 room_id/duration_days 的请求。
|
||||||
return roomPinTypeRegion
|
return roomPinTypeRegion
|
||||||
|
|||||||
@ -37,6 +37,7 @@ type roomListCursor struct {
|
|||||||
Tab string `json:"tab"`
|
Tab string `json:"tab"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
CountryCode string `json:"country_code,omitempty"`
|
CountryCode string `json:"country_code,omitempty"`
|
||||||
|
ViewerCountry string `json:"viewer_country,omitempty"`
|
||||||
SortScore int64 `json:"sort_score,omitempty"`
|
SortScore int64 `json:"sort_score,omitempty"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
|
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
|
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
|
||||||
@ -68,7 +69,8 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
countryCode := normalizeRoomCountryCode(req.GetCountryCode())
|
countryCode := normalizeRoomCountryCode(req.GetCountryCode())
|
||||||
cursor, err := decodeRoomListCursor(tab, query, countryCode, req.GetCursor())
|
viewerCountryCode := normalizeRoomCountryCode(req.GetViewerCountryCode())
|
||||||
|
cursor, err := decodeRoomListCursor(tab, query, countryCode, viewerCountryCode, req.GetCursor())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -79,6 +81,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
|
|||||||
Tab: tab,
|
Tab: tab,
|
||||||
Query: query,
|
Query: query,
|
||||||
CountryCode: countryCode,
|
CountryCode: countryCode,
|
||||||
|
ViewerCountryCode: viewerCountryCode,
|
||||||
Limit: limit + 1,
|
Limit: limit + 1,
|
||||||
CursorSortScore: cursor.SortScore,
|
CursorSortScore: cursor.SortScore,
|
||||||
CursorCreatedAtMS: cursor.CreatedAtMS,
|
CursorCreatedAtMS: cursor.CreatedAtMS,
|
||||||
@ -93,7 +96,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return roomListResponseFromEntries(tab, query, countryCode, entries, limit), nil
|
return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, entries, limit), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
|
// ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
|
||||||
@ -115,7 +118,7 @@ func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cursor, err := decodeRoomListCursor(tab, query, "", req.GetCursor())
|
cursor, err := decodeRoomListCursor(tab, query, "", "", req.GetCursor())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -163,7 +166,7 @@ func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRe
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return roomListResponseFromEntries(tab, query, "", entries, limit), nil
|
return roomListResponseFromEntries(tab, query, "", "", entries, limit), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。
|
// GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。
|
||||||
@ -196,12 +199,12 @@ func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func roomListResponseFromEntries(tab string, query string, countryCode string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse {
|
func roomListResponseFromEntries(tab string, query string, countryCode string, viewerCountryCode string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse {
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
if len(entries) > limit {
|
if len(entries) > limit {
|
||||||
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
|
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
|
||||||
last := entries[limit-1]
|
last := entries[limit-1]
|
||||||
nextCursor = encodeRoomListCursor(tab, query, countryCode, last)
|
nextCursor = encodeRoomListCursor(tab, query, countryCode, viewerCountryCode, last)
|
||||||
entries = entries[:limit]
|
entries = entries[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,10 +409,10 @@ func normalizeRoomCountryCode(countryCode string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。
|
// decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。
|
||||||
func decodeRoomListCursor(tab string, query string, countryCode string, encoded string) (roomListCursor, error) {
|
func decodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, encoded string) (roomListCursor, error) {
|
||||||
encoded = strings.TrimSpace(encoded)
|
encoded = strings.TrimSpace(encoded)
|
||||||
if encoded == "" {
|
if encoded == "" {
|
||||||
return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode}, nil
|
return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode, ViewerCountry: viewerCountryCode}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
@ -421,7 +424,7 @@ func decodeRoomListCursor(tab string, query string, countryCode string, encoded
|
|||||||
if err := json.Unmarshal(payload, &cursor); err != nil {
|
if err := json.Unmarshal(payload, &cursor); err != nil {
|
||||||
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
||||||
}
|
}
|
||||||
if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.RoomID == "" {
|
if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.ViewerCountry != viewerCountryCode || cursor.RoomID == "" {
|
||||||
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -430,11 +433,12 @@ func decodeRoomListCursor(tab string, query string, countryCode string, encoded
|
|||||||
|
|
||||||
// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
|
// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
|
||||||
// cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。
|
// cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。
|
||||||
func encodeRoomListCursor(tab string, query string, countryCode string, entry RoomListEntry) string {
|
func encodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, entry RoomListEntry) string {
|
||||||
cursor := roomListCursor{
|
cursor := roomListCursor{
|
||||||
Tab: tab,
|
Tab: tab,
|
||||||
Query: query,
|
Query: query,
|
||||||
CountryCode: countryCode,
|
CountryCode: countryCode,
|
||||||
|
ViewerCountry: viewerCountryCode,
|
||||||
SortScore: entry.SortScore,
|
SortScore: entry.SortScore,
|
||||||
CreatedAtMS: entry.CreatedAtMS,
|
CreatedAtMS: entry.CreatedAtMS,
|
||||||
UpdatedAtMS: entry.UpdatedAtMS,
|
UpdatedAtMS: entry.UpdatedAtMS,
|
||||||
|
|||||||
@ -64,7 +64,7 @@ func TestRegionalPinOrdersPublicRoomList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot(t *testing.T) {
|
func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForHot(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
svc := roomservice.New(roomservice.Config{
|
svc := roomservice.New(roomservice.Config{
|
||||||
@ -74,42 +74,46 @@ func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot(t *testing.T) {
|
|||||||
SnapshotEveryN: 1,
|
SnapshotEveryN: 1,
|
||||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||||
|
|
||||||
createPinnedListRoom(t, ctx, svc, "room-global-pin", "global-pin", 3101, 9002)
|
createPinnedListRoom(t, ctx, svc, "room-region-pin", "region-pin", 3101, 9001, "AE")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-region-pin", "region-pin", 3102, 9001)
|
createPinnedListRoom(t, ctx, svc, "room-country-pin", "country-pin", 3102, 9001, "US")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-local-online", "local-online", 3103, 9001)
|
createPinnedListRoom(t, ctx, svc, "room-viewer-country-online", "viewer-online", 3103, 9001, "US")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-other-online", "other-online", 3104, 9002)
|
createPinnedListRoom(t, ctx, svc, "room-other-country-online", "other-online", 3104, 9001, "AE")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-local-empty", "local-empty", 3105, 9001)
|
createPinnedListRoom(t, ctx, svc, "room-viewer-country-empty", "viewer-empty", 3105, 9001, "US")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-other-empty", "other-empty", 3106, 9002)
|
createPinnedListRoom(t, ctx, svc, "room-other-country-empty", "other-empty", 3106, 9001, "AE")
|
||||||
repository.SetRoomSortScore("room-global-pin", 1)
|
createPinnedListRoom(t, ctx, svc, "room-other-region-online", "other-region", 3107, 9002, "US")
|
||||||
repository.SetRoomSortScore("room-region-pin", 2)
|
repository.SetRoomSortScore("room-region-pin", 1)
|
||||||
repository.SetRoomSortScore("room-local-online", 100)
|
repository.SetRoomSortScore("room-country-pin", 2)
|
||||||
repository.SetRoomSortScore("room-other-online", 10000)
|
repository.SetRoomSortScore("room-viewer-country-online", 100)
|
||||||
repository.SetRoomSortScore("room-local-empty", 100000)
|
repository.SetRoomSortScore("room-other-country-online", 10000)
|
||||||
repository.SetRoomSortScore("room-other-empty", 200000)
|
repository.SetRoomSortScore("room-viewer-country-empty", 100000)
|
||||||
repository.SetRoomPresence("room-local-online", 1, 0)
|
repository.SetRoomSortScore("room-other-country-empty", 200000)
|
||||||
repository.SetRoomPresence("room-other-online", 1, 0)
|
repository.SetRoomSortScore("room-other-region-online", 300000)
|
||||||
repository.SetRoomPresence("room-local-empty", 0, 0)
|
repository.SetRoomPresence("room-viewer-country-online", 1, 0)
|
||||||
repository.SetRoomPresence("room-other-empty", 0, 0)
|
repository.SetRoomPresence("room-other-country-online", 1, 0)
|
||||||
|
repository.SetRoomPresence("room-viewer-country-empty", 0, 0)
|
||||||
|
repository.SetRoomPresence("room-other-country-empty", 0, 0)
|
||||||
|
repository.SetRoomPresence("room-other-region-online", 1, 0)
|
||||||
expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli()
|
expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli()
|
||||||
repository.PinRoomWithType("room-global-pin", "global", 0, 1, expiresAtMS)
|
|
||||||
repository.PinRoom("room-region-pin", 9001, 99, expiresAtMS)
|
repository.PinRoom("room-region-pin", 9001, 99, expiresAtMS)
|
||||||
|
repository.PinRoomWithType("room-country-pin", "country", 9001, 88, expiresAtMS)
|
||||||
|
|
||||||
page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||||
ViewerUserId: 4001,
|
ViewerUserId: 4001,
|
||||||
VisibleRegionId: 9001,
|
VisibleRegionId: 9001,
|
||||||
|
ViewerCountryCode: "US",
|
||||||
Tab: "hot",
|
Tab: "hot",
|
||||||
Limit: 6,
|
Limit: 6,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list hot rooms failed: %v", err)
|
t.Fatalf("list hot rooms failed: %v", err)
|
||||||
}
|
}
|
||||||
if got := roomIDs(page.GetRooms()); len(got) != 6 || got[0] != "room-global-pin" || got[1] != "room-region-pin" || got[2] != "room-local-online" || got[3] != "room-other-online" || got[4] != "room-local-empty" || got[5] != "room-other-empty" {
|
if got := roomIDs(page.GetRooms()); len(got) != 6 || got[0] != "room-region-pin" || got[1] != "room-country-pin" || got[2] != "room-viewer-country-online" || got[3] != "room-other-country-online" || got[4] != "room-viewer-country-empty" || got[5] != "room-other-country-empty" {
|
||||||
t.Fatalf("hot order mismatch: %+v", got)
|
t.Fatalf("hot order mismatch: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew(t *testing.T) {
|
func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForNew(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
svc := roomservice.New(roomservice.Config{
|
svc := roomservice.New(roomservice.Config{
|
||||||
@ -119,31 +123,34 @@ func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew(t *testing.T) {
|
|||||||
SnapshotEveryN: 1,
|
SnapshotEveryN: 1,
|
||||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||||
|
|
||||||
createPinnedListRoom(t, ctx, svc, "room-global-new", "global-new", 3201, 9002)
|
createPinnedListRoom(t, ctx, svc, "room-region-new", "region-new", 3201, 9001, "AE")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-region-new", "region-new", 3202, 9001)
|
createPinnedListRoom(t, ctx, svc, "room-country-new", "country-new", 3202, 9001, "US")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-local-newer", "local-newer", 3203, 9001)
|
createPinnedListRoom(t, ctx, svc, "room-viewer-newer", "viewer-newer", 3203, 9001, "US")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-local-older", "local-older", 3204, 9001)
|
createPinnedListRoom(t, ctx, svc, "room-viewer-older", "viewer-older", 3204, 9001, "US")
|
||||||
createPinnedListRoom(t, ctx, svc, "room-other-newest", "other-newest", 3205, 9002)
|
createPinnedListRoom(t, ctx, svc, "room-other-newest", "other-newest", 3205, 9001, "AE")
|
||||||
repository.SetRoomCreatedAt("room-global-new", 10)
|
createPinnedListRoom(t, ctx, svc, "room-other-region-newest", "other-region-newest", 3206, 9002, "US")
|
||||||
repository.SetRoomCreatedAt("room-region-new", 20)
|
repository.SetRoomCreatedAt("room-region-new", 20)
|
||||||
repository.SetRoomCreatedAt("room-local-newer", 300)
|
repository.SetRoomCreatedAt("room-country-new", 10)
|
||||||
repository.SetRoomCreatedAt("room-local-older", 100)
|
repository.SetRoomCreatedAt("room-viewer-newer", 300)
|
||||||
|
repository.SetRoomCreatedAt("room-viewer-older", 100)
|
||||||
repository.SetRoomCreatedAt("room-other-newest", 1000)
|
repository.SetRoomCreatedAt("room-other-newest", 1000)
|
||||||
|
repository.SetRoomCreatedAt("room-other-region-newest", 2000)
|
||||||
expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli()
|
expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli()
|
||||||
repository.PinRoomWithType("room-global-new", "global", 0, 1, expiresAtMS)
|
|
||||||
repository.PinRoom("room-region-new", 9001, 99, expiresAtMS)
|
repository.PinRoom("room-region-new", 9001, 99, expiresAtMS)
|
||||||
|
repository.PinRoomWithType("room-country-new", "country", 9001, 88, expiresAtMS)
|
||||||
|
|
||||||
page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||||
ViewerUserId: 4001,
|
ViewerUserId: 4001,
|
||||||
VisibleRegionId: 9001,
|
VisibleRegionId: 9001,
|
||||||
|
ViewerCountryCode: "US",
|
||||||
Tab: "new",
|
Tab: "new",
|
||||||
Limit: 5,
|
Limit: 5,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list new rooms failed: %v", err)
|
t.Fatalf("list new rooms failed: %v", err)
|
||||||
}
|
}
|
||||||
if got := roomIDs(page.GetRooms()); len(got) != 5 || got[0] != "room-global-new" || got[1] != "room-region-new" || got[2] != "room-local-newer" || got[3] != "room-local-older" || got[4] != "room-other-newest" {
|
if got := roomIDs(page.GetRooms()); len(got) != 5 || got[0] != "room-region-new" || got[1] != "room-country-new" || got[2] != "room-viewer-newer" || got[3] != "room-viewer-older" || got[4] != "room-other-newest" {
|
||||||
t.Fatalf("new order mismatch: %+v", got)
|
t.Fatalf("new order mismatch: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -423,6 +423,8 @@ type RoomListQuery struct {
|
|||||||
Query string
|
Query string
|
||||||
// CountryCode 非空时只返回房主国家命中的房间;gateway 必须先校验它属于当前用户区域。
|
// CountryCode 非空时只返回房主国家命中的房间;gateway 必须先校验它属于当前用户区域。
|
||||||
CountryCode string
|
CountryCode string
|
||||||
|
// ViewerCountryCode 是当前用户国家;公共列表不按它过滤,只用它在区域内把本国家房间排到同状态房间前。
|
||||||
|
ViewerCountryCode string
|
||||||
// Limit 是实际查询数量,service 层会限制最大值。
|
// Limit 是实际查询数量,service 层会限制最大值。
|
||||||
Limit int
|
Limit int
|
||||||
// CursorSortScore 是 hot tab 的游标 score。
|
// CursorSortScore 是 hot tab 的游标 score。
|
||||||
|
|||||||
@ -1937,7 +1937,7 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRoomListEntries 读取公共发现列表读模型,并按查询区域把全区置顶、区域置顶、本区房和外区房排成稳定分页序列。
|
// ListRoomListEntries 读取公共发现列表读模型,并先按查询区域隔离,再在区域内部按置顶、用户国家和在线状态排成稳定分页序列。
|
||||||
func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) {
|
func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) {
|
||||||
if query.Limit <= 0 {
|
if query.Limit <= 0 {
|
||||||
query.Limit = 20
|
query.Limit = 20
|
||||||
@ -2354,16 +2354,22 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
|
|||||||
if regionID < 0 {
|
if regionID < 0 {
|
||||||
regionID = 0
|
regionID = 0
|
||||||
}
|
}
|
||||||
regionLiteral := strconv.FormatInt(regionID, 10)
|
viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode)
|
||||||
pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL THEN 0 ELSE 1 END"
|
filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode)
|
||||||
newPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " THEN 2 ELSE 3 END"
|
sortCountryCode := viewerCountryCode
|
||||||
hotPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " AND r.online_count > 0 THEN 2 WHEN r.visible_region_id <> " + regionLiteral + " AND r.online_count > 0 THEN 3 WHEN r.visible_region_id = " + regionLiteral + " THEN 4 ELSE 5 END"
|
if filterCountryCode != "" {
|
||||||
pinRankExpr := hotPinRankExpr
|
// 国家 tab 已经把结果集收敛到一个国家,国家置顶和国家优先桶都应按该 tab 国家解释。
|
||||||
if query.Tab == "new" {
|
sortCountryCode = filterCountryCode
|
||||||
pinRankExpr = newPinRankExpr
|
|
||||||
}
|
}
|
||||||
pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight ELSE 0 END"
|
normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 END"
|
||||||
pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms ELSE 0 END"
|
if sortCountryCode != "" {
|
||||||
|
countryLiteral := roomListSQLStringLiteral(sortCountryCode)
|
||||||
|
normalRankExpr = "CASE WHEN r.owner_country_code = " + countryLiteral + " AND r.online_count > 0 THEN 2 WHEN r.owner_country_code <> " + countryLiteral + " AND r.online_count > 0 THEN 3 WHEN r.owner_country_code = " + countryLiteral + " THEN 4 ELSE 5 END"
|
||||||
|
}
|
||||||
|
pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL AND cp.room_id IS NULL THEN 0 ELSE 1 END"
|
||||||
|
pinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 0 WHEN cp.room_id IS NOT NULL THEN 1 ELSE " + normalRankExpr + " END"
|
||||||
|
pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight WHEN cp.room_id IS NOT NULL THEN cp.weight ELSE 0 END"
|
||||||
|
pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms WHEN cp.room_id IS NOT NULL THEN cp.expires_at_ms ELSE 0 END"
|
||||||
selectSQL := `
|
selectSQL := `
|
||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
|
||||||
@ -2384,9 +2390,18 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
|
|||||||
AND rp.room_id = r.room_id
|
AND rp.room_id = r.room_id
|
||||||
AND rp.status = ?
|
AND rp.status = ?
|
||||||
AND rp.pinned_at_ms <= ?
|
AND rp.pinned_at_ms <= ?
|
||||||
AND rp.expires_at_ms > ?`
|
AND rp.expires_at_ms > ?
|
||||||
where := []string{"r.app_code = ?", "r.status = ?"}
|
LEFT JOIN room_region_pins cp
|
||||||
args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active"}
|
ON cp.app_code = r.app_code
|
||||||
|
AND cp.pin_type = 'country'
|
||||||
|
AND cp.visible_region_id = ?
|
||||||
|
AND cp.room_id = r.room_id
|
||||||
|
AND r.owner_country_code = ?
|
||||||
|
AND cp.status = ?
|
||||||
|
AND cp.pinned_at_ms <= ?
|
||||||
|
AND cp.expires_at_ms > ?`
|
||||||
|
where := []string{"r.app_code = ?", "r.status = ?", "r.visible_region_id = ?"}
|
||||||
|
args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, regionID, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active", regionID}
|
||||||
if query.OwnerUserID > 0 {
|
if query.OwnerUserID > 0 {
|
||||||
where = append(where, "r.owner_user_id = ?")
|
where = append(where, "r.owner_user_id = ?")
|
||||||
args = append(args, query.OwnerUserID)
|
args = append(args, query.OwnerUserID)
|
||||||
@ -2417,6 +2432,23 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
|
|||||||
return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args
|
return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeRoomListSQLCountryCode(countryCode string) string {
|
||||||
|
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||||
|
if len(countryCode) < 2 || len(countryCode) > 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, ch := range countryCode {
|
||||||
|
if ch < 'A' || ch > 'Z' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countryCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomListSQLStringLiteral(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) {
|
func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) {
|
||||||
if query.Page <= 0 {
|
if query.Page <= 0 {
|
||||||
query.Page = 1
|
query.Page = 1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user