Merge branch 'main' into test

# Conflicts:
#	services/room-service/internal/storage/mysql/repository.go
This commit is contained in:
zhx 2026-06-22 17:24:10 +08:00
commit 0f9a73642a
134 changed files with 21734 additions and 14322 deletions

View File

@ -50,6 +50,7 @@ run:
./scripts/resolve-compose-container-conflicts.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
./scripts/print-compose-addresses.sh
TZ=UTC go run ./cmd/dev-run -services "$(DEV_RUN_SERVICE_LIST)"
@ -69,6 +70,7 @@ admin-deps:
./scripts/resolve-compose-container-conflicts.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
docker compose up --build -d user-service
./scripts/print-compose-addresses.sh
@ -92,11 +94,13 @@ up:
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service robot-service game-service notice-service statistics-service; \
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
docker compose up -d $(SERVICE_ID); \
else \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
docker compose up -d $(SERVICE_ID); \
fi
./scripts/print-compose-addresses.sh
@ -114,6 +118,7 @@ rebuild: check-service
./scripts/resolve-compose-container-conflicts.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
fi
docker compose up -d --build $(SERVICE_ID)
./scripts/print-compose-addresses.sh
@ -174,6 +179,7 @@ up-service: check-service
./scripts/resolve-compose-container-conflicts.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
fi
docker compose up -d $(SERVICE_ID)
./scripts/print-compose-addresses.sh

File diff suppressed because it is too large Load Diff

View File

@ -1665,6 +1665,8 @@ message LuckyGiftMeta {
int32 gift_count = 12;
// visible_region_id room-service IM
int64 visible_region_id = 13;
// country_id
int64 country_id = 14;
}
message LuckyGiftTier {
@ -1948,6 +1950,18 @@ message WheelDrawResult {
string wallet_transaction_id = 16;
int64 coin_balance_after = 17;
string metadata_json = 18;
repeated WheelDrawReward rewards = 19;
}
message WheelDrawReward {
string selected_tier_id = 1;
string reward_type = 2;
string reward_id = 3;
int64 reward_count = 4;
int64 reward_coins = 5;
int64 rtp_value_coins = 6;
string reward_status = 7;
string metadata_json = 8;
}
message ExecuteWheelDrawRequest {

File diff suppressed because it is too large Load Diff

View File

@ -140,6 +140,8 @@ message LaunchGameRequest {
// access_token is the same bearer token used by the App request.
// Provider adapters pass it through as the game token instead of minting a separate game token.
string access_token = 7;
// country_id gateway provider
int64 country_id = 8;
}
message LaunchGameResponse {
@ -194,6 +196,8 @@ message DiceMatch {
int64 ready_at_ms = 24;
int64 canceled_at_ms = 25;
int64 pool_delta_coin = 26;
// country_id
int64 country_id = 27;
}
message DiceStakeOption {
@ -364,6 +368,8 @@ message CreateDiceMatchRequest {
int32 min_players = 7;
int32 max_players = 8;
string rps_gesture = 9;
// country_id gateway
int64 country_id = 10;
}
message JoinDiceMatchRequest {
@ -397,6 +403,8 @@ message MatchDiceRequest {
int64 region_id = 5;
int64 stake_coin = 6;
string rps_gesture = 7;
// country_id gateway
int64 country_id = 8;
}
message CancelDiceMatchRequest {

File diff suppressed because it is too large Load Diff

View File

@ -273,6 +273,11 @@ message AdminHumanRoomRobotCountryPool {
repeated int64 robot_user_ids = 2;
}
message AdminHumanRoomRobotCountryRule {
string country_code = 1;
int32 max_room_count = 2;
}
message AdminHumanRoomRobotConfig {
string app_code = 1;
bool enabled = 2;
@ -299,6 +304,12 @@ message AdminHumanRoomRobotConfig {
int64 normal_gift_interval_max_ms = 23;
int32 room_target_min_online = 24;
int32 room_target_max_online = 25;
// allowed_owner_ids / owner_user_id
repeated string allowed_owner_ids = 26;
// country_limit_enabled country_rules
bool country_limit_enabled = 27;
// country_rules
repeated AdminHumanRoomRobotCountryRule country_rules = 28;
}
message AdminGetHumanRoomRobotConfigRequest {
@ -986,6 +997,17 @@ message SystemEvictUserResponse {
string rtc_kick_error = 6;
}
// SendGiftTargetHostScope gateway
message SendGiftTargetHostScope {
int64 target_user_id = 1;
// target_is_host gateway user-service active host profile
bool target_is_host = 2;
// target_host_region_id Host & Agency
int64 target_host_region_id = 3;
// target_agency_owner_user_id
int64 target_agency_owner_user_id = 4;
}
// SendGiftRequest
message SendGiftRequest {
RequestMeta meta = 1;
@ -1007,6 +1029,12 @@ message SendGiftRequest {
int64 sender_region_id = 11;
// sender_country_id gateway user-service
int64 sender_country_id = 12;
// entitlement_id Bag
string entitlement_id = 13;
// source bag
string source = 14;
// target_host_scopes target
repeated SendGiftTargetHostScope target_host_scopes = 15;
}
// SendGiftResponse

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,10 @@ message DebitGiftRequest {
int64 target_agency_owner_user_id = 12;
// sender_region_id visible_region_id
int64 sender_region_id = 13;
// entitlement_id gift COIN
string entitlement_id = 14;
// charge_source coin/bag coin
string charge_source = 15;
}
// DebitGiftResponse sender COIN
@ -51,6 +55,8 @@ message DebitGiftResponse {
string gift_icon_url = 15;
string gift_animation_url = 16;
repeated string gift_effect_types = 17;
string entitlement_id = 18;
string charge_source = 19;
}
// DebitGiftTarget
@ -76,6 +82,8 @@ message BatchDebitGiftRequest {
int64 region_id = 8;
int64 sender_region_id = 9;
repeated DebitGiftTarget targets = 10;
string entitlement_id = 11;
string charge_source = 12;
}
// BatchDebitGiftReceipt
@ -1330,6 +1338,19 @@ message HandleMifapayNotifyResponse {
string order_id = 3;
}
message HandleV5PayNotifyRequest {
string request_id = 1;
string app_code = 2;
string raw_json = 3;
map<string, string> fields = 4;
}
message HandleV5PayNotifyResponse {
bool accepted = 1;
string response_text = 2;
string order_id = 3;
}
message DiamondExchangeRule {
string exchange_type = 1;
string from_asset_type = 2;
@ -1569,6 +1590,8 @@ message CreditLuckyGiftRewardRequest {
string pool_id = 8;
string reason = 9;
int64 visible_region_id = 10;
// country_id outbox
int64 country_id = 11;
}
// CreditLuckyGiftRewardResponse COIN
@ -1892,6 +1915,40 @@ message CronBatchResponse {
bool has_more = 5;
}
message ResourceShopPurchaseOrder {
string app_code = 1;
string order_id = 2;
string command_id = 3;
int64 user_id = 4;
int64 shop_item_id = 5;
int64 resource_id = 6;
Resource resource = 7;
int32 duration_days = 8;
int64 price_coin = 9;
string status = 10;
string wallet_transaction_id = 11;
string resource_grant_id = 12;
string entitlement_id = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
message ListResourceShopPurchaseOrdersRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
string resource_type = 4;
string status = 5;
string keyword = 6;
int32 page = 7;
int32 page_size = 8;
}
message ListResourceShopPurchaseOrdersResponse {
repeated ResourceShopPurchaseOrder orders = 1;
int64 total = 2;
}
// WalletCronService cron-service wallet-service owner
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -1940,6 +1997,7 @@ service WalletService {
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
rpc ListResourceShopPurchaseOrders(ListResourceShopPurchaseOrdersRequest) returns (ListResourceShopPurchaseOrdersResponse);
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
@ -1959,6 +2017,7 @@ service WalletService {
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
rpc HandleV5PayNotify(HandleV5PayNotifyRequest) returns (HandleV5PayNotifyResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);

View File

@ -240,6 +240,7 @@ const (
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
WalletService_ListResourceShopPurchaseOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopPurchaseOrders"
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
@ -259,6 +260,7 @@ const (
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
WalletService_GetH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/GetH5RechargeOrder"
WalletService_HandleMifapayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleMifapayNotify"
WalletService_HandleV5PayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleV5PayNotify"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
@ -331,6 +333,7 @@ type WalletServiceClient interface {
ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error)
ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
@ -350,6 +353,7 @@ type WalletServiceClient interface {
SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error)
HandleV5PayNotify(ctx context.Context, in *HandleV5PayNotifyRequest, opts ...grpc.CallOption) (*HandleV5PayNotifyResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
@ -775,6 +779,16 @@ func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in
return out, nil
}
func (c *walletServiceClient) ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourceShopPurchaseOrdersResponse)
err := c.cc.Invoke(ctx, WalletService_ListResourceShopPurchaseOrders_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PurchaseResourceShopItemResponse)
@ -965,6 +979,16 @@ func (c *walletServiceClient) HandleMifapayNotify(ctx context.Context, in *Handl
return out, nil
}
func (c *walletServiceClient) HandleV5PayNotify(ctx context.Context, in *HandleV5PayNotifyRequest, opts ...grpc.CallOption) (*HandleV5PayNotifyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HandleV5PayNotifyResponse)
err := c.cc.Invoke(ctx, WalletService_HandleV5PayNotify_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDiamondExchangeConfigResponse)
@ -1260,6 +1284,7 @@ type WalletServiceServer interface {
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
@ -1279,6 +1304,7 @@ type WalletServiceServer interface {
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
@ -1431,6 +1457,9 @@ func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context,
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
}
func (UnimplementedWalletServiceServer) ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListResourceShopPurchaseOrders not implemented")
}
func (UnimplementedWalletServiceServer) PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PurchaseResourceShopItem not implemented")
}
@ -1488,6 +1517,9 @@ func (UnimplementedWalletServiceServer) GetH5RechargeOrder(context.Context, *Get
func (UnimplementedWalletServiceServer) HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method HandleMifapayNotify not implemented")
}
func (UnimplementedWalletServiceServer) HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method HandleV5PayNotify not implemented")
}
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
}
@ -2286,6 +2318,24 @@ func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListResourceShopPurchaseOrders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourceShopPurchaseOrdersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListResourceShopPurchaseOrders(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListResourceShopPurchaseOrders_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListResourceShopPurchaseOrders(ctx, req.(*ListResourceShopPurchaseOrdersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_PurchaseResourceShopItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PurchaseResourceShopItemRequest)
if err := dec(in); err != nil {
@ -2628,6 +2678,24 @@ func _WalletService_HandleMifapayNotify_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _WalletService_HandleV5PayNotify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HandleV5PayNotifyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).HandleV5PayNotify(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_HandleV5PayNotify_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).HandleV5PayNotify(ctx, req.(*HandleV5PayNotifyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiamondExchangeConfigRequest)
if err := dec(in); err != nil {
@ -3241,6 +3309,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetResourceShopItemStatus",
Handler: _WalletService_SetResourceShopItemStatus_Handler,
},
{
MethodName: "ListResourceShopPurchaseOrders",
Handler: _WalletService_ListResourceShopPurchaseOrders_Handler,
},
{
MethodName: "PurchaseResourceShopItem",
Handler: _WalletService_PurchaseResourceShopItem_Handler,
@ -3317,6 +3389,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "HandleMifapayNotify",
Handler: _WalletService_HandleMifapayNotify_Handler,
},
{
MethodName: "HandleV5PayNotify",
Handler: _WalletService_HandleV5PayNotify_Handler,
},
{
MethodName: "GetDiamondExchangeConfig",
Handler: _WalletService_GetDiamondExchangeConfig_Handler,

View File

@ -0,0 +1,12 @@
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
# Local RocketMQ clients run in two places: Docker containers and host go run
# processes. Advertising the Docker host address keeps both paths reachable.
brokerIP1 = host.docker.internal
listenPort = 10911

View File

@ -27,7 +27,7 @@ services:
- "10911:10911"
- "19011:10911"
volumes:
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
- ./deploy/rocketmq/broker.local.conf:/home/rocketmq/broker.conf:ro
- rocketmq-store:/home/rocketmq/store
depends_on:
- rocketmq-namesrv

View File

@ -0,0 +1,172 @@
# 幸运礼物产品文档
本文档按当前代码实现描述幸运礼物产品能力。所有功能点均给出实现依据;未在代码中闭环的能力不写成已上线能力。
## 1. 产品定位和范围
幸运礼物是建立在房间送礼链路上的付费抽奖玩法。用户发送带 `pool_id` 的幸运礼物,系统先完成金币扣费,再按奖池规则抽奖;中奖金币由钱包服务异步入账,开奖结果通过房间 IM 和区域播报做表现。
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 付费抽奖定位 | 幸运礼物不是独立支付入口,而是送礼成功后的抽奖附加玩法。 | `services/room-service/internal/room/service/gift.go``SendGift` 先调用 `wallet.DebitGift`,成功后再调用 `ExecuteLuckyGiftDraw`。 |
| 规则隔离维度 | 规则按 `pool_id` 隔离,不按真实礼物 ID 绑定规则;一个奖池可服务不同价格礼物。 | `docs/幸运礼物实现文档.md``services/activity-service/internal/storage/mysql/lucky_gift_rule_config_repository.go``GetLuckyGiftRuleConfig``getLuckyGiftRuleConfig``luckyRuntimeConfig` 按真实 `coin_spent` 缩放奖档金额。 |
| 服务边界 | `activity-service` 负责规则、抽奖、奖池、风控、抽奖事实和 outbox`wallet-service` 负责扣费和返奖入账;`room-service` 负责房间状态、热度、榜单和房间 outbox。 | `services/activity-service/internal/service/luckygift/service.go``Repository``WalletClient``RoomPublisher` 接口;`services/room-service/internal/room/service/gift.go``services/wallet-service/internal/service/wallet/service.go``CreditLuckyGiftReward`。 |
| 不兼容旧玩法假设 | 当前只以 v2 规则版本为准;旧文档和旧 gift_id 规则模型不作为产品口径。 | `docs/幸运礼物实现文档.md` 开头说明;后台路由固定为 `/admin/activity/lucky-gifts/v2/*`,见 `server/admin/internal/modules/luckygift/routes.go`。 |
## 2. 角色和使用场景
| 角色 | 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- | --- |
| 送礼用户 | 发送幸运礼物并获得即时抽奖结果 | 用户在房间内给一个在线用户送礼,送礼成功后当前用户同步拿到 `lucky_gift` 结果。 | `SendGift` 校验送礼者和收礼者都在房间;响应包含 `roomv1.SendGiftResponse.LuckyGift`;转换逻辑在 `luckyGiftResultFromProto`。 |
| 收礼用户/主播 | 作为礼物目标和主播风控主体 | 目标用户必须在房间内;主播风控优先使用房主 ID缺失时退回房间 ID。 | `SendGift` 校验 `TargetUserID``luckyGiftAnchorID`。 |
| 房间内用户 | 看到送礼事实和开奖结果表现 | 普通送礼表现继续广播;中奖结果由 activity outbox worker 发布 `lucky_gift_drawn` 房间群消息。 | `RoomGiftSent` outbox 构建在 `SendGift`;开奖结果发布在 `publishLuckyGiftDrawn`。 |
| 运营管理员 | 配置奖池、阶段奖档、RTP、风控上限并查看数据 | 后台提供奖池配置、发布、列表、抽奖汇总和抽奖明细接口;前端提供配置页、模拟工具和数据概览。 | 后端 `server/admin/internal/modules/luckygift/handler.go`;前端 `hyapp-admin-platform/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx`。 |
## 3. 用户端功能
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 送礼前资格检查 | App 可在送礼前调用检查接口确认奖池是否可用;该接口只检查,不扣费、不抽奖。 | `POST /api/v1/activities/lucky-gifts/check` 注册在 `services/gateway-service/internal/transport/http/httproutes/router.go`;处理函数 `activityapi.Handler.checkLuckyGift`。 |
| 检查参数 | 检查接口要求 `room_id``gift_id``pool_id` 必填,用户 ID 来自登录态。 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go``luckyGiftCheckRequest` 和必填校验。 |
| 检查返回 | 返回是否启用、原因、奖池 ID、礼物 ID、参考价格、规则版本、目标 RTP、用户当前体验池。 | `luckyGiftCheckData``activityv1.CheckLuckyGiftResponse``Repository.CheckLuckyGift` 读取用户状态并返回 `ExperiencePool`。 |
| 显式奖池触发 | 新客户端传 `pool_id` 时,系统必须先检查规则启用,再扣费;规则未启用则整笔送礼失败。 | `SendGift``cmd.PoolID != ""` 分支调用 `CheckLuckyGift`,未启用返回 `RuleNotActive`,位置在扣费调用前。 |
| 旧客户端兼容触发 | 未显式传 `pool_id` 时,若钱包返回礼物类型为 `lucky``super_lucky`,仍可触发幸运礼物抽奖。 | `Service.shouldDrawLuckyGift` 判断 `giftTypeCode``lucky/super_lucky`。 |
| 扣费优先 | 抽奖只使用钱包扣费成功后的 `coin_spent`,不信任客户端价格。 | `SendGift` 调用 `wallet.DebitGift` 后,将 `billing.GetCoinSpent()` 写入 `LuckyGiftMeta.CoinSpent`。 |
| 设备风控标识 | 设备维度优先使用房间命令的 `session_id`,没有时退回 `command_id`,保证风控 scope 不为空。 | `luckyGiftDeviceID`。 |
| 同步抽奖结果 | 送礼响应中返回抽奖 ID、奖池、规则版本、体验池、命中奖档、倍率、奖励金币、发放状态、阶段反馈和高倍标识。 | `api/proto/room/v1/room.proto``LuckyGiftDrawResult``luckyGiftResultFromProto`。 |
| 普通送礼事实保留 | 幸运礼物不会替代普通送礼房间热度、本地礼物榜、RoomGiftSent、RoomHeatChanged、RoomRankChanged 仍照常落地。 | `SendGift` 在抽奖后继续更新 `current.Heat``GiftRank`,并构建 `RoomGiftSent``RoomHeatChanged``RoomRankChanged` outbox。 |
| 批量送礼 | `gift_count=N` 表示抽 N 次;响应展示整次送礼的聚合倍数和总奖励。 | `executeLuckyGiftDrawBatch``luckyDrawUnitSpend``stageLuckyBatchDraw``luckyAggregateDrawResults`。 |
| 批量金额拆分 | 总扣费按 `gift_count` 拆为 N 个单抽金额,余数分给前几抽,保证子抽金额总和等于扣费事实。 | `luckyDrawUnitSpend`;单测 `TestLuckyDrawUnitSpendSplitsBatchSpendByGiftCount`。 |
## 4. 抽奖规则
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 奖池配置开关 | 每个 `pool_id` 有独立启用状态;未配置或未启用时不能抽奖。 | `Service.GetConfig` 未配置返回 disabled 草稿;`Repository.CheckLuckyGift` 未配置返回 `enabled=false``executeSingleLuckyGiftDraw``executeOptimizedLuckyGiftDrawBatch` 未启用返回 `RuleNotActive`。 |
| 不可变规则版本 | 每次后台发布都新增 `rule_version`,不覆盖历史版本。 | `Repository.PublishLuckyGiftRuleConfig` 事务内锁最新版本并写入 `lucky_gift_rule_versions``lucky_gift_stage_tiers`。 |
| 最新规则读取 | App 检查、抽奖运行和后台列表都读取当前奖池最新版本。 | `getLuckyGiftRuleConfig``rule_version DESC LIMIT 1``ListLuckyGiftRuleConfigs` 使用每个 pool 的 `MAX(rule_version)`。 |
| RTP 目标 | `target_rtp_ppm` 表示基础返奖目标,后台业务单位是百分比,服务端持久化为 ppm。 | `server/admin/internal/modules/luckygift/handler.go``percentToPPM``ppmToPercent``validateRuleConfig` 限制 10%-99%。 |
| 入池比例 | 每笔扣费按 `pool_rate_ppm` 进入基础奖池,且入池比例必须不低于目标 RTP。 | `validateRuleConfig` 校验 `PoolRatePPM >= TargetRTPPPM``luckyBasePoolIn``creditLuckyBasePools`。 |
| 结算窗口 | RTP 结算窗口按金币流水配置,运行时按参考价格折算窗口抽数;窗口只用于观察和结算,不改变随机概率。 | `luckyRuntimeConfigFromRuleConfig``SettlementWindowWager / GiftPriceReference` 折算窗口;`getOpenLuckyRTPWindow``selectLuckyCandidate` 注释和实现不读取 RTP 缺口。 |
| 波动带 | 后台发布时校验每个阶段期望 RTP 是否落在 `目标 RTP ± 波动带`。 | `validateRuleStageExpectedRTP`;前端 `luckyGiftConfigFormSchema` 同步校验。 |
| 参考价格 | `gift_price_reference` 用于配置奖档金额、结算窗口和等价抽数;真实抽奖按本次单抽 `coin_spent` 缩放奖档金额。 | `luckyRuntimeConfigFromRuleConfig``luckyRuntimeConfig``luckyScaleReward`。 |
| 用户阶段 | 用户分为新手、正常、高阶三个阶段;阶段由累计金币流水折算等价抽数决定。 | `luckyEquivalentDraws``luckyExperiencePool`;用户状态表字段见 `lucky_user_states`。 |
| 阶段奖档 | 每个阶段都配置一组奖档,每个奖档包含倍率、概率、预算来源、高水位、播报级别、启用状态;当前 v2 抽奖主链路只把 `base_rtp` 奖档转换为随机候选。 | `LuckyGiftRuleStage``LuckyGiftRuleTier` proto`domain.RuleStage``domain.RuleTier`;后台 DTO `stageDTO``tierDTO`;运行转换见 `luckyRuntimeTiersFromRuleStages`。 |
| 概率闭合 | 每个阶段启用奖档概率合计必须为 100%,且必须存在 0x 档和至少一个基础 RTP 奖档。 | `validateRuleStage`;前端 `luckyGiftConfigFormSchema`。 |
| 高倍限制 | 高倍率奖档必须标记高水位,运行时仍受奖池和风控过滤。 | 后端 `validateRuleStage` 对高倍率校验;前端 schema 对 5x 及以上要求 `highWaterOnly``selectLuckyCandidate` 对高倍和 `LargeTierEnabled` 做过滤。 |
| 随机候选 | 抽奖只使用后台配置概率;奖池和风控只删除不可支付候选,不动态注入 RTP 修正档。 | `selectLuckyCandidate``luckyConfiguredWeightCandidates`;单测 `TestLuckyConfiguredWeightCandidatesUsesConfiguredWeightsWithoutForcedSettlement`。 |
| 无可支付奖档 | 所有正奖档都因奖池或风控不可支付时,本抽返回 0 倍 `no_payable_tier`,不透支。 | `selectLuckyCandidate` 返回 `no_payable_tier`;单测 `TestSelectLuckyCandidateFallsBackToZeroWhenAllPositiveTiersAreUnpayable`。 |
## 5. 奖池、预算和风控
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 三层基础奖池 | 基础返奖由平台池、房间池、礼物池共同承担;任一层余额不足都会过滤奖档。 | `getOrCreateLuckyPool``luckySplitWeighted``luckyWeightedPoolCapacity``creditLuckyBasePools``debitLuckyPool`。 |
| 先入池后开奖 | 每笔扣费先按入池比例补充奖池,再过滤候选并开奖。 | 单抽路径先调用 `creditLuckyBasePools``selectLuckyCandidate`;批量路径在 `stageLuckyBatchDraw` 中先 `creditLuckyPoolInMemory` 再候选过滤。 |
| 房间气氛池 | 当前保留房间气氛池账本和结果字段,且气氛奖励口径独立于基础 RTPv2 主候选默认仍来自基础 RTP。 | `lucky_room_atmosphere_pools` 表;`getOrCreateLuckyAtmosphere``creditLuckyAtmosphere``updateLuckyAtmosphereNet``applyLuckyDraw` 注释说明气氛奖励不进入基础 RTP。 |
| 活动补贴预算 | 当前保留活动补贴总预算和 UTC 日预算账本v2 后台第一阶段未暴露活动预算,运行主候选默认不从活动补贴生成。 | `lucky_activity_budgets` 表;`getLuckyActivityRemaining``getOrCreateLuckyActivityTotalRemaining``luckyRuntimeConfigFromRuleConfig``ActivityBudget``ActivityDailyLimit` 置为 0。 |
| 单次风控上限 | 单抽奖励不能超过规则配置的单次返奖上限。 | `validateRuleConfig` 要求 `MaxSinglePayout > 0``luckyRiskCapacity` 首先使用 `MaxSinglePayout`。 |
| 用户小时/日上限 | 同一用户在小时和 UTC 自然日内有返奖上限。 | `getLuckyRiskCounters` 创建 `user:hour``user:day` 计数;`luckyRiskCapacity``updateLuckyRiskCounters`。 |
| 设备日上限 | 同一设备在 UTC 自然日内有返奖上限。 | `getLuckyRiskCounters``device/day``luckyGiftDeviceID` 保证设备 scope。 |
| 房间小时上限 | 同一房间每小时有返奖上限。 | `getLuckyRiskCounters``room/hour``luckyRiskCapacity`。 |
| 主播日上限 | 同一主播 UTC 自然日有返奖上限。 | `getLuckyRiskCounters``anchor/day``luckyGiftAnchorID`。 |
| 并发一致性 | 奖池、用户状态、RTP 窗口和风控计数在事务内锁定,避免并发透支。 | `Repository.ExecuteLuckyGiftDraw` 使用 MySQL 事务;`getLuckyUserStateForUpdate``getLatestLuckyRTPWindow(... FOR UPDATE)``getOrCreateLuckyPool(... FOR UPDATE)``getOrCreateLuckyRiskCounter(... FOR UPDATE)`。 |
## 6. 返奖和表现
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 异步发奖 | 抽奖事务只写事实和 outbox不直接调用钱包worker 后续处理返奖和表现。 | `applyLuckyDraw``insertLuckyAggregateDrawOutbox``activity_outbox`;外部 RPC 在 `processDrawOutbox`。 |
| 钱包入账 | 有有效奖励时worker 调用钱包 `CreditLuckyGiftReward` 给中奖用户加 COIN。 | `Service.processDrawOutbox` 调用 `walletv1.CreditLuckyGiftReward`;钱包实现见 `services/wallet-service/internal/service/wallet/service.go``internal/storage/mysql/repository.go`。 |
| 返奖幂等 | 钱包命令 ID 由聚合 `draw_id` 派生,同一 draw 重试只加币一次。 | `processDrawOutbox` 使用 `CommandId: "lucky_reward:" + payload.DrawID`;钱包 `CreditLuckyGiftReward``lookupTransaction` 和 request hash 幂等;单测 `TestCreditLuckyGiftRewardIsIdempotent`。 |
| 房间开奖结果 | worker 发 `lucky_gift_drawn` 房间群自定义消息,用于房间内开奖结果表现。 | `publishLuckyGiftDrawn`IM 消息 `Desc``lucky_gift_drawn`。 |
| 区域播报 | 当存在可见区域、有效奖励且聚合倍率不低于 1x 时,写区域播报 outbox。 | `shouldPublishLuckyGiftRegionBroadcast``publishLuckyGiftRegionBroadcast``luckyGiftRegionBroadcastPayload`。 |
| 成功收敛 | 钱包、区域播报和房间 IM 都成功后,整批 draw records 标记 `granted`outbox 标记 `delivered`。 | `processDrawOutbox` 末尾调用 `MarkLuckyGiftDrawsGranted``MarkLuckyGiftOutboxDelivered`。 |
| 失败退避 | outbox 处理失败按指数退避重试;达到最大重试后标记失败。 | `markOutboxFailed``luckyGiftBackoff``MarkLuckyGiftOutboxRetryable``MarkLuckyGiftOutboxFailed`。 |
| 已返奖但表现失败 | 钱包已加币后,如果 IM 或播报失败,不把 draw 改成 failed避免覆盖账务事实。 | `markOutboxFailed(... alreadyCredited=true)` 分支;`MarkLuckyGiftDrawsFailed` 注释约束 only pending。 |
## 7. 批量抽奖产品规则
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| N 次独立随机 | `gift_count=N` 时内部执行 N 次抽奖,每次保留独立 draw record。 | `executeOptimizedLuckyGiftDrawBatch` 循环调用 `stageLuckyBatchDraw``insertLuckyDrawRecords` 批量写每条明细。 |
| 一次聚合发放 | 批量抽奖只产生一条聚合 `LuckyGiftDrawn` outbox钱包只入账一次。 | `insertLuckyAggregateDrawOutbox` payload 包含 `draw_id` 和全部 `draw_ids``processDrawOutbox` 对聚合金额调用一次钱包。 |
| 聚合结果展示 | 批量响应展示总基础奖励、总气氛奖励、总活动补贴、总有效奖励、总倍率;总倍率是子抽倍率相加,不是平均倍率。 | `luckyAggregateDrawResults`。 |
| 批量性能 | 规则、用户状态、RTP 窗口、奖池、预算和风控行只锁一次,循环内推进内存状态,事务尾部批量写回。 | `executeOptimizedLuckyGiftDrawBatch``stageLuckyBatchDraw``persistLuckyBatchDraw`。 |
| 批量幂等 | 批量子抽命令使用 `command_id#序号`;重试读回已提交子抽,部分存在时报冲突。 | `luckyDrawSubCommandID``collectLuckyBatchDrawsByCommand`。 |
## 8. 后台配置功能
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 后台菜单 | 运营后台有“幸运礼物”菜单,权限码为 `lucky-gift:view``lucky-gift:update`。 | `server/admin/migrations/017_lucky_gift_navigation.sql``server/admin/internal/repository/seed.go`;前端 `routes.js`。 |
| 查看配置 | 管理员可按 `pool_id` 查看奖池配置;未配置时返回 disabled 默认草稿。 | `GET /admin/activity/lucky-gifts/v2/config``Handler.GetLuckyGiftConfig``Service.GetConfig`;前端 `getLuckyGiftConfig`。 |
| 新增奖池 | 前端允许输入新奖池 ID并以默认草稿打开配置抽屉。 | `LuckyGiftConfigPage.jsx``AddPoolDialog``useLuckyGiftPage.submitAddPool`。 |
| 发布配置 | 管理员保存后发布为新的不可变规则版本。 | `PUT /admin/activity/lucky-gifts/v2/config``Handler.UpsertLuckyGiftConfig``Service.UpsertConfig``Repository.PublishLuckyGiftRuleConfig`。 |
| 奖池列表 | 管理员可查看已有奖池列表,每个奖池展示最新版本。 | `GET /admin/activity/lucky-gifts/v2/configs``Handler.ListLuckyGiftConfigs``Repository.ListLuckyGiftRuleConfigs`;前端 `listLuckyGiftConfigs`。 |
| 基础控制字段 | 后台可配置启用状态、RTP、入池比例、结算窗口流水、波动带、参考价格、新手/正常等价抽数。 | 前端 `LuckyGiftConfigDrawer` 的“基础控制”;后端 `configRequest``LuckyGiftRuleConfig` 字段。 |
| 风控上限字段 | 后台可配置单次、用户小时、用户每日、设备每日、房间小时、主播每日上限。 | 前端 `LuckyGiftConfigDrawer` 的“风控上限”;后端 `validateRuleConfig`。 |
| 阶段奖档编辑 | 后台可编辑新手、正常、高阶三个阶段的奖档 ID、倍率、概率、预算来源、广播级别、高水位和启用状态其中 `reward_source` 当前用于配置和校验,运行随机只消费 `base_rtp`。 | `StageTierEditor``TierRow`;后端 `configToProto``configFromProto`;运行转换见 `luckyRuntimeTiersFromRuleStages`。 |
| 概率纠正工具 | 前端提供“纠正概率”工具,按目标 RTP 调整阶段概率,帮助运营形成可发布配置。 | `LuckyGiftConfigDrawer` 调用 `correctStageTierProbabilities`;实现见 `configModel.js`。 |
| 表单校验 | 前端提前校验概率闭合、RTP 波动带、0x 档、基础 RTP 档和高倍高水位;后端仍做最终校验。 | 前端 `luckyGiftConfigFormSchema`;后端 `validateRuleConfig``validateRuleStage``validateRuleStageExpectedRTP`。 |
| 随机模拟测试 | 后台配置页提供随机模拟,可输入用户数、房间数、单抽花费范围和抽奖次数范围,输出概览并下载 CSV。 | `LuckyGiftSimulationPanel.jsx``runLuckyGiftSimulation`。 |
## 9. 后台审计和数据
| 功能点 | 产品规则 | 实现依据 |
| --- | --- | --- |
| 抽奖明细查询 | 后台接口支持按 `pool_id/gift_id/user_id/room_id/status` 分页查询抽奖明细。 | `GET /admin/activity/lucky-gifts/v2/draws``Handler.ListLuckyGiftDraws``Repository.ListLuckyGiftDraws``luckyDrawWhereClause`。 |
| 抽奖汇总 | 后台接口和页面展示总抽数、参与用户、参与房间、消耗金币、返奖金币、实际 RTP、pending/granted/failed 数。 | `GET /admin/activity/lucky-gifts/v2/draw-summary``Repository.GetLuckyGiftDrawSummary`;前端 `LuckyGiftDrawSummaryPanel`。 |
| 审计快照 | 每条抽奖明细记录命中奖档、过滤原因、奖池快照、RTP 快照、发放状态和钱包交易 ID。 | `lucky_draw_records` 表;`luckyDrawRecordSnapshots``ListLuckyGiftDraws`。 |
| 统计大盘口径 | 统计服务从 `RoomGiftSent` 事件识别带 `pool_id` 的礼物,累计幸运礼物流水和去重付费人数。 | `RoomGiftSent.pool_id``statistics-service/internal/storage/mysql/repository.go``ConsumeRoomGift``stat_lucky_gift_day_payers`。 |
| 发放状态 | 抽奖记录状态分为 `pending``granted``failed`;无奖励且无表现副作用的记录可直接 `granted`。 | `domain.StatusPending/Granted/Failed``luckyInitialRewardStatus``MarkLuckyGiftDrawsGranted``MarkLuckyGiftDrawsFailed`。 |
## 10. 接口清单
| 端 | 接口 | 功能点 | 实现依据 |
| --- | --- | --- | --- |
| App HTTP | `POST /api/v1/activities/lucky-gifts/check` | 送礼前资格检查。 | `activityapi.Handler.checkLuckyGift``httproutes.registerTaskRoutes`。 |
| App HTTP | `POST /api/v1/rooms/gift/send` | 发礼物并在成功扣费后触发幸运礼物抽奖。 | `httproutes.registerRoomRoutes``room-service``SendGift`。 |
| 内部 gRPC | `LuckyGiftService.CheckLuckyGift` | 房间链路或 gateway 查询奖池可用性。 | `api/proto/activity/v1/activity.proto``LuckyGiftServer.CheckLuckyGift`。 |
| 内部 gRPC | `LuckyGiftService.ExecuteLuckyGiftDraw` | 扣费成功后的抽奖命令。 | `LuckyGiftMeta``LuckyGiftDrawResult``LuckyGiftServer.ExecuteLuckyGiftDraw`。 |
| 内部 gRPC | `WalletService.CreditLuckyGiftReward` | 抽奖 outbox worker 调钱包入账。 | `api/proto/wallet/v1/wallet.proto`;钱包 `CreditLuckyGiftReward`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/config` | 获取单个奖池配置。 | `Handler.GetLuckyGiftConfig`。 |
| 后台 HTTP | `PUT /admin/activity/lucky-gifts/v2/config` | 发布奖池配置。 | `Handler.UpsertLuckyGiftConfig`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/configs` | 获取奖池配置列表。 | `Handler.ListLuckyGiftConfigs`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/draws` | 查询抽奖明细。 | `Handler.ListLuckyGiftDraws`。 |
| 后台 HTTP | `GET /admin/activity/lucky-gifts/v2/draw-summary` | 查询抽奖汇总。 | `Handler.GetLuckyGiftDrawSummary`。 |
## 11. 异常和边界
| 场景 | 产品处理 | 实现依据 |
| --- | --- | --- |
| 奖池未配置 | 检查接口返回不可用;后台返回可编辑 disabled 草稿。 | `Repository.CheckLuckyGift` 返回 `not_configured``Service.GetConfig` 返回 `DefaultRuleConfig`。 |
| 奖池关闭 | 显式传 `pool_id` 的送礼在扣费前失败;抽奖入口也会拒绝。 | `SendGift``CheckLuckyGift` 分支;`executeSingleLuckyGiftDraw``executeOptimizedLuckyGiftDrawBatch`。 |
| 抽奖失败 | 整笔送礼失败,不落普通礼物事实,依赖钱包扣费幂等处理重试。 | `SendGift` 在抽奖失败时直接返回错误,未进入 Room Cell 事件落地。 |
| outbox payload 损坏 | 无法解析时走失败收敛,不无限重试。 | `decodeDrawOutboxPayload``markOutboxFailed`。 |
| worker 依赖缺失 | 钱包、房间发布或区域播报依赖缺失时outbox 进入重试或失败,不直接丢弃。 | `processDrawOutbox``wallet == nil``publisher == nil``broadcaster == nil` 分支。 |
| 小额礼物 | 阶段按累计流水折算,奖档金额按实际单抽金额缩放,风控上限保持绝对金币。 | `luckyRuntimeConfig` 注释和实现;单测 `TestLuckyRuntimeConfigScalesTierRewardsByActualSpendButKeepsRiskCapsAbsolute`。 |
| UTC 窗口 | 付费时间、风控小时/日、活动预算日均使用 UTC epoch ms。 | `Service.Draw``PaidAtMS` 使用 `UTC().UnixMilli()``getLuckyRiskCounters``getLuckyActivityRemaining` 使用 `time.UnixMilli(...).UTC()`。 |
## 12. 当前不作为已实现产品能力
| 能力 | 当前状态 | 实现依据 |
| --- | --- | --- |
| 运行时按 RTP 缺口动态调概率 | 不支持。RTP 只用于发布校验和窗口观察。 | `selectLuckyCandidate` 只使用配置权重并过滤不可支付候选。 |
| 多目标送礼抽奖拆分 | 不支持。当前房间送礼仅支持 `target_type=user` 且一个目标用户。 | `SendGift` 对非 `user` 和多目标返回 `InvalidArgument`。 |
| 后台配置三层奖池权重 | v2 后台当前不暴露,运行侧使用保守默认权重。 | `luckyRuntimeConfigFromRuleConfig` 固定 `PlatformPoolWeightPPM=200000``RoomPoolWeightPPM=300000``GiftPoolWeightPPM=500000`。 |
| 按奖档广播级别精细化播报 | 配置字段存在,但当前区域播报阈值由代码统一判断为有区域、有效奖励、倍率不低于 1x。 | `LuckyGiftRuleTier.broadcast_level``shouldPublishLuckyGiftRegionBroadcast`。 |
| 按 `reward_source` 生成活动补贴或表现候选 | 不支持。当前 v2 运行候选只读取 `base_rtp` 奖档,活动补贴和表现来源不会被转换成随机候选。 | `luckyRuntimeTiersFromRuleStages` 对非 `SourceBaseRTP` 的奖档 `continue`。 |
## 13. 验证依据
| 验证项 | 覆盖功能 | 实现依据 |
| --- | --- | --- |
| Activity 配置校验单测 | 默认配置、ppm 单位、防错、概率闭合、0x 档、风控上限、阶段阈值。 | `services/activity-service/internal/service/luckygift/config_test.go`。 |
| Activity worker 单测 | 钱包入账、房间 IM、批量一次发奖并批量 granted。 | `services/activity-service/internal/service/luckygift/service_test.go`。 |
| Activity 抽奖算法单测 | 真实金额缩放、等价抽数、批量拆分、聚合倍数、候选过滤、不可支付回退 0x。 | `services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go`。 |
| Room 送礼链路单测 | 送礼后返回 `lucky_gift`,并把扣费后的金额传给抽奖。 | `services/room-service/internal/room/service/lucky_gift_test.go`。 |
| Gateway 检查接口单测 | App 检查接口正确转发 room/gift/pool/user 参数。 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler_test.go`。 |
| Wallet 返奖幂等单测 | 同一幸运礼物返奖命令只产生一条钱包交易和 outbox。 | `services/wallet-service/internal/service/wallet/service_test.go``TestCreditLuckyGiftRewardIsIdempotent`。 |
| 后台单位转换单测 | 百分比和倍率在后台 HTTP DTO 与 proto ppm 之间正确转换。 | `server/admin/internal/modules/luckygift/handler_test.go`。 |
| 前端表单与模拟单测 | 配置 payload、阶段 RTP、概率纠正和模拟确定性。 | `hyapp-admin-platform/src/features/lucky-gift/*test*`。 |

View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Local services start RocketMQ consumers before any business event is produced,
# so relying on producer-side auto topic creation makes a fresh broker fail at
# startup. Keep the fixed owner-outbox topics explicit and idempotent here.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${PROJECT_ROOT}"
"${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" rocketmq-namesrv rocketmq-broker
docker compose up -d rocketmq-namesrv rocketmq-broker >/dev/null
MQADMIN="/home/rocketmq/rocketmq-5.3.1/bin/mqadmin"
NAMESRV="rocketmq-namesrv:9876"
CLUSTER="hyapp-local"
TOPICS=(
"hyapp_wallet_outbox"
"hyapp_wallet_realtime_outbox"
"hyapp_room_outbox"
"hyapp_robot_room_outbox"
"hyapp_room_rocket_launch"
"hyapp_user_outbox"
"hyapp_game_outbox"
)
# The broker process can accept a container start before it has registered with
# NameServer. Wait for the cluster view so updateTopic targets the real broker.
for _ in {1..60}; do
if docker compose exec -T rocketmq-broker "${MQADMIN}" clusterList -n "${NAMESRV}" 2>/dev/null | grep -q "${CLUSTER}"; then
break
fi
sleep 1
done
if ! docker compose exec -T rocketmq-broker "${MQADMIN}" clusterList -n "${NAMESRV}" 2>/dev/null | grep -q "${CLUSTER}"; then
printf 'rocketmq broker did not register in cluster %s before topic init\n' "${CLUSTER}" >&2
exit 1
fi
for topic in "${TOPICS[@]}"; do
printf 'applying rocketmq topic: %s\n' "${topic}"
docker compose exec -T rocketmq-broker "${MQADMIN}" updateTopic -n "${NAMESRV}" -c "${CLUSTER}" -t "${topic}" -r 8 -w 8 >/dev/null
done

View File

@ -1,5 +1,5 @@
UPDATE user_invite_validity_policies
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'usdt_trc20'),
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'v5pay', 'usdt_trc20'),
updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000
WHERE app_code = 'lalu'
AND region_id = 0

View File

@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息',
can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送座驾',
can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -23,14 +30,86 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_grant_avatar_frame') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送头像框'' AFTER contact_info',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_grant_vehicle') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送座驾'' AFTER can_grant_avatar_frame',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_update_user_level') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心升级用户等级'' AFTER can_grant_vehicle',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_bd_leader') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 BD Leader'' AFTER can_update_user_level',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_admin') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 Admin'' AFTER can_add_bd_leader',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_superadmin') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 Superadmin'' AFTER can_add_admin',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_block_user') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心封禁用户'' AFTER can_add_superadmin',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
created_by_admin_id, created_at_ms, updated_at_ms
)
SELECT
bl.app_code,
bl.created_by_user_id,
'active',
'',
1,
1,
1,
1,
1,
1,
1,
0,
MIN(bl.created_at_ms),
MAX(bl.updated_at_ms)

View File

@ -0,0 +1,75 @@
-- Seed V5Pay as another provider under the existing third_party_payment_* model.
-- pay_type stores the V5Pay productType used by wallet-service when creating cashier orders.
SET @now_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
INSERT INTO third_party_payment_channels (
app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms
) VALUES ('lalu', 'v5pay', 'V5Pay', 'active', 20, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
provider_name = VALUES(provider_name),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO third_party_payment_methods (
app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type,
method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'UB_ONLINE', 'UnionBank Online', '', 'active', 1, 2010, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'INSTAPAY', 'InstaPay', '', 'active', 1, 2020, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'PESONET', 'PESONet', '', 'active', 1, 2030, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'GCASH_ONLINE', 'GCash Online', '', 'active', 1, 2040, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'COINS_ONLINE', 'Coins.ph Online', '', 'active', 1, 2050, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BPI_ONLINE', 'BPI Online', '', 'active', 1, 2060, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'RCBC_ONLINE', 'RCBC Online', '', 'active', 1, 2070, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'UCPB_ONLINE', 'UCPB Online', '', 'active', 1, 2080, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'ROBINSONSBANK_ONLINE', 'Robinsons Bank Online', '', 'active', 1, 2090, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'PSBANK_ONLINE', 'PSBank Online', '', 'active', 1, 2100, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'METROBANK_ONLINE', 'Metrobank Online', '', 'active', 1, 2110, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'LANDBANK_ONLINE', 'Landbank Online', '', 'active', 1, 2120, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'CHINABANK_ONLINE', 'China Bank Online', '', 'active', 1, 2130, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BDO_ONLINE', 'BDO Online', '', 'active', 1, 2140, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BOC_ONLINE', 'Bank of Commerce Online', '', 'active', 1, 2150, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'GRABPAY_ONLINE', 'GrabPay Online', '', 'active', 1, 2160, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'INSTAPAY_QR', 'InstaPay QR', '', 'active', 1, 2170, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'ALIPAY_QR', 'Alipay QR', '', 'active', 1, 2180, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'ALIPAY_PLUS', 'Alipay+', '', 'active', 1, 2190, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Billing', 'BILL', 'Bills Payment', '', 'active', 1, 2200, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH_GCASH', 'QRPh GCash', '', 'active', 1, 2210, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPAY', 'STC Pay', '', 'active', 1, 2310, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 2320, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'APPLEPAY', 'Apple Pay', '', 'active', 1, 2330, @now_ms, @now_ms),
('lalu', 'v5pay', 'EG', 'Egypt', 'EGP', 'Card', 'CARD', 'Egypt Card', '', 'active', 1, 2410, @now_ms, @now_ms),
('lalu', 'v5pay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CARD', 'UAE Card', '', 'active', 1, 2510, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI_VA', 'BNI Virtual Account', '', 'active', 1, 2610, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI_VA', 'BRI Virtual Account', '', 'active', 1, 2620, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'CIMB_VA', 'CIMB Virtual Account', '', 'active', 1, 2630, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'MANDIRI_VA', 'Mandiri Virtual Account', '', 'active', 1, 2640, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'MAYBANK_VA', 'Maybank Virtual Account', '', 'active', 1, 2650, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'QR', 'NOBU_BANK_QRIS', 'Nobu Bank QRIS', '', 'active', 1, 2660, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 2670, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'PERMATA_VA', 'Permata Virtual Account', '', 'active', 1, 2680, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'SHOPEEPAY_JUMPAPP', 'ShopeePay Jump App', '', 'active', 1, 2690, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'GOPAY', 'GoPay', '', 'active', 1, 2700, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ALIPAY_CN_ALIPAYPLUS', 'Alipay+ Indonesia', '', 'active', 1, 2710, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'BankTransfer', 'BANK_ONLINE', 'Bank Online', '', 'active', 1, 2810, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'UPI', 'UPI', 'UPI', '', 'active', 1, 2820, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'BankTransfer', 'ONLINE_BANKING', 'Online Banking', '', 'active', 1, 2830, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'EASYPAISA', 'Easypaisa', '', 'active', 1, 2910, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JAZZCASH', 'JazzCash', '', 'active', 1, 2920, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'BankTransfer', 'ALFA', 'Bank Alfalah', '', 'active', 1, 2930, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'HBL_KONNECT', 'HBL Konnect', '', 'active', 1, 2940, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Billing', 'BILL_PK', 'Pakistan Bill Payment', '', 'active', 1, 2950, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
currency_code = VALUES(currency_code),
method_name = VALUES(method_name),
sort_order = VALUES(sort_order),
usd_to_currency_rate = IF(usd_to_currency_rate <= 0, VALUES(usd_to_currency_rate), usd_to_currency_rate),
updated_at_ms = VALUES(updated_at_ms);
UPDATE invite_reward_policies
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'v5pay', 'usdt_trc20'),
updated_at_ms = @now_ms
WHERE JSON_CONTAINS(eligible_recharge_types, JSON_QUOTE('mifapay'))
AND NOT JSON_CONTAINS(eligible_recharge_types, JSON_QUOTE('v5pay'));

View File

@ -0,0 +1,2 @@
ALTER TABLE external_recharge_orders
ADD INDEX idx_external_recharge_orders_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms);

View File

@ -186,11 +186,19 @@ type HumanRoomRobotConfig struct {
LuckyPauseMinMS int64
LuckyPauseMaxMS int64
MaxGiftSenders int64
AllowedOwnerIDs []string
CountryLimitEnabled bool
CountryRules []HumanRoomRobotCountryRule
UpdatedByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64
}
type HumanRoomRobotCountryRule struct {
CountryCode string
MaxRoomCount int32
}
type UpdateHumanRoomRobotConfigRequest struct {
Config HumanRoomRobotConfig
AdminID uint64
@ -746,10 +754,18 @@ func humanRoomRobotConfigFromProto(input *roomv1.AdminHumanRoomRobotConfig) Huma
LuckyPauseMinMS: input.GetLuckyPauseMinMs(),
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
MaxGiftSenders: input.GetMaxGiftSenders(),
AllowedOwnerIDs: append([]string(nil), input.GetAllowedOwnerIds()...),
CountryLimitEnabled: input.GetCountryLimitEnabled(),
UpdatedByAdminID: input.GetUpdatedByAdminId(),
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: input.GetUpdatedAtMs(),
}
for _, rule := range input.GetCountryRules() {
config.CountryRules = append(config.CountryRules, HumanRoomRobotCountryRule{
CountryCode: rule.GetCountryCode(),
MaxRoomCount: rule.GetMaxRoomCount(),
})
}
return config
}
@ -776,10 +792,18 @@ func humanRoomRobotConfigToProto(input HumanRoomRobotConfig) *roomv1.AdminHumanR
LuckyPauseMinMs: input.LuckyPauseMinMS,
LuckyPauseMaxMs: input.LuckyPauseMaxMS,
MaxGiftSenders: input.MaxGiftSenders,
AllowedOwnerIds: append([]string(nil), input.AllowedOwnerIDs...),
CountryLimitEnabled: input.CountryLimitEnabled,
UpdatedByAdminId: input.UpdatedByAdminID,
CreatedAtMs: input.CreatedAtMS,
UpdatedAtMs: input.UpdatedAtMS,
}
for _, rule := range input.CountryRules {
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
})
}
return out
}

View File

@ -34,6 +34,7 @@ type Client interface {
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error)
ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error)
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
@ -158,6 +159,10 @@ func (c *GRPCClient) SetResourceShopItemStatus(ctx context.Context, req *walletv
return c.client.SetResourceShopItemStatus(ctx, req)
}
func (c *GRPCClient) ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error) {
return c.client.ListResourceShopPurchaseOrders(ctx, req)
}
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
return c.client.AdminCreditAsset(ctx, req)
}

View File

@ -35,6 +35,32 @@ func (h *Handler) GetConfig(c *gin.Context) {
response.OK(c, config)
}
func (h *Handler) ListRelationships(c *gin.Context) {
items, err := h.service.ListRelationships(c.Request.Context(), appctx.FromContext(c.Request.Context()), cpListOptionsFromContext(c))
if err != nil {
if errors.Is(err, ErrInvalidArgument) {
response.BadRequest(c, err.Error())
return
}
response.ServerError(c, "获取用户关系列表失败")
return
}
response.OK(c, items)
}
func (h *Handler) ListApplications(c *gin.Context) {
items, err := h.service.ListApplications(c.Request.Context(), appctx.FromContext(c.Request.Context()), cpListOptionsFromContext(c))
if err != nil {
if errors.Is(err, ErrInvalidArgument) {
response.BadRequest(c, err.Error())
return
}
response.ServerError(c, "获取CP申请列表失败")
return
}
response.OK(c, items)
}
func (h *Handler) UpdateConfig(c *gin.Context) {
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -57,3 +83,22 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
fmt.Sprintf("relations=%d", len(config.Relations)))
response.OK(c, config)
}
func cpListOptionsFromContext(c *gin.Context) cpListOptions {
options := shared.ListOptions(c)
return cpListOptions{
Page: options.Page,
PageSize: options.PageSize,
Status: options.Status,
RelationType: firstQuery(c, "relation_type", "relationType"),
}
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,354 @@
package cprelation
import (
"context"
"database/sql"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
const (
cpListDefaultPageSize = 50
cpListMaxPageSize = 100
dayMS = int64(24 * 60 * 60 * 1000)
)
var (
relationshipStatuses = []string{"active", "ended"}
applicationStatuses = []string{"pending", "accepted", "rejected", "expired", "blocked"}
)
type cpListOptions struct {
Page int
PageSize int
Status string
RelationType string
}
type cpUserDTO struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type cpGiftSnapshotDTO struct {
GiftID string `json:"giftId"`
GiftName string `json:"giftName"`
GiftIconURL string `json:"giftIconUrl"`
GiftAnimationURL string `json:"giftAnimationUrl"`
GiftCount int64 `json:"giftCount"`
GiftValue int64 `json:"giftValue"`
BillingReceiptID string `json:"billingReceiptId"`
}
type cpRelationshipDTO struct {
RelationshipID string `json:"relationshipId"`
RelationType string `json:"relationType"`
Status string `json:"status"`
UserA cpUserDTO `json:"userA"`
UserB cpUserDTO `json:"userB"`
IntimacyValue int64 `json:"intimacyValue"`
Level int32 `json:"level"`
DurationDays int64 `json:"durationDays"`
Gift *cpGiftSnapshotDTO `json:"gift,omitempty"`
FormedAtMS int64 `json:"formedAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
EndedAtMS int64 `json:"endedAtMs"`
}
type cpRelationshipPageDTO struct {
Items []cpRelationshipDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type cpApplicationDTO struct {
ApplicationID string `json:"applicationId"`
RelationType string `json:"relationType"`
Status string `json:"status"`
Requester cpUserDTO `json:"requester"`
Target cpUserDTO `json:"target"`
Gift cpGiftSnapshotDTO `json:"gift"`
RoomID string `json:"roomId"`
RoomRegionID int64 `json:"roomRegionId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
DecidedAtMS int64 `json:"decidedAtMs"`
}
type cpApplicationPageDTO struct {
Items []cpApplicationDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
// ListRelationships 给管理端按全站维度分页读取关系;这里不能复用 App 侧按单用户读取的仓储,否则会漏掉“所有用户关系”这个运营视角。
func (s *Service) ListRelationships(ctx context.Context, appCode string, options cpListOptions) (cpRelationshipPageDTO, error) {
options = normalizeCPListOptions(options)
nowMS := time.Now().UTC().UnixMilli()
whereSQL, args, err := relationshipListWhere(appCode, options)
if err != nil {
return cpRelationshipPageDTO{}, err
}
if s == nil || s.db == nil {
return cpRelationshipPageDTO{}, errorsUserDBNotConfigured()
}
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+whereSQL, args...)
if err != nil {
return cpRelationshipPageDTO{}, err
}
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
rows, err := s.db.QueryContext(ctx, `
SELECT
r.relationship_id, r.relation_type, r.status,
r.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
r.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''),
r.intimacy_value, r.level_no, r.formed_at_ms, r.updated_at_ms, r.ended_at_ms,
COALESCE(source.gift_id, ''), COALESCE(source.gift_name, ''), COALESCE(source.gift_icon_url, ''),
COALESCE(source.gift_animation_url, ''), COALESCE(source.gift_count, 0), COALESCE(source.gift_value, 0),
COALESCE(source.billing_receipt_id, '')
FROM user_cp_relationships r
LEFT JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id
LEFT JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id
LEFT JOIN user_cp_applications source ON source.app_code = r.app_code AND source.application_id = r.source_application_id
WHERE `+whereSQL+`
ORDER BY r.formed_at_ms DESC, r.relationship_id DESC
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return cpRelationshipPageDTO{}, err
}
defer rows.Close()
items, err := scanRelationshipRows(rows, nowMS)
if err != nil {
return cpRelationshipPageDTO{}, err
}
return cpRelationshipPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
}
// ListApplications 读取所有用户的申请记录;读取前先沿用 App 侧懒过期语义,避免后台把已过期申请继续展示为 pending。
func (s *Service) ListApplications(ctx context.Context, appCode string, options cpListOptions) (cpApplicationPageDTO, error) {
options = normalizeCPListOptions(options)
nowMS := time.Now().UTC().UnixMilli()
whereSQL, args, err := applicationListWhere(appCode, options)
if err != nil {
return cpApplicationPageDTO{}, err
}
if s == nil || s.db == nil {
return cpApplicationPageDTO{}, errorsUserDBNotConfigured()
}
if err := s.expirePendingApplications(ctx, appCode, nowMS); err != nil {
return cpApplicationPageDTO{}, err
}
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+whereSQL, args...)
if err != nil {
return cpApplicationPageDTO{}, err
}
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
rows, err := s.db.QueryContext(ctx, `
SELECT
a.application_id, a.relation_type, a.status,
a.requester_user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''),
a.target_user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''),
a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url, a.gift_count, a.gift_value, a.billing_receipt_id,
a.room_id, a.room_region_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms
FROM user_cp_applications a
LEFT JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id
LEFT JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id
WHERE `+whereSQL+`
ORDER BY a.created_at_ms DESC, a.application_id DESC
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return cpApplicationPageDTO{}, err
}
defer rows.Close()
items, err := scanApplicationRows(rows)
if err != nil {
return cpApplicationPageDTO{}, err
}
return cpApplicationPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
}
func (s *Service) expirePendingApplications(ctx context.Context, appCode string, nowMS int64) error {
_, err := s.db.ExecContext(ctx, `
UPDATE user_cp_applications
SET status = 'expired', updated_at_ms = ?
WHERE app_code = ? AND status = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?`,
nowMS, appCode, nowMS,
)
return err
}
func scanRelationshipRows(rows *sql.Rows, nowMS int64) ([]cpRelationshipDTO, error) {
items := []cpRelationshipDTO{}
for rows.Next() {
var item cpRelationshipDTO
var gift cpGiftSnapshotDTO
if err := rows.Scan(
&item.RelationshipID, &item.RelationType, &item.Status,
&item.UserA.UserID, &item.UserA.DisplayUserID, &item.UserA.Username, &item.UserA.Avatar,
&item.UserB.UserID, &item.UserB.DisplayUserID, &item.UserB.Username, &item.UserB.Avatar,
&item.IntimacyValue, &item.Level, &item.FormedAtMS, &item.UpdatedAtMS, &item.EndedAtMS,
&gift.GiftID, &gift.GiftName, &gift.GiftIconURL, &gift.GiftAnimationURL, &gift.GiftCount, &gift.GiftValue, &gift.BillingReceiptID,
); err != nil {
return nil, err
}
item.UserA = normalizeCPUser(item.UserA)
item.UserB = normalizeCPUser(item.UserB)
item.DurationDays = relationshipDurationDays(item.FormedAtMS, item.EndedAtMS, nowMS)
if !gift.empty() {
item.Gift = &gift
}
items = append(items, item)
}
return items, rows.Err()
}
func scanApplicationRows(rows *sql.Rows) ([]cpApplicationDTO, error) {
items := []cpApplicationDTO{}
for rows.Next() {
var item cpApplicationDTO
if err := rows.Scan(
&item.ApplicationID, &item.RelationType, &item.Status,
&item.Requester.UserID, &item.Requester.DisplayUserID, &item.Requester.Username, &item.Requester.Avatar,
&item.Target.UserID, &item.Target.DisplayUserID, &item.Target.Username, &item.Target.Avatar,
&item.Gift.GiftID, &item.Gift.GiftName, &item.Gift.GiftIconURL, &item.Gift.GiftAnimationURL, &item.Gift.GiftCount, &item.Gift.GiftValue, &item.Gift.BillingReceiptID,
&item.RoomID, &item.RoomRegionID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.ExpiresAtMS, &item.DecidedAtMS,
); err != nil {
return nil, err
}
item.Requester = normalizeCPUser(item.Requester)
item.Target = normalizeCPUser(item.Target)
items = append(items, item)
}
return items, rows.Err()
}
func relationshipListWhere(appCode string, options cpListOptions) (string, []any, error) {
where := []string{"r.app_code = ?"}
args := []any{appCode}
if err := appendRelationFilter(&where, &args, "r", options.RelationType); err != nil {
return "", nil, err
}
if err := appendStatusFilter(&where, &args, "r", options.Status, relationshipStatuses, "关系状态不正确"); err != nil {
return "", nil, err
}
return strings.Join(where, " AND "), args, nil
}
func applicationListWhere(appCode string, options cpListOptions) (string, []any, error) {
where := []string{"a.app_code = ?"}
args := []any{appCode}
if err := appendRelationFilter(&where, &args, "a", options.RelationType); err != nil {
return "", nil, err
}
if err := appendStatusFilter(&where, &args, "a", options.Status, applicationStatuses, "申请状态不正确"); err != nil {
return "", nil, err
}
return strings.Join(where, " AND "), args, nil
}
func appendRelationFilter(where *[]string, args *[]any, alias string, value string) error {
relationType := normalizeCPFilterValue(value)
if relationType == "" {
return nil
}
if !slices.Contains(relationTypes, relationType) {
return fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument)
}
*where = append(*where, alias+".relation_type = ?")
*args = append(*args, relationType)
return nil
}
func appendStatusFilter(where *[]string, args *[]any, alias string, value string, allowed []string, message string) error {
status := normalizeCPFilterValue(value)
if status == "" {
return nil
}
if !slices.Contains(allowed, status) {
return fmt.Errorf("%w: %s", ErrInvalidArgument, message)
}
*where = append(*where, alias+".status = ?")
*args = append(*args, status)
return nil
}
func normalizeCPListOptions(options cpListOptions) cpListOptions {
if options.Page < 1 {
options.Page = 1
}
if options.PageSize < 1 {
options.PageSize = cpListDefaultPageSize
}
if options.PageSize > cpListMaxPageSize {
options.PageSize = cpListMaxPageSize
}
options.Status = normalizeCPFilterValue(options.Status)
options.RelationType = normalizeCPFilterValue(options.RelationType)
return options
}
func normalizeCPFilterValue(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "all" {
return ""
}
return value
}
func cpListOffset(options cpListOptions) int {
return (options.Page - 1) * options.PageSize
}
func (s *Service) countRows(ctx context.Context, query string, args ...any) (int64, error) {
var total int64
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func normalizeCPUser(user cpUserDTO) cpUserDTO {
if strings.TrimSpace(user.DisplayUserID) == "" && user.UserID > 0 {
user.DisplayUserID = strconv.FormatInt(user.UserID, 10)
}
return user
}
func relationshipDurationDays(formedAtMS int64, endedAtMS int64, nowMS int64) int64 {
if formedAtMS <= 0 {
return 0
}
endAtMS := nowMS
if endedAtMS > 0 {
endAtMS = endedAtMS
}
if endAtMS < formedAtMS {
return 0
}
// 运营侧“天数”按自然关系天展示:刚建立也算第 1 天,避免表格出现 0 天这种难以理解的状态。
return ((endAtMS - formedAtMS) / dayMS) + 1
}
func (gift cpGiftSnapshotDTO) empty() bool {
return strings.TrimSpace(gift.GiftID) == "" &&
strings.TrimSpace(gift.GiftName) == "" &&
strings.TrimSpace(gift.GiftIconURL) == "" &&
strings.TrimSpace(gift.GiftAnimationURL) == "" &&
strings.TrimSpace(gift.BillingReceiptID) == "" &&
gift.GiftCount == 0 &&
gift.GiftValue == 0
}
func errorsUserDBNotConfigured() error {
return fmt.Errorf("user db is not configured")
}

View File

@ -11,5 +11,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
return
}
protected.GET("/admin/activity/cp/config", middleware.RequirePermission("cp-config:view"), h.GetConfig)
protected.GET("/admin/activity/cp/relationships", middleware.RequirePermission("cp-config:view"), h.ListRelationships)
protected.GET("/admin/activity/cp/applications", middleware.RequirePermission("cp-config:view"), h.ListApplications)
protected.PUT("/admin/activity/cp/config", middleware.RequirePermission("cp-config:update"), h.UpdateConfig)
}

View File

@ -2,6 +2,7 @@ package cprelation
import (
"errors"
"strings"
"testing"
)
@ -44,6 +45,92 @@ func TestNormalizeRelationsRequiresIncreasingLevelThresholds(t *testing.T) {
}
}
func TestNormalizeCPListOptionsBoundsPageSize(t *testing.T) {
options := normalizeCPListOptions(cpListOptions{Page: -3, PageSize: 1000, Status: "ALL", RelationType: " Brother "})
if options.Page != 1 {
t.Fatalf("page = %d, want 1", options.Page)
}
if options.PageSize != cpListMaxPageSize {
t.Fatalf("page size = %d, want max %d", options.PageSize, cpListMaxPageSize)
}
if options.Status != "" {
t.Fatalf("status = %q, want empty all filter", options.Status)
}
if options.RelationType != relationTypeBrother {
t.Fatalf("relation type = %q, want brother", options.RelationType)
}
}
func TestRelationshipListWhereValidatesFilters(t *testing.T) {
whereSQL, args, err := relationshipListWhere("lalu", cpListOptions{RelationType: relationTypeSister, Status: "active"})
if err != nil {
t.Fatalf("relationshipListWhere() error = %v", err)
}
if !strings.Contains(whereSQL, "r.app_code = ?") ||
!strings.Contains(whereSQL, "r.relation_type = ?") ||
!strings.Contains(whereSQL, "r.status = ?") {
t.Fatalf("relationship where sql missing filters: %s", whereSQL)
}
wantArgs := []any{"lalu", relationTypeSister, statusActive}
if len(args) != len(wantArgs) {
t.Fatalf("args len = %d, want %d: %#v", len(args), len(wantArgs), args)
}
for index := range wantArgs {
if args[index] != wantArgs[index] {
t.Fatalf("args[%d] = %#v, want %#v", index, args[index], wantArgs[index])
}
}
if _, _, err := relationshipListWhere("lalu", cpListOptions{RelationType: "enemy"}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid relation type error = %v, want ErrInvalidArgument", err)
}
if _, _, err := relationshipListWhere("lalu", cpListOptions{Status: "pending"}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid relationship status error = %v, want ErrInvalidArgument", err)
}
}
func TestApplicationListWhereValidatesApplicationStatuses(t *testing.T) {
whereSQL, args, err := applicationListWhere("lalu", cpListOptions{RelationType: relationTypeCP, Status: "pending"})
if err != nil {
t.Fatalf("applicationListWhere() error = %v", err)
}
if !strings.Contains(whereSQL, "a.relation_type = ?") || !strings.Contains(whereSQL, "a.status = ?") {
t.Fatalf("application where sql missing filters: %s", whereSQL)
}
if len(args) != 3 || args[0] != "lalu" || args[1] != relationTypeCP || args[2] != "pending" {
t.Fatalf("application args mismatch: %#v", args)
}
if _, _, err := applicationListWhere("lalu", cpListOptions{Status: "active"}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid application status error = %v, want ErrInvalidArgument", err)
}
}
func TestRelationshipDurationDaysIsInclusive(t *testing.T) {
const formedAtMS int64 = 1700000000000
if got := relationshipDurationDays(formedAtMS, 0, formedAtMS); got != 1 {
t.Fatalf("same moment duration = %d, want 1", got)
}
if got := relationshipDurationDays(formedAtMS, 0, formedAtMS+dayMS*2+1); got != 3 {
t.Fatalf("third day duration = %d, want 3", got)
}
if got := relationshipDurationDays(formedAtMS, formedAtMS+dayMS, formedAtMS+dayMS*9); got != 2 {
t.Fatalf("ended relationship duration = %d, want 2", got)
}
if got := relationshipDurationDays(formedAtMS, formedAtMS-1, formedAtMS+dayMS); got != 0 {
t.Fatalf("invalid ended duration = %d, want 0", got)
}
}
func TestCPGiftSnapshotEmpty(t *testing.T) {
if !((&cpGiftSnapshotDTO{}).empty()) {
t.Fatalf("zero gift should be empty")
}
gift := cpGiftSnapshotDTO{GiftValue: 100}
if gift.empty() {
t.Fatalf("gift with value should not be empty")
}
}
func relationInput(relationType string, maxCount int32) relationDTO {
return relationDTO{
RelationType: relationType,

View File

@ -18,6 +18,11 @@ const (
robotMaxDisplayLevel = 30
)
var excludedRobotAvatarFrameResourceIDs = map[int64]struct{}{
165: {},
175: {},
}
type robotAppearanceCatalog struct {
avatarFrames []*walletv1.Resource
vehicles []*walletv1.Resource
@ -102,13 +107,27 @@ func (h *Handler) listRobotResources(ctx context.Context, requestID string, appC
}
resources := make([]*walletv1.Resource, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
if item != nil && item.GetResourceId() > 0 && item.GetStatus() == "active" {
resources = append(resources, item)
if item == nil || item.GetResourceId() <= 0 || item.GetStatus() != "active" {
continue
}
if isExcludedRobotAppearanceResource(resourceType, item.GetResourceId()) {
// 机器人装扮随机池只过滤明确不适合机器人批量露出的资源;资源仍保留在 wallet owner 中,
// 真人发放、商城和其他后台场景不受这个创建机器人专用策略影响。
continue
}
resources = append(resources, item)
}
return resources, nil
}
func isExcludedRobotAppearanceResource(resourceType string, resourceID int64) bool {
if resourceType != "avatar_frame" {
return false
}
_, excluded := excludedRobotAvatarFrameResourceIDs[resourceID]
return excluded
}
func (h *Handler) grantAndEquipRobotResource(ctx context.Context, requestID string, appCode string, actorUserID int64, userID int64, slot string, resourceID int64, equip bool) error {
grant, err := h.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: fmt.Sprintf("game_robot_init:%s:%d:%s:%d", requestID, userID, slot, resourceID),

View File

@ -66,7 +66,7 @@ func (c *fakeRobotGameClient) RegisterDiceRobots(_ context.Context, req *gamev1.
type fakeRobotWalletClient struct {
walletclient.Client
listRequests []*walletv1.ListResourcesRequest
grants []string
grants []int64
equips []int64
}
@ -74,7 +74,11 @@ func (c *fakeRobotWalletClient) ListResources(_ context.Context, req *walletv1.L
c.listRequests = append(c.listRequests, req)
switch req.GetResourceType() {
case "avatar_frame":
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 101, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false}}}, nil
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{
{ResourceId: 165, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false},
{ResourceId: 175, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false},
{ResourceId: 101, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false},
}}, nil
case "vehicle":
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 201, ResourceType: "vehicle", Status: "active", ManagerGrantEnabled: false}}}, nil
case "badge":
@ -88,7 +92,7 @@ func (c *fakeRobotWalletClient) ListResources(_ context.Context, req *walletv1.L
}
func (c *fakeRobotWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) {
c.grants = append(c.grants, req.GetCommandId())
c.grants = append(c.grants, req.GetResourceId())
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{Items: []*walletv1.ResourceGrantItem{{
ResourceId: req.GetResourceId(),
EntitlementId: "entitlement",
@ -173,6 +177,12 @@ func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) {
t.Fatalf("robot level out of range: %+v", level)
}
}
for _, resourceID := range append(wallets.grants, wallets.equips...) {
// 165 和 175 是资源库里的有效头像框,但创建机器人不能随机抽中,避免批量机器人展示这两款运营保留资源。
if resourceID == 165 || resourceID == 175 {
t.Fatalf("robot appearance must exclude avatar frame %d from random grants/equips", resourceID)
}
}
}
func TestGenerateDiceRobotsFallsBackToRandomProfilesWhenLikeiMissing(t *testing.T) {

View File

@ -92,6 +92,26 @@ func (h *Handler) CreateManager(c *gin.Context) {
response.Created(c, item)
}
func (h *Handler) UpdateManager(c *gin.Context) {
userID, ok := parseInt64ID(c, "user_id")
if !ok {
return
}
var req updateManagerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "经理参数不正确")
return
}
item, err := h.service.UpdateManager(c.Request.Context(), adminActorID(c), userID, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "update-manager", "manager_profiles", item.UserID,
fmt.Sprintf("user_id=%d permissions_updated=true", item.UserID))
response.OK(c, item)
}
func (h *Handler) ListHosts(c *gin.Context) {
query, ok := parseListQuery(c)
if !ok {

View File

@ -43,18 +43,25 @@ type CoinSellerListItem struct {
}
type ManagerListItem struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
Contact string `json:"contact"`
BDLeaderCount int64 `json:"bdLeaderCount"`
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
Contact string `json:"contact"`
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddAdmin bool `json:"canAddAdmin"`
CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanBlockUser bool `json:"canBlockUser"`
BDLeaderCount int64 `json:"bdLeaderCount"`
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type CoinSellerSalaryRateTier struct {
@ -575,30 +582,42 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.
// CreateManagerProfile 创建或恢复经理身份。
// Manager 是 manager_profiles 表里的独立身份;这里只写经理事实和联系方式,不改 Host/Agency/BD 任一关系。
func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, contact string) (*ManagerListItem, error) {
func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, req createManagerRequest) (*ManagerListItem, error) {
if r == nil || r.db == nil {
return nil, errUserDBNotConfigured()
}
if userID <= 0 {
return nil, fmt.Errorf("target_user_id is required")
}
contact = strings.TrimSpace(contact)
contact := strings.TrimSpace(req.Contact)
if len([]rune(contact)) > 128 {
return nil, fmt.Errorf("contact is too long")
}
permissions := req.permissionsWithDefault()
nowMs := time.Now().UnixMilli()
appCode := appctx.FromContext(ctx)
// ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态,
// 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。
if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?)
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = 'active',
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, contact, actorID, nowMs, nowMs); err != nil {
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, actorID, nowMs, nowMs); err != nil {
return nil, err
}
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
@ -611,6 +630,54 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
return items[0], nil
}
// UpdateManagerProfile 局部更新经理运营资料和经理中心权限。
// 未传入的布尔权限不参与 SET避免后台只编辑联系方式时把现有权限误恢复成默认值。
func (r *Reader) UpdateManagerProfile(ctx context.Context, userID int64, actorID int64, req updateManagerRequest) (*ManagerListItem, error) {
if r == nil || r.db == nil {
return nil, errUserDBNotConfigured()
}
if userID <= 0 {
return nil, fmt.Errorf("manager user_id is required")
}
assignments := []string{"updated_at_ms = ?"}
args := []any{time.Now().UnixMilli()}
if req.Contact != nil {
contact := strings.TrimSpace(*req.Contact)
if len([]rune(contact)) > 128 {
return nil, fmt.Errorf("contact is too long")
}
assignments = append(assignments, "contact_info = ?")
args = append(args, contact)
}
req.appendPermissionAssignments(&assignments, &args)
args = append(args, appctx.FromContext(ctx), userID)
// 权限更新只作用于已存在经理RowsAffected=0 时返回 not found避免 PUT 顺手创建新经理绕过创建审计。
result, err := r.db.ExecContext(ctx, `
UPDATE manager_profiles
SET `+strings.Join(assignments, ", ")+`
WHERE app_code = ? AND user_id = ?
`, args...)
if err != nil {
return nil, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, fmt.Errorf("manager not found")
}
_ = actorID
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("manager not found")
}
return items[0], nil
}
// ListManagers 按 manager_profiles 的独立身份事实读取经理列表。
// 经理是 BD Leader/Admin 的上级,不属于 Host/Agency/BD 链路;下级 BD Leader 只作为统计信息左连汇总。
func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) {
@ -648,11 +715,17 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
mp.status, COALESCE(mp.contact_info, ''), COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.status, COALESCE(mp.contact_info, ''),
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user,
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.created_at_ms, mp.updated_at_ms
%s
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
manager_region.region_id, manager_region.name, mp.status, mp.contact_info, mp.created_at_ms, mp.updated_at_ms
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user,
mp.created_at_ms, mp.updated_at_ms
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
@ -673,6 +746,13 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.RegionName,
&item.Status,
&item.Contact,
&item.CanGrantAvatarFrame,
&item.CanGrantVehicle,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddAdmin,
&item.CanAddSuperadmin,
&item.CanBlockUser,
&item.BDLeaderCount,
&item.LastInvitedAtMs,
&item.CreatedAtMs,

View File

@ -61,8 +61,70 @@ type createBDLeaderRequest struct {
}
type createManagerRequest struct {
Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
}
type updateManagerRequest struct {
Contact *string `json:"contact"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
}
type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
}
func (r createManagerRequest) permissionsWithDefault() managerPermissions {
return managerPermissions{
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
}
}
func (r updateManagerRequest) appendPermissionAssignments(assignments *[]string, args *[]any) {
appendBoolAssignment(assignments, args, "can_grant_avatar_frame", r.CanGrantAvatarFrame)
appendBoolAssignment(assignments, args, "can_grant_vehicle", r.CanGrantVehicle)
appendBoolAssignment(assignments, args, "can_update_user_level", r.CanUpdateUserLevel)
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
appendBoolAssignment(assignments, args, "can_add_superadmin", r.CanAddSuperadmin)
appendBoolAssignment(assignments, args, "can_block_user", r.CanBlockUser)
}
func appendBoolAssignment(assignments *[]string, args *[]any, column string, value *bool) {
if value == nil {
return
}
*assignments = append(*assignments, column+" = ?")
*args = append(*args, *value)
}
func boolDefaultTrue(value *bool) bool {
return value == nil || *value
}
type createBDRequest struct {

View File

@ -17,6 +17,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus)
protected.GET("/admin/managers", middleware.RequirePermission("bd:view"), h.ListManagers)
protected.POST("/admin/managers", middleware.RequirePermission("bd:create"), h.CreateManager)
protected.PUT("/admin/managers/:user_id", middleware.RequirePermission("bd:update"), h.UpdateManager)
protected.GET("/admin/agencies", middleware.RequirePermission("agency:view"), h.ListAgencies)
protected.POST("/admin/agencies", middleware.RequirePermission("agency:create"), h.CreateAgency)
protected.POST("/admin/agencies/:agency_id/close", middleware.RequirePermission("agency:status"), h.CloseAgency)

View File

@ -60,8 +60,12 @@ func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID st
if err != nil {
return nil, err
}
// 经理身份只落 manager_profiles 表;联系方式作为后台运营资料保存,不参与经理中心授权。
return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req.Contact)
// 经理身份只落 manager_profiles 表;权限开关同时写入 manager_profiles供 H5 写入口逐项校验。
return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req)
}
func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64, req updateManagerRequest) (*ManagerListItem, error) {
return s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
}
func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) {

View File

@ -26,6 +26,8 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
Username: "Kitty",
Status: "active",
Contact: "+63",
CanGrantVehicle: true,
CanBlockUser: false,
BDLeaderCount: 3,
LastInvitedAtMs: 1720000000000,
}
@ -38,6 +40,8 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
`"userId":"316033326332776448"`,
`"displayUserId":"165549"`,
`"contact":"+63"`,
`"canGrantVehicle":true`,
`"canBlockUser":false`,
`"bdLeaderCount":3`,
`"lastInvitedAtMs":1720000000000`,
} {

View File

@ -38,10 +38,30 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}, {
AppCode: "lalu",
ProviderCode: "v5pay",
ProviderName: "V5Pay",
Status: "active",
SortOrder: 20,
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 2310,
AppCode: "lalu",
ProviderCode: "v5pay",
ProviderName: "V5Pay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Ewallet",
PayType: "STCPAY",
MethodName: "STC Pay",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?provider_code=mifapay&status=active", nil)
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?status=active", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -51,7 +71,7 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
}
if wallet.lastThirdPartyChannels == nil ||
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "mifapay" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "" ||
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
@ -62,8 +82,10 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
}
items := response.Data["items"].([]any)
channel := items[0].(map[string]any)
v5Channel := items[1].(map[string]any)
methods := channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || len(methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" {
v5Methods := v5Channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || v5Channel["providerCode"] != "v5pay" || len(methods) != 1 || len(v5Methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" || v5Methods[0].(map[string]any)["payType"] != "STCPAY" {
t.Fatalf("third party channels response mismatch: %+v", response)
}
}
@ -119,6 +141,14 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
{MethodId: 810, CurrencyCode: "SAR", Status: "active"},
{MethodId: 811, CurrencyCode: "BHD", Status: "disabled"},
},
}, {
AppCode: "lalu",
ProviderCode: "v5pay",
ProviderName: "V5Pay",
Status: "active",
Methods: []*walletv1.ThirdPartyPaymentMethod{
{MethodId: 2310, CurrencyCode: "SAR", Status: "active"},
},
}},
}}
handler := New(wallet, nil, nil)
@ -138,7 +168,7 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("sync must read active and disabled methods: %+v", wallet.lastThirdPartyChannels)
}
if len(wallet.updateRateRequests) != 2 {
if len(wallet.updateRateRequests) != 3 {
t.Fatalf("sync should update every method with a fetched currency rate: %+v", wallet.updateRateRequests)
}
got := map[int64]string{}
@ -148,14 +178,14 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
}
got[req.GetMethodId()] = req.GetUsdToCurrencyRate()
}
if got[810] != "3.75000000" || got[811] != "0.37600000" {
if got[810] != "3.75000000" || got[811] != "0.37600000" || got[2310] != "3.75000000" {
t.Fatalf("synced rates mismatch: %+v", got)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != 0 || response.Data["updatedCount"].(float64) != 2 {
if response.Code != 0 || response.Data["updatedCount"].(float64) != 3 {
t.Fatalf("sync response mismatch: %+v", response)
}
}

View File

@ -145,10 +145,13 @@ type grantDTO struct {
}
type grantUserDTO struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId,omitempty"`
DefaultDisplayUserID string `json:"defaultDisplayUserId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
PrettyID string `json:"prettyId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type operatorDTO struct {
@ -178,6 +181,25 @@ type resourceShopItemDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type resourceShopPurchaseOrderDTO struct {
AppCode string `json:"appCode"`
OrderID string `json:"orderId"`
CommandID string `json:"commandId"`
UserID int64 `json:"userId,string"`
User *grantUserDTO `json:"user,omitempty"`
ShopItemID int64 `json:"shopItemId"`
ResourceID int64 `json:"resourceId"`
Resource resourceDTO `json:"resource"`
DurationDays int32 `json:"durationDays"`
PriceCoin int64 `json:"priceCoin"`
Status string `json:"status"`
WalletTransactionID string `json:"walletTransactionId"`
ResourceGrantID string `json:"resourceGrantId"`
EntitlementID string `json:"entitlementId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func resourceFromProto(item *walletv1.Resource) resourceDTO {
if item == nil {
return resourceDTO{}
@ -279,6 +301,29 @@ func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItem
}
}
func resourceShopPurchaseOrderFromProto(item *walletv1.ResourceShopPurchaseOrder) resourceShopPurchaseOrderDTO {
if item == nil {
return resourceShopPurchaseOrderDTO{}
}
return resourceShopPurchaseOrderDTO{
AppCode: item.GetAppCode(),
OrderID: item.GetOrderId(),
CommandID: item.GetCommandId(),
UserID: item.GetUserId(),
ShopItemID: item.GetShopItemId(),
ResourceID: item.GetResourceId(),
Resource: resourceFromProto(item.GetResource()),
DurationDays: item.GetDurationDays(),
PriceCoin: item.GetPriceCoin(),
Status: item.GetStatus(),
WalletTransactionID: item.GetWalletTransactionId(),
ResourceGrantID: item.GetResourceGrantId(),
EntitlementID: item.GetEntitlementId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
if gift == nil {
return giftDTO{}

View File

@ -695,6 +695,55 @@ func (h *Handler) ListResourceShopItems(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ListResourceShopPurchaseOrders(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
userID, ok := optionalInt64Query(c, "user_id", "userId")
if !ok {
return
}
userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user"))
if userID == 0 && userKeyword != "" {
resolvedUserID, filtered, err := h.resolveResourceShopPurchaseUserID(c.Request.Context(), appCode, userKeyword)
if err != nil {
if err == sql.ErrNoRows {
response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
response.ServerError(c, "查询购买用户失败")
return
}
if filtered {
userID = resolvedUserID
}
}
ctx, cancel := h.walletRequestContext(c)
defer cancel()
resp, err := h.wallet.ListResourceShopPurchaseOrders(ctx, &walletv1.ListResourceShopPurchaseOrdersRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
ResourceType: strings.TrimSpace(c.Query("resource_type")),
Status: options.Status,
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取售卖记录失败")
return
}
items := make([]resourceShopPurchaseOrderDTO, 0, len(resp.GetOrders()))
for _, item := range resp.GetOrders() {
items = append(items, resourceShopPurchaseOrderFromProto(item))
}
if err := h.enrichResourceShopPurchaseUsers(c.Request.Context(), items); err != nil {
response.ServerError(c, "获取购买用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) UpsertResourceShopItems(c *gin.Context) {
var req resourceShopItemsRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -2,10 +2,13 @@ package resource
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
)
const (
@ -48,10 +51,36 @@ func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[i
if len(ids) == 0 || h.userDB == nil {
return out, nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
nowMs := time.Now().UnixMilli()
args := append([]any{nowMs, nowMs, appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id,
current_display_user_id,
COALESCE(default_display_user_id, ''),
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pdi
ON pdi.app_code = lease.app_code
AND pdi.assigned_lease_id = lease.lease_id
AND pdi.status = 'assigned'
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE(username, ''),
COALESCE(avatar, '')
FROM users
@ -64,7 +93,7 @@ func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[i
for rows.Next() {
var user grantUserDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar); err != nil {
return nil, err
}
out[user.UserID] = user
@ -86,6 +115,79 @@ func applyGrantTargetUsers(grants []grantDTO, users map[int64]grantUserDTO) {
}
}
func (h *Handler) enrichResourceShopPurchaseUsers(ctx context.Context, orders []resourceShopPurchaseOrderDTO) error {
ids := collectResourceShopPurchaseUserIDs(orders)
if len(ids) == 0 {
return nil
}
users := map[int64]grantUserDTO{}
if h.userDB != nil {
var err error
users, err = h.queryGrantTargetUsers(ctx, ids)
if err != nil {
return err
}
}
for index := range orders {
userID := orders[index].UserID
if userID <= 0 {
continue
}
if user, ok := users[userID]; ok {
orders[index].User = &user
continue
}
orders[index].User = &grantUserDTO{UserID: userID}
}
return nil
}
func collectResourceShopPurchaseUserIDs(orders []resourceShopPurchaseOrderDTO) []int64 {
seen := make(map[int64]struct{}, len(orders))
ids := make([]int64, 0, len(orders))
for _, order := range orders {
if order.UserID <= 0 {
continue
}
if _, exists := seen[order.UserID]; exists {
continue
}
seen[order.UserID] = struct{}{}
ids = append(ids, order.UserID)
}
return ids
}
func (h *Handler) resolveResourceShopPurchaseUserID(ctx context.Context, appCode string, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return 0, false, nil
}
if h == nil || h.userDB == nil {
return 0, false, fmt.Errorf("user mysql is not configured")
}
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
row := h.userDB.QueryRowContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
args...,
)
var userID int64
if err := row.Scan(&userID); err != nil {
return 0, true, err
}
return userID, true, nil
}
func (h *Handler) enrichGrantOperators(ctx context.Context, grants []grantDTO) error {
// operator_user_id 在 wallet 发放事实里只保存稳定 ID后台列表按来源补展示资料避免把 admin 用户和 app 用户混成同一张表。
adminIDs, managerIDs := collectGrantOperatorIDs(grants)

View File

@ -46,6 +46,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), h.LookupResourceGrantTarget)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
protected.GET("/admin/resource-shop/purchase-orders", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopPurchaseOrders)
protected.GET("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopItems)
protected.POST("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:update"), h.UpsertResourceShopItems)
protected.POST("/admin/resource-shop/items/:shop_item_id/enable", middleware.RequirePermission("resource-shop:update"), h.EnableResourceShopItem)

View File

@ -1,6 +1,7 @@
package roomadmin
import (
"encoding/json"
"errors"
"reflect"
"testing"
@ -46,3 +47,37 @@ func TestNormalizeRoomConfigRejectsInvalidValues(t *testing.T) {
})
}
}
func TestHumanRoomRobotAllowedOwnerIDsAcceptsCommaStringAndArray(t *testing.T) {
for _, test := range []struct {
name string
body string
want []string
}{
{name: "comma_string", body: `{"allowedOwnerIds":"1001, 1002,,1001"}`, want: []string{"1001", "1002"}},
{name: "array", body: `{"allowedOwnerIds":["1003"," 1004 ","1003"]}`, want: []string{"1003", "1004"}},
} {
t.Run(test.name, func(t *testing.T) {
var req updateHumanRoomRobotConfigRequest
if err := json.Unmarshal([]byte(test.body), &req); err != nil {
t.Fatalf("unmarshal human room robot config failed: %v", err)
}
if got := normalizeStringList([]string(req.AllowedOwnerIDs)); !reflect.DeepEqual(got, test.want) {
t.Fatalf("allowed owner ids mismatch: got %+v want %+v", got, test.want)
}
})
}
}
func TestHumanRoomRobotCountryRulesDecode(t *testing.T) {
var req updateHumanRoomRobotConfigRequest
if err := json.Unmarshal([]byte(`{"countryLimitEnabled":true,"countryRules":[{"countryCode":"sa","maxRoomCount":2}]}`), &req); err != nil {
t.Fatalf("unmarshal human room robot country rules failed: %v", err)
}
if !req.CountryLimitEnabled || len(req.CountryRules) != 1 {
t.Fatalf("country rules not decoded: %+v", req)
}
if req.CountryRules[0].CountryCode != "sa" || req.CountryRules[0].MaxRoomCount != 2 {
t.Fatalf("country rule mismatch: %+v", req.CountryRules[0])
}
}

View File

@ -139,26 +139,29 @@ type updateRoomWhitelistRequest struct {
}
type updateHumanRoomRobotConfigRequest struct {
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
AllowedOwnerIDs flexibleCommaStringList `json:"allowedOwnerIds"`
CountryLimitEnabled bool `json:"countryLimitEnabled"`
CountryRules []HumanRoomRobotCountryRule `json:"countryRules"`
}
type flexibleJSONInt64StringSlice []string
@ -182,3 +185,40 @@ func (values *flexibleJSONInt64StringSlice) UnmarshalJSON(data []byte) error {
*values = out
return nil
}
type flexibleCommaStringList []string
func (values *flexibleCommaStringList) UnmarshalJSON(data []byte) error {
// 真人房机器人后台是人工输入框场景,兼容 "1001,1002" 和 ["1001","1002"] 两种提交,统一在 service 层去空去重。
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*values = nil
return nil
}
if strings.HasPrefix(raw, "\"") {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
*values = splitCommaStringList(text)
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err != nil {
return err
}
*values = splitCommaStringList(strings.Join(list, ","))
return nil
}
func splitCommaStringList(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}

View File

@ -59,30 +59,38 @@ type AvailableRoomRobot struct {
}
type HumanRoomRobotConfig struct {
AppCode string `json:"appCode"`
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
UpdatedByAdminID uint64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
AppCode string `json:"appCode"`
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
AllowedOwnerIDs []string `json:"allowedOwnerIds"`
CountryLimitEnabled bool `json:"countryLimitEnabled"`
CountryRules []HumanRoomRobotCountryRule `json:"countryRules"`
UpdatedByAdminID uint64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type HumanRoomRobotCountryRule struct {
CountryCode string `json:"countryCode"`
MaxRoomCount int32 `json:"maxRoomCount"`
}
func (s *Service) ListRobotRooms(ctx context.Context, query robotRoomListQuery) ([]RobotRoom, int64, error) {
@ -213,6 +221,9 @@ func (s *Service) UpdateHumanRoomRobotConfig(ctx context.Context, req updateHuma
LuckyPauseMinMS: req.LuckyPauseMinMS,
LuckyPauseMaxMS: req.LuckyPauseMaxMS,
MaxGiftSenders: req.MaxGiftSenders,
AllowedOwnerIDs: normalizeStringList([]string(req.AllowedOwnerIDs)),
CountryLimitEnabled: req.CountryLimitEnabled,
CountryRules: humanRoomRobotCountryRulesToClient(req.CountryRules),
}
saved, err := s.roomClient.UpdateHumanRoomRobotConfig(ctx, roomclient.UpdateHumanRoomRobotConfigRequest{
Config: config,
@ -283,6 +294,9 @@ func (s *Service) humanRoomRobotConfigFromClient(_ context.Context, config roomc
LuckyPauseMinMS: config.LuckyPauseMinMS,
LuckyPauseMaxMS: config.LuckyPauseMaxMS,
MaxGiftSenders: config.MaxGiftSenders,
AllowedOwnerIDs: append([]string(nil), config.AllowedOwnerIDs...),
CountryLimitEnabled: config.CountryLimitEnabled,
CountryRules: humanRoomRobotCountryRulesFromClient(config.CountryRules),
UpdatedByAdminID: config.UpdatedByAdminID,
CreatedAtMS: config.CreatedAtMS,
UpdatedAtMS: config.UpdatedAtMS,
@ -290,6 +304,28 @@ func (s *Service) humanRoomRobotConfigFromClient(_ context.Context, config roomc
return out, nil
}
func humanRoomRobotCountryRulesToClient(values []HumanRoomRobotCountryRule) []roomclient.HumanRoomRobotCountryRule {
out := make([]roomclient.HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
out = append(out, roomclient.HumanRoomRobotCountryRule{
CountryCode: strings.ToUpper(strings.TrimSpace(value.CountryCode)),
MaxRoomCount: value.MaxRoomCount,
})
}
return out
}
func humanRoomRobotCountryRulesFromClient(values []roomclient.HumanRoomRobotCountryRule) []HumanRoomRobotCountryRule {
out := make([]HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
out = append(out, HumanRoomRobotCountryRule{
CountryCode: value.CountryCode,
MaxRoomCount: value.MaxRoomCount,
})
}
return out
}
func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomRequest, actor shared.Actor) (RobotRoom, error) {
if s.roomClient == nil {
return RobotRoom{}, fmt.Errorf("room service client is not configured")

View File

@ -22,7 +22,7 @@ import (
const defaultWheelID = "default"
var wheelTierCounts = map[string]int{
"classic": 9,
"classic": 8,
"luxury": 8,
"advanced": 12,
}
@ -210,7 +210,7 @@ func validateConfigRequest(req configRequest) error {
return fmt.Errorf("wheel_id 不能为空")
}
expectedCount := expectedWheelTierCount(req.WheelID)
// 转盘前端布局是固定格子数classic=9、luxury=8、advanced=12。这里按池子强校验
// 转盘前端布局是固定格子数classic=8、luxury=8、advanced=12。这里按池子强校验
// 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。
if len(req.Tiers) != expectedCount {
return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount)

View File

@ -9,9 +9,9 @@ import (
func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) {
tiers := validClassicTiers()
tiers[0] = tierDTO{DisplayName: "金币", RewardType: " coin ", RewardCount: 1, RewardCoins: 1000, ProbabilityPercent: defaultProbabilityPercent(0, 9), Enabled: true}
tiers[1] = tierDTO{DisplayName: "礼物", RewardType: "gift", RewardID: "gift_1", RewardCount: 1, RTPValueCoins: 5000, ProbabilityPercent: defaultProbabilityPercent(1, 9), Enabled: true}
tiers[2] = tierDTO{DisplayName: "装扮", RewardType: "dress_up", RewardID: "dress_1", RewardCount: 1, RTPValueCoins: 999999, ProbabilityPercent: defaultProbabilityPercent(2, 9), Enabled: true}
tiers[0] = tierDTO{DisplayName: "金币", RewardType: " coin ", RewardCount: 1, RewardCoins: 1000, ProbabilityPercent: defaultProbabilityPercent(0, 8), Enabled: true}
tiers[1] = tierDTO{DisplayName: "礼物", RewardType: "gift", RewardID: "gift_1", RewardCount: 1, RTPValueCoins: 5000, ProbabilityPercent: defaultProbabilityPercent(1, 8), Enabled: true}
tiers[2] = tierDTO{DisplayName: "装扮", RewardType: "dress_up", RewardID: "dress_1", RewardCount: 1, RTPValueCoins: 999999, ProbabilityPercent: defaultProbabilityPercent(2, 8), Enabled: true}
req := configRequest{
WheelID: " classic ",
Enabled: true,
@ -32,8 +32,8 @@ func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) {
t.Fatalf("ppm mismatch: target=%d pool=%d", config.GetTargetRtpPpm(), config.GetPoolRatePpm())
}
protoTiers := config.GetTiers()
if len(protoTiers) != 9 {
t.Fatalf("tier count = %d, want 9", len(protoTiers))
if len(protoTiers) != 8 {
t.Fatalf("tier count = %d, want 8", len(protoTiers))
}
if protoTiers[0].GetTierId() != "coin-01" || protoTiers[0].GetRtpValueCoins() != 1000 {
t.Fatalf("coin tier mismatch: %+v", protoTiers[0])
@ -52,11 +52,11 @@ func TestValidateConfigRequiresPoolTierCountAndProbabilitySum(t *testing.T) {
{RewardType: "gift", RewardID: "gift_2", ProbabilityPercent: 20},
},
})
if err == nil || !strings.Contains(err.Error(), "9 个奖品档位") {
if err == nil || !strings.Contains(err.Error(), "8 个奖品档位") {
t.Fatalf("classic tier count should be rejected, got %v", err)
}
tiers := validClassicTiers()
tiers[8].ProbabilityPercent = 1
tiers[7].ProbabilityPercent = 1
err = validateConfigRequest(configRequest{
WheelID: "classic",
Tiers: tiers,
@ -99,14 +99,14 @@ func TestConfigFromProtoConvertsPPMToPercent(t *testing.T) {
}
func validClassicTiers() []tierDTO {
tiers := make([]tierDTO, 0, 9)
for index := 0; index < 9; index++ {
tiers := make([]tierDTO, 0, 8)
for index := 0; index < 8; index++ {
tiers = append(tiers, tierDTO{
DisplayName: "金币",
RewardType: "coin",
RewardCount: 1,
RewardCoins: 100,
ProbabilityPercent: defaultProbabilityPercent(index, 9),
ProbabilityPercent: defaultProbabilityPercent(index, 8),
Enabled: true,
})
}

View File

@ -152,6 +152,7 @@ type DrawCommand struct {
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
CountryID int64
}
type CheckResult struct {

View File

@ -79,6 +79,19 @@ type DrawResult struct {
WalletTransactionID string
CoinBalanceAfter int64
MetadataJSON string
Rewards []DrawReward
}
// DrawReward 是批量抽奖响应里的单次命中奖品快照;它只服务展示和协议转换,发奖状态仍以每条 draw record 为准。
type DrawReward struct {
SelectedTierID string
RewardType string
RewardID string
RewardCount int64
RewardCoins int64
RTPValueCoins int64
RewardStatus string
MetadataJSON string
}
type DrawSummary struct {

View File

@ -326,6 +326,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
SenderUserID: cmd.UserID,
TargetUserID: cmd.TargetUserID,
VisibleRegionID: cmd.VisibleRegionID,
CountryID: cmd.CountryID,
CoinSpent: cmd.CoinSpent,
RuleVersion: result.RuleVersion,
ExperiencePool: result.ExperiencePool,
@ -395,6 +396,7 @@ func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, pay
PoolId: payload.PoolID,
Reason: "lucky_gift_reward",
VisibleRegionId: payload.VisibleRegionID,
CountryId: payload.CountryID,
})
if err != nil {
return luckyGiftRewardReceipt{}, err
@ -545,6 +547,7 @@ type luckyGiftDrawnPayload struct {
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
VisibleRegionID int64 `json:"visible_region_id"`
CountryID int64 `json:"country_id"`
CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"`

View File

@ -72,6 +72,7 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
PoolID: "super_lucky",
UserID: 42,
TargetUserID: 99,
CountryID: 15,
DeviceID: "device-1",
RoomID: "room-1",
AnchorID: "99",
@ -83,7 +84,7 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
if err != nil {
t.Fatalf("Draw failed: %v", err)
}
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 {
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
}
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 {
@ -138,6 +139,7 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"country_id": 15,
"visible_region_id": 210,
"room_id": "room-1",
"gift_id": "rose",
@ -169,7 +171,7 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 {
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
}
if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
@ -192,6 +194,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"country_id": 15,
"room_id": "room-1",
"gift_id": "rose",
"gift_count": 3,
@ -222,7 +225,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetVisibleRegionId() != 210 {
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
}
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {

View File

@ -11,6 +11,12 @@ import (
const defaultWheelID = "default"
var wheelTierCounts = map[string]int{
"classic": 8,
"luxury": 8,
"advanced": 12,
}
func NormalizeWheelID(wheelID string) string {
wheelID = strings.TrimSpace(wheelID)
if wheelID == "" {
@ -65,6 +71,11 @@ func ValidateRuleConfig(config domain.RuleConfig) error {
if len(config.Tiers) == 0 {
return xerr.New(xerr.InvalidArgument, "wheel tiers are required")
}
if expectedCount, ok := wheelTierCounts[config.WheelID]; ok && len(config.Tiers) != expectedCount {
// H5 每个转盘的视觉格子数是固定契约;服务端发布前强校验数量,避免后台或脚本写入多余档位后,
// 抽奖结果命中一个 H5 没有位置展示的奖品,造成动画、中奖弹窗和实际发奖对不上。
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel %s requires %d tiers", config.WheelID, expectedCount))
}
seen := map[string]bool{}
var enabled bool
for _, tier := range config.Tiers {

View File

@ -1,6 +1,8 @@
package wheel
import (
"fmt"
"strings"
"testing"
domain "hyapp/services/activity-service/internal/domain/wheel"
@ -27,7 +29,7 @@ func TestNormalizeRuleConfigForcesPropRTPValueToZero(t *testing.T) {
func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation(t *testing.T) {
config := NormalizeRuleConfig(domain.RuleConfig{
WheelID: "classic",
WheelID: "custom",
DrawPriceCoins: 100,
SettlementWindowDraws: 1000,
Tiers: []domain.Tier{
@ -39,3 +41,25 @@ func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation
t.Fatalf("ValidateRuleConfig should normalize prop rtp value before validation: %v", err)
}
}
func TestValidateRuleConfigRejectsClassicNineTiers(t *testing.T) {
tiers := make([]domain.Tier, 0, 9)
for index := 0; index < 9; index++ {
tiers = append(tiers, domain.Tier{
TierID: fmt.Sprintf("classic-%d", index+1),
RewardType: "coin",
RewardCoins: 100,
WeightPPM: 1,
Enabled: true,
})
}
err := ValidateRuleConfig(domain.RuleConfig{
WheelID: "classic",
DrawPriceCoins: 100,
SettlementWindowDraws: 1000,
Tiers: tiers,
})
if err == nil || !strings.Contains(err.Error(), "requires 8 tiers") {
t.Fatalf("classic should reject nine tiers, got %v", err)
}
}

View File

@ -2,7 +2,10 @@ package wheel
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
@ -25,6 +28,7 @@ type Repository interface {
type WalletClient interface {
CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
type Service struct {
@ -78,7 +82,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if err != nil {
return domain.DrawResult{}, err
}
return s.creditCoinRewardFastPath(ctx, cmd, result), nil
return s.fulfillWheelRewardFastPath(ctx, cmd, result), nil
}
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
@ -120,37 +124,180 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
return s.repository.GetWheelDrawSummary(ctx, normalizeDrawQuery(query))
}
func (s *Service) creditCoinRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.RewardType != domain.RewardTypeCoin || result.RewardCoins <= 0 || result.RewardStatus == domain.StatusGranted || len(result.DrawIDs) != 1 {
func (s *Service) fulfillWheelRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.RewardStatus == domain.StatusGranted {
return result
}
if s.wallet == nil {
logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID))
return result
}
rewards := wheelResultRewards(result)
if len(rewards) == 0 {
return result
}
receipts := make([]string, 0, len(rewards))
for index, reward := range rewards {
receipt, coinBalance, err := s.fulfillWheelRewardItem(ctx, cmd, result, reward, index, len(rewards))
if err != nil {
// 抽奖结果已经在事务内确认,发放失败时不能回滚抽奖事实;保留 pending/outbox补偿 worker 或重试可以用同一 command_id 幂等补发。
logx.Error(ctx, "wheel_reward_fast_path_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("reward_type", reward.RewardType),
slog.String("selected_tier_id", reward.SelectedTierID),
)
return result
}
if receipt != "" {
receipts = append(receipts, receipt)
}
if coinBalance > 0 {
result.CoinBalanceAfter = coinBalance
}
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = strings.Join(receipts, ",")
drawIDs := []string{result.DrawID}
if result.DrawID == "" {
drawIDs = append([]string(nil), result.DrawIDs...)
}
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), drawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
}
return result
}
func (s *Service) fulfillWheelRewardItem(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
reward.RewardType = strings.TrimSpace(strings.ToLower(reward.RewardType))
switch reward.RewardType {
case domain.RewardTypeCoin:
return s.creditWheelCoinReward(ctx, cmd, result, reward, index, total)
case domain.RewardTypeGift, domain.RewardTypeProp, domain.RewardTypeDress:
return s.grantWheelResourceReward(ctx, cmd, result, reward, index, total)
default:
return "", 0, fmt.Errorf("unsupported wheel reward type %q", reward.RewardType)
}
}
func (s *Service) creditWheelCoinReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
if reward.RewardCoins <= 0 {
return "", 0, fmt.Errorf("wheel coin reward amount must be positive")
}
resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{
CommandId: "wheel_reward:" + result.DrawID,
CommandId: wheelRewardCommandID("wheel_reward", result.DrawID, index, total),
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
Amount: result.RewardCoins,
Amount: reward.RewardCoins,
DrawId: result.DrawID,
WheelId: result.WheelID,
SelectedTierId: result.SelectedTierID,
SelectedTierId: reward.SelectedTierID,
Reason: "wheel_reward",
VisibleRegionId: cmd.VisibleRegionID,
})
if err != nil {
// 钱包不可用不能回滚已经确认的抽奖事实pending 记录和 outbox 保留,后续补偿 worker 可以继续处理。
logx.Error(ctx, "wheel_reward_fast_path_failed", err, slog.String("draw_id", result.DrawID), slog.String("command_id", result.CommandID))
return result
return "", 0, err
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = resp.GetTransactionId()
result.CoinBalanceAfter = resp.GetBalance().GetAvailableAmount()
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), result.DrawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
return resp.GetTransactionId(), resp.GetBalance().GetAvailableAmount(), nil
}
func (s *Service) grantWheelResourceReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
resourceID := wheelRewardResourceID(reward)
if resourceID <= 0 {
// gift/prop/dress 的真实发放 owner 是 wallet 资源权益;缺 resource_id 时不能用展示 ID 猜测,必须让记录保持 pending 以便后台修配置后补偿。
return "", 0, fmt.Errorf("wheel resource reward missing resource_id")
}
quantity := reward.RewardCount
if quantity <= 0 {
quantity = 1
}
resp, err := s.wallet.GrantResource(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.GrantResourceRequest{
CommandId: wheelRewardCommandID("wheel_resource", result.DrawID, index, total),
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
ResourceId: resourceID,
Quantity: quantity,
DurationMs: wheelRewardDurationMS(reward),
Reason: "wheel_reward",
OperatorUserId: cmd.UserID,
GrantSource: "wheel_reward",
})
if err != nil {
return "", 0, err
}
return resp.GetGrant().GetGrantId(), 0, nil
}
func wheelResultRewards(result domain.DrawResult) []domain.DrawReward {
if len(result.Rewards) > 0 {
return result.Rewards
}
if result.RewardType == "" {
return nil
}
return []domain.DrawReward{{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}}
}
func wheelRewardCommandID(prefix, drawID string, index, total int) string {
if total <= 1 {
return prefix + ":" + drawID
}
return fmt.Sprintf("%s:%s:%d", prefix, drawID, index+1)
}
func wheelRewardResourceID(reward domain.DrawReward) int64 {
if value := wheelRewardMetadataInt64(reward.MetadataJSON, "resource_id", "resourceId"); value > 0 {
return value
}
value, _ := strconv.ParseInt(strings.TrimSpace(reward.RewardID), 10, 64)
return value
}
func wheelRewardDurationMS(reward domain.DrawReward) int64 {
return wheelRewardMetadataInt64(reward.MetadataJSON, "duration_ms", "durationMs", "validity_ms", "validityMs")
}
func wheelRewardMetadataInt64(raw string, keys ...string) int64 {
metadata := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
return 0
}
for _, key := range keys {
if value, ok := metadata[key]; ok {
if parsed := anyToInt64(value); parsed > 0 {
return parsed
}
}
}
return 0
}
func anyToInt64(value any) int64 {
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
return 0
}
return result
}
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {

View File

@ -0,0 +1,148 @@
package wheel
import (
"context"
"testing"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
func TestDrawGrantsResourceReward(t *testing.T) {
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
DrawID: "wheel_draw_1",
DrawIDs: []string{"wheel_draw_1"},
CommandID: "cmd-1",
WheelID: "classic",
SelectedTierID: "classic-1",
RewardType: domain.RewardTypeGift,
RewardID: "gift-1",
RewardCount: 2,
RewardStatus: domain.StatusPending,
MetadataJSON: `{"resource_id":326}`,
}}
wallet := &fakeWheelWallet{}
service := New(repository, WithWallet(wallet))
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
CommandID: "cmd-1",
WheelID: "classic",
UserID: 1001,
DeviceID: "device-1",
CoinSpent: 10000,
})
if err != nil {
t.Fatalf("Draw returned error: %v", err)
}
if result.RewardStatus != domain.StatusGranted {
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
}
if len(wallet.resourceGrants) != 1 {
t.Fatalf("resource grants mismatch: %+v", wallet.resourceGrants)
}
req := wallet.resourceGrants[0]
if req.GetCommandId() != "wheel_resource:wheel_draw_1" || req.GetTargetUserId() != 1001 || req.GetResourceId() != 326 || req.GetQuantity() != 2 {
t.Fatalf("grant request mismatch: %+v", req)
}
if req.GetReason() != "wheel_reward" || req.GetGrantSource() != "wheel_reward" || req.GetOperatorUserId() != 1001 {
t.Fatalf("grant audit fields mismatch: %+v", req)
}
if len(repository.markGrantedDrawIDs) != 1 || repository.markGrantedDrawIDs[0] != "wheel_draw_1" {
t.Fatalf("marked draw IDs mismatch: %+v", repository.markGrantedDrawIDs)
}
}
func TestDrawFulfillsBatchCoinAndResourceRewards(t *testing.T) {
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
DrawID: "wheel_draw_batch",
DrawIDs: []string{"wheel_draw_batch"},
CommandID: "cmd-batch",
WheelID: "advanced",
RewardType: "batch",
RewardStatus: domain.StatusPending,
Rewards: []domain.DrawReward{
{SelectedTierID: "advanced-1", RewardType: domain.RewardTypeCoin, RewardCoins: 100},
{SelectedTierID: "advanced-2", RewardType: domain.RewardTypeProp, RewardID: "901", RewardCount: 1, MetadataJSON: `{"resource_id":901}`},
},
}}
wallet := &fakeWheelWallet{coinBalanceAfter: 9900}
service := New(repository, WithWallet(wallet))
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
CommandID: "cmd-batch",
WheelID: "advanced",
UserID: 2002,
DeviceID: "device-2",
CoinSpent: 50000,
DrawCount: 2,
})
if err != nil {
t.Fatalf("Draw returned error: %v", err)
}
if result.RewardStatus != domain.StatusGranted {
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
}
if len(wallet.coinCredits) != 1 || wallet.coinCredits[0].GetCommandId() != "wheel_reward:wheel_draw_batch:1" {
t.Fatalf("coin credit mismatch: %+v", wallet.coinCredits)
}
if len(wallet.resourceGrants) != 1 || wallet.resourceGrants[0].GetCommandId() != "wheel_resource:wheel_draw_batch:2" {
t.Fatalf("resource grant mismatch: %+v", wallet.resourceGrants)
}
if result.CoinBalanceAfter != 9900 {
t.Fatalf("coin balance mismatch: %d", result.CoinBalanceAfter)
}
}
type fakeWheelRepository struct {
executeResult domain.DrawResult
markGrantedDrawIDs []string
}
func (r *fakeWheelRepository) PublishWheelRuleConfig(context.Context, domain.RuleConfig, int64) (domain.RuleConfig, error) {
return domain.RuleConfig{}, nil
}
func (r *fakeWheelRepository) GetWheelRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
return domain.RuleConfig{}, false, nil
}
func (r *fakeWheelRepository) ExecuteWheelDraw(context.Context, domain.DrawCommand, int64) (domain.DrawResult, error) {
return r.executeResult, nil
}
func (r *fakeWheelRepository) ListWheelDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
return nil, 0, nil
}
func (r *fakeWheelRepository) GetWheelDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
return domain.DrawSummary{}, nil
}
func (r *fakeWheelRepository) MarkWheelDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error {
r.markGrantedDrawIDs = append([]string(nil), drawIDs...)
return nil
}
type fakeWheelWallet struct {
coinCredits []*walletv1.CreditWheelRewardRequest
resourceGrants []*walletv1.GrantResourceRequest
coinBalanceAfter int64
}
func (w *fakeWheelWallet) CreditWheelReward(_ context.Context, req *walletv1.CreditWheelRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error) {
w.coinCredits = append(w.coinCredits, req)
return &walletv1.CreditWheelRewardResponse{
TransactionId: "tx-" + req.GetCommandId(),
Balance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: w.coinBalanceAfter},
}, nil
}
func (w *fakeWheelWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
w.resourceGrants = append(w.resourceGrants, req)
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-" + req.GetCommandId()}}, nil
}

View File

@ -1130,6 +1130,7 @@ func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Cont
"sender_user_id": cmd.UserID,
"target_user_id": cmd.TargetUserID,
"visible_region_id": cmd.VisibleRegionID,
"country_id": cmd.CountryID,
"room_id": cmd.RoomID,
"gift_id": cmd.GiftID,
"gift_count": cmd.GiftCount,
@ -1568,6 +1569,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
"sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID,
"country_id": input.Command.CountryID,
"room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID,
"gift_count": input.Command.GiftCount,

View File

@ -171,23 +171,35 @@ func (r *Repository) ExecuteWheelDraw(ctx context.Context, cmd domain.DrawComman
totalPoolIn += unitSpent * config.PoolRatePPM / wheelPPMScale
pool.Balance += unitSpent * config.PoolRatePPM / wheelPPMScale
subCommand := wheelSubCommandID(cmd.CommandID, i, cmd.DrawCount)
result, poolOut, err := r.executeSingleWheelDraw(ctx, tx, appCode, cmd, subCommand, unitSpent, config, &window, &pool, nowMS)
result, poolOut, err := r.executeSingleWheelDraw(cmd, subCommand, unitSpent, config, &window, &pool, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
totalPoolOut += poolOut
results = append(results, result)
}
if err := r.persistWheelRTPWindow(ctx, tx, appCode, config.WheelID, window, nowMS); err != nil {
return domain.DrawResult{}, err
}
if err := r.persistWheelPoolDelta(ctx, tx, appCode, config.WheelID, totalPoolIn, totalPoolOut, nowMS); err != nil {
return domain.DrawResult{}, err
}
if err := r.updateWheelStats(ctx, tx, appCode, config.WheelID, cmd.UserID, cmd.CoinSpent, results, nowMS); err != nil {
return domain.DrawResult{}, err
}
aggregate := aggregateWheelDrawResults(cmd, results)
if err := r.insertAggregateWheelDraw(ctx, tx, appCode, cmd, aggregate, nowMS); err != nil {
return domain.DrawResult{}, err
}
if aggregate.RewardStatus == domain.StatusPending {
if err := r.insertWheelRewardOutbox(ctx, tx, appCode, aggregate, cmd, nowMS); err != nil {
return domain.DrawResult{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.DrawResult{}, err
}
return aggregateWheelDrawResults(cmd, results), nil
return aggregate, nil
}
func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
@ -222,6 +234,7 @@ func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery)
return nil, 0, err
}
item.DrawIDs = []string{item.DrawID}
item.Rewards = wheelRewardsFromMetadata(item.MetadataJSON)
items = append(items, item)
}
return items, total, rows.Err()
@ -355,7 +368,7 @@ func (r *Repository) MarkWheelDrawsGranted(ctx context.Context, appCode string,
return tx.Commit()
}
func (r *Repository) executeSingleWheelDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, commandID string, unitSpent int64, config domain.RuleConfig, window *wheelRTPWindow, pool *wheelPool, nowMS int64) (domain.DrawResult, int64, error) {
func (r *Repository) executeSingleWheelDraw(cmd domain.DrawCommand, commandID string, unitSpent int64, config domain.RuleConfig, window *wheelRTPWindow, pool *wheelPool, nowMS int64) (domain.DrawResult, int64, error) {
candidates, limited := wheelPayableCandidates(config, pool)
if len(candidates) == 0 {
return domain.DrawResult{}, 0, xerr.New(xerr.Conflict, "wheel has no payable tier")
@ -383,42 +396,12 @@ func (r *Repository) executeSingleWheelDraw(ctx context.Context, tx *sql.Tx, app
window.WagerCoins += unitSpent
window.TargetPayout = window.WagerCoins * window.TargetRTPPPM / wheelPPMScale
window.ActualRTPValue += tier.RTPValueCoins
if _, err := tx.ExecContext(ctx, `
UPDATE wheel_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, actual_rtp_value_coins = ?,
carry_ppm = ?, updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayout, window.ActualRTPValue,
window.CarryPPM, nowMS, appCode, config.WheelID, window.WindowIndex,
); err != nil {
return domain.DrawResult{}, 0, err
}
drawID := idgen.New("wheel_draw")
status := domain.StatusGranted
if wheelDrawNeedsFulfillment(tier) {
status = domain.StatusPending
}
candidateJSON, _ := json.Marshal(map[string]any{"selected": tier.TierID, "limited": limited})
rtpJSON, _ := json.Marshal(map[string]any{"window_index": window.WindowIndex, "wager_coins": window.WagerCoins, "actual_rtp_value_coins": window.ActualRTPValue})
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_draw_records (
app_code, draw_id, command_id, user_id, device_id, visible_region_id, wheel_id, coin_spent,
rule_version, rtp_window_index, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, candidate_tiers_json, rtp_snapshot_json, prize_metadata_json,
reward_status, paid_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, drawID, commandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, config.WheelID, unitSpent,
config.RuleVersion, window.WindowIndex, tier.TierID, tier.RewardType, tier.RewardID, tier.RewardCount,
tier.RewardCoins, tier.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(tier.MetadataJSON),
status, cmd.PaidAtMS, nowMS, nowMS,
); err != nil {
return domain.DrawResult{}, 0, err
}
if status == domain.StatusPending {
if err := r.insertWheelRewardOutbox(ctx, tx, appCode, drawID, commandID, cmd, tier, nowMS); err != nil {
return domain.DrawResult{}, 0, err
}
}
_ = limited
return domain.DrawResult{
DrawID: drawID,
CommandID: commandID,
@ -576,6 +559,18 @@ func (r *Repository) persistWheelPoolDelta(ctx context.Context, tx *sql.Tx, appC
return err
}
func (r *Repository) persistWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, window wheelRTPWindow, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE wheel_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, actual_rtp_value_coins = ?,
carry_ppm = ?, updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayout, window.ActualRTPValue,
window.CarryPPM, nowMS, appCode, wheelID, window.WindowIndex,
)
return err
}
func wheelPayableCandidates(config domain.RuleConfig, pool *wheelPool) ([]wheelCandidate, map[string]bool) {
limited := map[string]bool{}
capacity := pool.Balance - pool.ReserveFloor
@ -643,50 +638,73 @@ func (r *Repository) updateWheelStats(ctx context.Context, tx *sql.Tx, appCode,
return nil
}
func (r *Repository) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode, drawID, commandID string, cmd domain.DrawCommand, tier domain.Tier, nowMS int64) error {
func (r *Repository) insertAggregateWheelDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, result domain.DrawResult, nowMS int64) error {
candidateJSON, _ := json.Marshal(map[string]any{
"draw_count": len(result.Rewards),
"batch": len(result.Rewards) > 1,
})
rtpJSON, _ := json.Marshal(map[string]any{
"rtp_window_index": result.RTPWindowIndex,
"total_rtp_value_coins": result.RTPValueCoins,
})
_, err := tx.ExecContext(ctx, `
INSERT INTO wheel_draw_records (
app_code, draw_id, command_id, user_id, device_id, visible_region_id, wheel_id, coin_spent,
rule_version, rtp_window_index, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, candidate_tiers_json, rtp_snapshot_json, prize_metadata_json,
reward_status, paid_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, result.DrawID, cmd.CommandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, result.WheelID, cmd.CoinSpent,
result.RuleVersion, result.RTPWindowIndex, result.SelectedTierID, result.RewardType, result.RewardID, result.RewardCount,
result.RewardCoins, result.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(result.MetadataJSON),
result.RewardStatus, cmd.PaidAtMS, nowMS, nowMS,
)
return err
}
func (r *Repository) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode string, result domain.DrawResult, cmd domain.DrawCommand, nowMS int64) error {
payload, _ := json.Marshal(map[string]any{
"app_code": appCode,
"draw_id": drawID,
"command_id": commandID,
"draw_id": result.DrawID,
"draw_ids": result.DrawIDs,
"command_id": cmd.CommandID,
"user_id": cmd.UserID,
"wheel_id": cmd.WheelID,
"selected_tier_id": tier.TierID,
"reward_type": tier.RewardType,
"reward_id": tier.RewardID,
"reward_count": tier.RewardCount,
"reward_coins": tier.RewardCoins,
"selected_tier_id": result.SelectedTierID,
"reward_type": result.RewardType,
"reward_id": result.RewardID,
"reward_count": result.RewardCount,
"reward_coins": result.RewardCoins,
"rewards": result.Rewards,
"visible_region_id": cmd.VisibleRegionID,
"created_at_ms": nowMS,
})
return r.insertLuckyDrawOutbox(ctx, tx, appCode, "wheel_reward_"+drawID, domain.EventTypeWheelRewardSettlement, payload, nowMS)
return r.insertLuckyDrawOutbox(ctx, tx, appCode, "wheel_reward_"+result.DrawID, domain.EventTypeWheelRewardSettlement, payload, nowMS)
}
func (r *Repository) collectWheelDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, drawCount int32) ([]domain.DrawResult, bool, error) {
if drawCount <= 0 {
drawCount = 1
}
results := make([]domain.DrawResult, 0, drawCount)
for i := int32(1); i <= drawCount; i++ {
sub := wheelSubCommandID(commandID, i, drawCount)
row := tx.QueryRowContext(ctx, `
row := tx.QueryRowContext(ctx, `
SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms,
COALESCE(CAST(prize_metadata_json AS CHAR), '{}')
FROM wheel_draw_records
WHERE app_code = ? AND command_id = ?`,
appCode, sub,
)
var result domain.DrawResult
if err := row.Scan(&result.DrawID, &result.CommandID, &result.WheelID, &result.RuleVersion, &result.SelectedTierID, &result.RewardType, &result.RewardID, &result.RewardCount,
&result.RewardCoins, &result.RTPValueCoins, &result.RewardStatus, &result.WalletTransactionID, &result.RTPWindowIndex, &result.CreatedAtMS, &result.MetadataJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue
}
return nil, false, err
appCode, strings.TrimSpace(commandID),
)
var result domain.DrawResult
if err := row.Scan(&result.DrawID, &result.CommandID, &result.WheelID, &result.RuleVersion, &result.SelectedTierID, &result.RewardType, &result.RewardID, &result.RewardCount,
&result.RewardCoins, &result.RTPValueCoins, &result.RewardStatus, &result.WalletTransactionID, &result.RTPWindowIndex, &result.CreatedAtMS, &result.MetadataJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
results = append(results, result)
return nil, false, err
}
return results, int32(len(results)) == drawCount, nil
result.DrawIDs = []string{result.DrawID}
result.Rewards = wheelRewardsFromMetadata(result.MetadataJSON)
return []domain.DrawResult{result}, true, nil
}
func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult {
@ -695,9 +713,15 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
}
aggregate := results[0]
aggregate.CommandID = cmd.CommandID
aggregate.DrawIDs = make([]string, 0, len(results))
aggregate.DrawIDs = []string{aggregate.DrawID}
aggregate.Rewards = make([]domain.DrawReward, 0, len(results))
if len(results) == 1 {
aggregate.DrawIDs = []string{aggregate.DrawID}
if len(results[0].Rewards) > 0 {
aggregate.Rewards = append([]domain.DrawReward(nil), results[0].Rewards...)
return aggregate
}
aggregate.Rewards = []domain.DrawReward{wheelDrawRewardFromResult(aggregate)}
aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards)
return aggregate
}
aggregate.SelectedTierID = "batch"
@ -707,8 +731,10 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
aggregate.RewardCoins = 0
aggregate.RTPValueCoins = 0
aggregate.RewardStatus = domain.StatusGranted
aggregate.MetadataJSON = ""
for _, result := range results {
aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
aggregate.Rewards = append(aggregate.Rewards, wheelDrawRewardFromResult(result))
aggregate.RewardCount += result.RewardCount
aggregate.RewardCoins += result.RewardCoins
aggregate.RTPValueCoins += result.RTPValueCoins
if result.RewardStatus == domain.StatusPending {
@ -717,9 +743,73 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
aggregate.RewardStatus = domain.StatusFailed
}
}
aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards)
return aggregate
}
func wheelDrawRewardFromResult(result domain.DrawResult) domain.DrawReward {
return domain.DrawReward{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}
}
func wheelAggregateMetadataJSON(rewards []domain.DrawReward) string {
items := make([]map[string]any, 0, len(rewards))
for _, reward := range rewards {
items = append(items, map[string]any{
"selected_tier_id": reward.SelectedTierID,
"reward_type": reward.RewardType,
"reward_id": reward.RewardID,
"reward_count": reward.RewardCount,
"reward_coins": reward.RewardCoins,
"rtp_value_coins": reward.RTPValueCoins,
"reward_status": reward.RewardStatus,
"metadata_json": wheelMetadataJSON(reward.MetadataJSON),
})
}
payload, _ := json.Marshal(map[string]any{"rewards": items})
return string(payload)
}
func wheelRewardsFromMetadata(metadataJSON string) []domain.DrawReward {
var payload struct {
Rewards []struct {
SelectedTierID string `json:"selected_tier_id"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
RTPValueCoins int64 `json:"rtp_value_coins"`
RewardStatus string `json:"reward_status"`
MetadataJSON string `json:"metadata_json"`
} `json:"rewards"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil || len(payload.Rewards) == 0 {
return nil
}
rewards := make([]domain.DrawReward, 0, len(payload.Rewards))
for _, item := range payload.Rewards {
rewards = append(rewards, domain.DrawReward{
SelectedTierID: item.SelectedTierID,
RewardType: item.RewardType,
RewardID: item.RewardID,
RewardCount: item.RewardCount,
RewardCoins: item.RewardCoins,
RTPValueCoins: item.RTPValueCoins,
RewardStatus: item.RewardStatus,
MetadataJSON: wheelMetadataJSON(item.MetadataJSON),
})
}
return rewards
}
func wheelSubCommandID(commandID string, drawIndex int32, drawCount int32) string {
if drawCount <= 1 {
return strings.TrimSpace(commandID)

View File

@ -0,0 +1,30 @@
package mysql
import (
"testing"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
func TestAggregateWheelDrawResultsKeepsBatchRewardDetails(t *testing.T) {
results := []domain.DrawResult{
{DrawID: "draw-1", CommandID: "cmd#000001", WheelID: "luxury", SelectedTierID: "gift-a", RewardType: domain.RewardTypeGift, RewardID: "gift-a", RewardCount: 1, RTPValueCoins: 100, RewardStatus: domain.StatusPending, MetadataJSON: `{"resource_id":331}`},
{DrawID: "draw-2", CommandID: "cmd#000002", WheelID: "luxury", SelectedTierID: "gift-b", RewardType: domain.RewardTypeGift, RewardID: "gift-b", RewardCount: 1, RTPValueCoins: 200, RewardStatus: domain.StatusGranted, MetadataJSON: `{"resource_id":332}`},
{DrawID: "draw-3", CommandID: "cmd#000003", WheelID: "luxury", SelectedTierID: "gift-a", RewardType: domain.RewardTypeGift, RewardID: "gift-a", RewardCount: 1, RTPValueCoins: 100, RewardStatus: domain.StatusPending, MetadataJSON: `{"resource_id":331}`},
}
aggregate := aggregateWheelDrawResults(domain.DrawCommand{CommandID: "cmd", DrawCount: 3}, results)
if aggregate.CommandID != "cmd" || aggregate.SelectedTierID != "batch" || aggregate.RewardType != "batch" {
t.Fatalf("batch aggregate identity mismatch: %+v", aggregate)
}
if len(aggregate.DrawIDs) != 1 || aggregate.DrawIDs[0] != aggregate.DrawID || len(aggregate.Rewards) != 3 {
t.Fatalf("batch aggregate must keep one persisted draw id and all reward details: %+v", aggregate)
}
if aggregate.RewardStatus != domain.StatusPending || aggregate.RTPValueCoins != 400 {
t.Fatalf("batch aggregate status/value mismatch: %+v", aggregate)
}
if aggregate.Rewards[0].RewardID != "gift-a" || aggregate.Rewards[1].RewardID != "gift-b" || aggregate.Rewards[2].MetadataJSON != `{"resource_id":331}` {
t.Fatalf("batch reward details changed order/content: %+v", aggregate.Rewards)
}
}

View File

@ -60,6 +60,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)

View File

@ -175,6 +175,22 @@ func wheelRuleConfigToProto(config domain.RuleConfig) *activityv1.WheelRuleConfi
}
func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResult {
if len(result.Rewards) == 0 {
result.Rewards = []domain.DrawReward{{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}}
}
rewards := make([]*activityv1.WheelDrawReward, 0, len(result.Rewards))
for _, reward := range result.Rewards {
rewards = append(rewards, wheelDrawRewardToProto(reward))
}
return &activityv1.WheelDrawResult{
DrawId: result.DrawID,
DrawIds: result.DrawIDs,
@ -194,6 +210,20 @@ func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResul
WalletTransactionId: result.WalletTransactionID,
CoinBalanceAfter: result.CoinBalanceAfter,
MetadataJson: result.MetadataJSON,
Rewards: rewards,
}
}
func wheelDrawRewardToProto(reward domain.DrawReward) *activityv1.WheelDrawReward {
return &activityv1.WheelDrawReward{
SelectedTierId: reward.SelectedTierID,
RewardType: reward.RewardType,
RewardId: reward.RewardID,
RewardCount: reward.RewardCount,
RewardCoins: reward.RewardCoins,
RtpValueCoins: reward.RTPValueCoins,
RewardStatus: reward.RewardStatus,
MetadataJson: reward.MetadataJSON,
}
}

View File

@ -69,6 +69,7 @@ CREATE TABLE IF NOT EXISTS game_launch_sessions (
display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的国家 ID',
scene VARCHAR(64) NOT NULL COMMENT '场景',
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码',
game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID',
@ -95,6 +96,7 @@ CREATE TABLE IF NOT EXISTS game_orders (
user_id BIGINT NOT NULL COMMENT '用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的国家 ID',
op_type VARCHAR(32) NOT NULL COMMENT 'op类型',
coin_amount BIGINT NOT NULL COMMENT '金币数量',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
@ -120,6 +122,7 @@ CREATE TABLE IF NOT EXISTS game_dice_matches (
provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID 快照',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户区域 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建局用户国家 ID',
min_players INT NOT NULL COMMENT '最少开局人数',
max_players INT NOT NULL COMMENT '最多参与人数',
current_players INT NOT NULL DEFAULT 0 COMMENT '当前参与人数',

View File

@ -165,6 +165,7 @@ type Match struct {
ProviderGameID string
RoomID string
RegionID int64
CountryID int64
MinPlayers int32
MaxPlayers int32
CurrentPlayers int32

View File

@ -117,6 +117,7 @@ type LaunchSession struct {
DisplayUserID string
RoomID string
RegionID int64
CountryID int64
Scene string
PlatformCode string
GameID string
@ -143,6 +144,7 @@ type GameOrder struct {
UserID int64
RoomID string
RegionID int64
CountryID int64
// OpType 只允许钱包语义 debit/credit厂商枚举在适配器层转换。
OpType string
CoinAmount int64

View File

@ -145,6 +145,7 @@ type CreateMatchCommand struct {
GameID string
RoomID string
RegionID int64
CountryID int64
StakeCoin int64
MinPlayers int32
MaxPlayers int32
@ -183,6 +184,7 @@ type MatchCommand struct {
GameID string
RoomID string
RegionID int64
CountryID int64
StakeCoin int64
RPSGesture string
}
@ -291,6 +293,7 @@ func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (
ProviderGameID: strings.TrimSpace(game.ProviderGameID),
RoomID: strings.TrimSpace(command.RoomID),
RegionID: command.RegionID,
CountryID: command.CountryID,
MinPlayers: minPlayers,
MaxPlayers: maxPlayers,
CurrentPlayers: 1,
@ -650,6 +653,7 @@ func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.M
GameID: config.GameID,
RoomID: "",
RegionID: command.RegionID,
CountryID: command.CountryID,
StakeCoin: command.StakeCoin,
MinPlayers: config.MinPlayers,
MaxPlayers: config.MaxPlayers,
@ -1569,6 +1573,7 @@ func (s *Service) applyGameCoinChange(ctx context.Context, requestID string, mat
UserID: participant.UserID,
RoomID: match.RoomID,
RegionID: match.RegionID,
CountryID: match.CountryID,
OpType: opType,
CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying,

View File

@ -130,6 +130,7 @@ type LaunchCommand struct {
Scene string
RoomID string
RegionID int64
CountryID int64
AccessToken string
}
@ -275,13 +276,20 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
}
regionID := command.RegionID
if regionID <= 0 {
regionID, countryID := command.RegionID, command.CountryID
if regionID <= 0 || countryID <= 0 {
var err error
regionID, err = s.resolveUserRegion(ctx, command.AppCode, command.RequestID, command.UserID)
// 游戏订单的统计事件只能用启动时的用户维度快照,不能在回调时靠区域桶或当前资料反推国家。
resolvedRegionID, resolvedCountryID, err := s.resolveUserDimensions(ctx, command.AppCode, command.RequestID, command.UserID)
if err != nil {
return LaunchResult{}, err
}
if regionID <= 0 {
regionID = resolvedRegionID
}
if countryID <= 0 {
countryID = resolvedCountryID
}
}
now := s.now()
@ -316,6 +324,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
RegionID: regionID,
CountryID: countryID,
Scene: command.Scene,
PlatformCode: game.PlatformCode,
GameID: game.GameID,
@ -515,8 +524,15 @@ func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *game
}
func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) {
regionID, _, err := s.resolveUserDimensions(ctx, app, requestID, userID)
return regionID, err
}
// resolveUserDimensions 只在入口没有携带完整维度时兜底读取 user-service
// 统计事实随后持久化快照,避免后续用户换国家导致历史订单归因漂移。
func (s *Service) resolveUserDimensions(ctx context.Context, app string, requestID string, userID int64) (int64, int64, error) {
if userID <= 0 || s.user == nil {
return 0, nil
return 0, 0, nil
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
@ -528,9 +544,10 @@ func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID s
UserId: userID,
})
if err != nil {
return 0, err
return 0, 0, err
}
return userResp.GetUser().GetRegionId(), nil
user := userResp.GetUser()
return user.GetRegionId(), user.GetCountryId(), nil
}
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
@ -563,7 +580,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
}
body.ProviderGameID = game.ProviderGameID
}
regionID, err := s.resolveUserRegion(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
regionID, countryID, err := s.resolveUserDimensions(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
if err != nil {
return nil, err
}
@ -578,6 +595,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID),
RegionID: regionID,
CountryID: countryID,
OpType: body.OpType,
CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying,

View File

@ -309,6 +309,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
UserID: session.UserID,
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
RegionID: session.RegionID,
CountryID: session.CountryID,
OpType: opType,
CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying,

View File

@ -30,12 +30,12 @@ func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match
// match 和首个 participant 必须同事务提交;否则创建成功但参与者缺失会导致后续 roll 永远人数不足。
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_dice_matches (
app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, country_id,
min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID, match.CountryID,
match.MinPlayers, match.MaxPlayers, match.CurrentPlayers, match.StakeCoin, match.RoundNo, match.Status, match.Result,
match.JoinDeadlineMS, match.ReadyAtMS, match.CreatedAtMS, match.UpdatedAtMS, match.SettledAtMS, match.CanceledAtMS,
match.FeeBPS, match.PoolBPS, match.MatchMode, match.ForcedResult, match.PoolDeltaCoin,
@ -548,6 +548,7 @@ func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]
"provider_game_id": strings.TrimSpace(match.ProviderGameID),
"room_id": strings.TrimSpace(match.RoomID),
"region_id": match.RegionID,
"country_id": match.CountryID,
"stake_coin": match.StakeCoin,
"min_players": match.MinPlayers,
"max_players": match.MaxPlayers,
@ -584,7 +585,7 @@ func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]
func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) (dicedomain.Match, error) {
// forUpdate 只给状态推进路径使用;读路径不加锁,避免普通刷新阻塞 join/roll。
query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, country_id,
min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
@ -595,7 +596,7 @@ func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID
}
row := q.QueryRowContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
var match dicedomain.Match
if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil {
if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.CountryID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
}

View File

@ -120,6 +120,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
@ -144,6 +145,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
@ -168,6 +170,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
provider_game_id VARCHAR(128) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
min_players INT NOT NULL,
max_players INT NOT NULL,
current_players INT NOT NULL DEFAULT 0,
@ -520,6 +523,15 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "black_pool_robot_force_win_enabled", "black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER normal_phase_target_win_rate_percent"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_orders", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_launch_sessions", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_dice_matches", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
return err
}
@ -823,10 +835,10 @@ func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCo
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_launch_sessions (
app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID, session.CountryID,
session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
)
@ -835,14 +847,14 @@ func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain
func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
FROM game_launch_sessions
WHERE app_code = ? AND launch_token_hash = ?`,
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
)
var item gamedomain.LaunchSession
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.CountryID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
}
@ -855,7 +867,7 @@ func (r *Repository) GetLaunchSessionByTokenScope(ctx context.Context, appCode s
// 同一个 App access token 会被多个厂商和多个游戏复用,回调校验必须把 token、平台和可选游戏 ID 一起收窄。
// 这里按 created_at_ms 倒序取最新一条,避免同用户重复打开同一游戏时拿到旧房间或旧 session。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
FROM game_launch_sessions
WHERE app_code = ?
@ -867,7 +879,7 @@ func (r *Repository) GetLaunchSessionByTokenScope(ctx context.Context, appCode s
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), strings.TrimSpace(platformCode), strings.TrimSpace(providerGameID), strings.TrimSpace(providerGameID),
)
var item gamedomain.LaunchSession
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.CountryID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
}
@ -922,10 +934,10 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_orders (
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user_id, room_id, region_id, country_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
order.UserID, order.RoomID, order.RegionID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
order.UserID, order.RoomID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
); err != nil {
return gamedomain.GameOrder{}, false, err
}
@ -938,7 +950,7 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
user_id, room_id, region_id, country_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
@ -946,7 +958,7 @@ func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platfo
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
)
var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.CountryID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil
}
@ -1044,7 +1056,7 @@ func (r *Repository) getRepairOrderByProvider(ctx context.Context, appCode strin
func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
user_id, room_id, region_id, country_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders
WHERE app_code = ? AND order_id = ?
@ -1052,7 +1064,7 @@ func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, or
appcode.Normalize(appCode), strings.TrimSpace(orderID),
)
var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.CountryID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil
}
@ -1087,6 +1099,7 @@ func insertGameLevelEventOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, balanceAfter int64, nowMs int64) error {
eventID := "GameOrderSettled:" + order.OrderID
// 国家必须来自订单快照region_id 只能表达区域分桶,不能替代国家维度。
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO game_outbox (
app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
@ -1102,6 +1115,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
'room_id', ?,
'visible_region_id', ?,
'region_id', ?,
'country_id', ?,
'op_type', ?,
'coin_amount', ?,
'wallet_transaction_id', ?,
@ -1109,7 +1123,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
), 'pending', ?, ?)`,
order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
order.UserID, order.RoomID, order.RegionID, order.RegionID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
order.UserID, order.RoomID, order.RegionID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
nowMs, nowMs,
)
return err

View File

@ -130,6 +130,7 @@ func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest)
GameID: req.GetGameId(),
Scene: req.GetScene(),
RoomID: req.GetRoomId(),
CountryID: req.GetCountryId(),
AccessToken: req.GetAccessToken(),
})
if err != nil {
@ -169,6 +170,7 @@ func (s *Server) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*
GameID: req.GetGameId(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
CountryID: req.GetCountryId(),
StakeCoin: req.GetStakeCoin(),
RPSGesture: req.GetRpsGesture(),
})
@ -191,6 +193,7 @@ func (s *Server) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatc
GameID: req.GetGameId(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
CountryID: req.GetCountryId(),
StakeCoin: req.GetStakeCoin(),
MinPlayers: req.GetMinPlayers(),
MaxPlayers: req.GetMaxPlayers(),
@ -765,6 +768,7 @@ func diceMatchToProtoAt(match dicedomain.Match, nowMS int64) *gamev1.DiceMatch {
GameId: match.GameID,
RoomId: match.RoomID,
RegionId: match.RegionID,
CountryId: match.CountryID,
MinPlayers: match.MinPlayers,
MaxPlayers: match.MaxPlayers,
CurrentPlayers: match.CurrentPlayers,

View File

@ -22,6 +22,7 @@ type WalletClient interface {
SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error)
HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error)
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
@ -111,6 +112,10 @@ func (c *grpcWalletClient) HandleMifapayNotify(ctx context.Context, req *walletv
return c.client.HandleMifapayNotify(ctx, req)
}
func (c *grpcWalletClient) HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) {
return c.client.HandleV5PayNotify(ctx, req)
}
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
return c.client.ConfirmGooglePayment(ctx, req)
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@ -264,7 +265,7 @@ func wheelPrizeFromTier(tier *activityv1.WheelPrizeTier) wheelPrizeData {
MetadataJSON: tier.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount()),
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount(), tier.GetMetadataJson()),
}
}
@ -273,6 +274,14 @@ func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
return wheelDrawData{Rewards: []wheelPrizeData{}}
}
reward := wheelPrizeFromDraw(item)
rewards := make([]wheelPrizeData, 0, len(item.GetRewards()))
for _, detail := range item.GetRewards() {
rewards = append(rewards, wheelPrizeFromDrawReward(detail))
}
if len(rewards) == 0 {
rewards = []wheelPrizeData{reward}
}
rewards = mergeWheelPrizeData(rewards)
return wheelDrawData{
DrawID: item.GetDrawId(),
DrawIDs: item.GetDrawIds(),
@ -287,8 +296,8 @@ func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
WalletTransactionID: item.GetWalletTransactionId(),
CoinBalanceAfter: item.GetCoinBalanceAfter(),
MetadataJSON: item.GetMetadataJson(),
RewardValue: reward.Value,
Rewards: []wheelPrizeData{reward},
RewardValue: wheelPrizeTotalValue(rewards),
Rewards: rewards,
}
}
@ -307,16 +316,76 @@ func wheelPrizeFromDraw(item *activityv1.WheelDrawResult) wheelPrizeData {
MetadataJSON: item.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()),
}
}
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64) int64 {
func wheelPrizeFromDrawReward(item *activityv1.WheelDrawReward) wheelPrizeData {
if item == nil {
return wheelPrizeData{}
}
previewURL, animationURL := wheelAssetURLs(item.GetRewardType(), item.GetMetadataJson())
return wheelPrizeData{
TierID: item.GetSelectedTierId(),
DisplayName: item.GetSelectedTierId(),
RewardType: item.GetRewardType(),
RewardID: item.GetRewardId(),
RewardCount: item.GetRewardCount(),
RewardCoins: item.GetRewardCoins(),
MetadataJSON: item.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()),
}
}
func mergeWheelPrizeData(items []wheelPrizeData) []wheelPrizeData {
merged := make([]wheelPrizeData, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := wheelPrizeMergeKey(item)
if index, ok := byKey[key]; ok {
merged[index].RewardCount += item.RewardCount
continue
}
byKey[key] = len(merged)
merged = append(merged, item)
}
return merged
}
func wheelPrizeTotalValue(items []wheelPrizeData) int64 {
var total int64
for _, item := range items {
count := item.RewardCount
if count <= 0 {
count = 1
}
total += item.Value * count
}
return total
}
func wheelPrizeMergeKey(item wheelPrizeData) string {
return strings.Join([]string{
item.RewardType,
item.RewardID,
item.MetadataJSON,
item.PreviewURL,
item.AnimationURL,
fmt.Sprintf("%d", item.Value),
}, "|")
}
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64, metadataJSON string) int64 {
// H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。
// 道具和装扮不计入 RTP 时内部估值会是 0这时回退奖励数量避免页面拿到 value=0 后看起来像奖品没有价值。
// 道具和装扮不计入 RTP 时内部估值会是 0所以优先读取 metadata 里资源原价,避免页面只能展示 reward_count=1
if rewardCoins > 0 {
return rewardCoins
}
if displayValue := wheelMetadataDisplayValue(metadataJSON); displayValue > 0 {
return displayValue
}
if internalValueCoins > 0 {
return internalValueCoins
}
@ -326,6 +395,14 @@ func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardC
return 1
}
func wheelMetadataDisplayValue(metadataJSON string) int64 {
var metadata map[string]any
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
return 0
}
return firstMetadataInt64(metadata, "display_value", "displayValue", "coin_price", "coinPrice", "price", "value")
}
func wheelAssetURLs(rewardType string, metadataJSON string) (string, string) {
var metadata map[string]any
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
@ -347,3 +424,24 @@ func firstMetadataString(metadata map[string]any, keys ...string) string {
}
return ""
}
func firstMetadataInt64(metadata map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch typed := value.(type) {
case float64:
if typed > 0 {
return int64(typed)
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil && parsed > 0 {
return parsed
}
}
}
return 0
}

View File

@ -0,0 +1,19 @@
package activityapi
import "testing"
func TestWheelPrizeDisplayValueUsesMetadataPrice(t *testing.T) {
metadata := `{"display_value":1314,"coin_price":1314}`
if got := wheelPrizeDisplayValue(0, 0, 1, metadata); got != 1314 {
t.Fatalf("prop display value should use metadata price, got %d", got)
}
}
func TestWheelPrizeDisplayValueKeepsCoinAmountFirst(t *testing.T) {
metadata := `{"display_value":1314,"coin_price":1314}`
if got := wheelPrizeDisplayValue(99, 0, 1, metadata); got != 99 {
t.Fatalf("coin display value should use reward coins first, got %d", got)
}
}

View File

@ -155,6 +155,7 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
Scene: body.Scene,
RoomId: body.RoomID,
AccessToken: bearerAccessToken(request),
CountryId: userResp.GetUser().GetCountryId(),
})
httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err)
}
@ -237,6 +238,7 @@ func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Reques
GameId: gameID,
RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(),
CountryId: userResp.GetUser().GetCountryId(),
StakeCoin: body.StakeCoin,
RpsGesture: rpsGesture,
})
@ -297,6 +299,7 @@ func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.
GameId: gameID,
RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(),
CountryId: userResp.GetUser().GetCountryId(),
StakeCoin: body.StakeCoin,
MinPlayers: body.MinPlayers,
MaxPlayers: body.MaxPlayers,

View File

@ -234,6 +234,7 @@ type WalletHandlers struct {
SubmitH5RechargeTx http.HandlerFunc
GetH5RechargeOrder http.HandlerFunc
HandleMifapayNotify http.HandlerFunc
HandleV5PayNotify http.HandlerFunc
ConfirmGooglePayment http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ListCoinTransactions http.HandlerFunc
@ -567,6 +568,7 @@ func (r routes) registerWalletRoutes() {
r.public("/recharge/h5/orders/{order_id}/tx", http.MethodPost, h.SubmitH5RechargeTx)
r.public("/recharge/h5/orders/{order_id}", http.MethodGet, h.GetH5RechargeOrder)
r.public("/payment/mifapay/notify", http.MethodPost, h.HandleMifapayNotify)
r.public("/payment/v5pay/notify", http.MethodPost, h.HandleV5PayNotify)
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)

View File

@ -206,6 +206,36 @@ func TestGrantManagerResourceAllowsConfiguredDuration(t *testing.T) {
}
}
func TestGrantManagerResourceChecksCapabilityByWalletResourceType(t *testing.T) {
walletClient := &fakeWalletClient{
resourcesByID: map[int64]*walletv1.Resource{
202: {ResourceId: 202, ResourceType: "vehicle"},
},
}
hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-grant-car","target_user_id":"900001","resource_id":"202"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-grant-car")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.lastGetResource == nil || walletClient.lastGetResource.GetResourceId() != 202 {
t.Fatalf("resource lookup mismatch: %+v", walletClient.lastGetResource)
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_center" {
t.Fatalf("last manager capability should be final same-country actor check, got %+v", hostClient.lastCapability)
}
if !hostClient.sawCapability("manager_grant_vehicle") {
t.Fatalf("vehicle grant must check manager_grant_vehicle, saw %#v", hostClient.capabilities)
}
}
func TestGrantManagerResourceRejectsClientComputedQuantityOrInvalidDuration(t *testing.T) {
walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})

View File

@ -18,6 +18,13 @@ import (
const managerCenterCapability = "manager_center"
const managerResourceGrantCapability = "manager_resource_grant"
const managerGrantAvatarFrameCapability = "manager_grant_avatar_frame"
const managerGrantVehicleCapability = "manager_grant_vehicle"
const managerUpdateUserLevelCapability = "manager_update_user_level"
const managerAddBDLeaderCapability = "manager_add_bd_leader"
const managerAddAdminCapability = "manager_add_admin"
const managerAddSuperadminCapability = "manager_add_superadmin"
const managerBlockUserCapability = "manager_block_user"
const managerGrantDayMS int64 = 24 * 60 * 60 * 1000
const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
@ -129,6 +136,7 @@ func (h *Handler) getManagerOverview(writer http.ResponseWriter, request *http.R
"region_id": actor.GetRegionId(),
"region_code": actor.GetRegionCode(),
"region_name": actor.GetRegionName(),
"permissions": h.managerPermissionOverview(request),
})
}
@ -202,7 +210,8 @@ func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request
if _, ok := h.requireManagerCenterActor(writer, request); !ok {
return
}
if !h.requireManagerResourceGrantCapability(writer, request) {
resourceType := strings.TrimSpace(request.URL.Query().Get("resource_type"))
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resourceType)) {
return
}
page, pageSize, ok := managerPage(request)
@ -213,7 +222,7 @@ func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request
resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
ResourceType: resourceType,
Page: page,
PageSize: pageSize,
ActiveOnly: true,
@ -290,9 +299,6 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if !h.requireManagerResourceGrantCapability(writer, request) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
@ -303,6 +309,24 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resourceResp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceId: resourceID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resource := resourceResp.GetResource()
if resource == nil || resource.GetResourceId() <= 0 {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
// 写入口不能信任 H5 自报的资源类型;必须先从 wallet-service 读取 resource_id 的真实类型,再按头像框或座驾权限拒绝。
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resource.GetResourceType())) {
return
}
// 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
return
@ -419,6 +443,9 @@ func (h *Handler) listManagerBlocks(writer http.ResponseWriter, request *http.Re
if !ok {
return
}
if !h.requireManagerCapability(writer, request, managerBlockUserCapability) {
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
@ -466,6 +493,9 @@ func (h *Handler) createManagerBlock(writer http.ResponseWriter, request *http.R
if !ok {
return
}
if !h.requireManagerCapability(writer, request, managerBlockUserCapability) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
@ -560,6 +590,9 @@ func (h *Handler) setManagerUserLevel(writer http.ResponseWriter, request *http.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if !h.requireManagerCapability(writer, request, managerUpdateUserLevelCapability) {
return
}
// 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
return
@ -599,6 +632,9 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
if !ok {
return
}
if !h.requireManagerCapability(writer, request, managerAddBDLeaderCapability) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
@ -636,20 +672,7 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
}
func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: managerResourceGrantCapability,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return false
}
if !resp.GetAllowed() {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return false
}
return true
return h.requireManagerCapability(writer, request, managerResourceGrantCapability)
}
func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) {
@ -718,6 +741,51 @@ func (h *Handler) requireSameCountryTargetWithActor(writer http.ResponseWriter,
return target, true
}
func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bool {
permissions := map[string]bool{
"can_grant_avatar_frame": false,
"can_grant_vehicle": false,
"can_update_user_level": false,
"can_add_bd_leader": false,
"can_add_admin": false,
"can_add_superadmin": false,
"can_block_user": false,
}
for key, capability := range map[string]string{
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability,
"can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability,
} {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: capability,
})
if err != nil {
// overview 已经通过 manager_center 身份校验;单项权限读取失败时按关闭返回,避免 H5 展示不可确认的高危入口。
permissions[key] = false
continue
}
permissions[key] = resp.GetAllowed()
}
return permissions
}
func managerCapabilityForResourceType(resourceType string) string {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "avatar_frame":
return managerGrantAvatarFrameCapability
case "vehicle":
return managerGrantVehicleCapability
default:
return managerResourceGrantCapability
}
}
func sameCountry(left string, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) && strings.TrimSpace(left) != ""
}

View File

@ -442,6 +442,7 @@ type fakeUserHostClient struct {
listCoinSellersErr error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostProfiles map[int64]*userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
@ -465,6 +466,7 @@ type fakeUserHostClient struct {
kickResp *userv1.KickAgencyHostResponse
kickErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilities []string
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
}
@ -507,6 +509,8 @@ type fakeWalletClient struct {
h5GetOrderResp *walletv1.H5RechargeOrderResponse
lastMifaPayNotify *walletv1.HandleMifapayNotifyRequest
mifaPayNotifyResp *walletv1.HandleMifapayNotifyResponse
lastV5PayNotify *walletv1.HandleV5PayNotifyRequest
v5PayNotifyResp *walletv1.HandleV5PayNotifyResponse
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
@ -1393,6 +1397,9 @@ func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHo
if f.hostErr != nil {
return nil, f.hostErr
}
if f.hostProfiles != nil {
return &userv1.GetHostProfileResponse{HostProfile: f.hostProfiles[req.GetUserId()]}, nil
}
if f.hostProfile != nil {
return &userv1.GetHostProfileResponse{HostProfile: f.hostProfile}, nil
}
@ -1459,6 +1466,7 @@ func (f *fakeUserHostClient) GetAgency(_ context.Context, req *userv1.GetAgencyR
func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) {
f.lastCapability = req
f.capabilities = append(f.capabilities, req.GetCapability())
if f.capabilityErr != nil {
return nil, f.capabilityErr
}
@ -1468,6 +1476,15 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
}
func (f *fakeUserHostClient) sawCapability(capability string) bool {
for _, item := range f.capabilities {
if item == capability {
return true
}
}
return false
}
func (f *fakeUserHostClient) GetAgencyMembers(_ context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
f.lastMembers = req
if f.membersErr != nil {
@ -1693,6 +1710,17 @@ func (f *fakeWalletClient) HandleMifapayNotify(_ context.Context, req *walletv1.
return &walletv1.HandleMifapayNotifyResponse{Accepted: true, ResponseText: "SUCCESS", OrderId: "h5-order-1"}, nil
}
func (f *fakeWalletClient) HandleV5PayNotify(_ context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) {
f.lastV5PayNotify = req
if f.err != nil {
return nil, f.err
}
if f.v5PayNotifyResp != nil {
return f.v5PayNotifyResp, nil
}
return &walletv1.HandleV5PayNotifyResponse{Accepted: true, ResponseText: "success", OrderId: "h5-order-1"}, nil
}
func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
f.lastGoogleConfirm = req
if f.err != nil {
@ -1846,7 +1874,10 @@ func (f *fakeWalletClient) GetResource(_ context.Context, req *walletv1.GetResou
if f.resourcesByID != nil {
return &walletv1.GetResourceResponse{Resource: f.resourcesByID[req.GetResourceId()]}, nil
}
return &walletv1.GetResourceResponse{}, nil
return &walletv1.GetResourceResponse{Resource: &walletv1.Resource{
ResourceId: req.GetResourceId(),
ResourceType: "avatar_frame",
}}, nil
}
func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {
@ -2095,6 +2126,32 @@ func TestRoutesWriteUnifiedEnvelopeAndGenerateRequestID(t *testing.T) {
}
}
func TestV5PayNotifyReturnsPlainSuccess(t *testing.T) {
walletClient := &fakeWalletClient{}
handler := NewHandlerWithClients(nil, nil, nil)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"orderNo":"order-1","productType":"INSTAPAY_QR","amount":"20.00","businessType":"0","fee":"0.00","orderStatus":"2","sign":"stub-sign"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/payment/v5pay/notify", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("v5pay notify status mismatch: got %d body=%q", recorder.Code, recorder.Body.String())
}
if strings.TrimSpace(recorder.Body.String()) != "success" {
t.Fatalf("v5pay notify must return plain success, got %q", recorder.Body.String())
}
if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
t.Fatalf("v5pay notify must return text/plain, got %q", contentType)
}
if walletClient.lastV5PayNotify == nil || walletClient.lastV5PayNotify.GetFields()["orderNo"] != "order-1" || walletClient.lastV5PayNotify.GetFields()["amount"] != "20.00" {
t.Fatalf("v5pay notify should forward string fields to wallet-service: %+v", walletClient.lastV5PayNotify)
}
}
func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
tests := []struct {
name string
@ -2419,6 +2476,33 @@ func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) {
}
}
func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{hostProfiles: map[int64]*userv1.HostProfile{
43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001},
44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002},
}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-multi-host","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
scopes := roomClient.lastGift.GetTargetHostScopes()
if len(scopes) != 2 || !scopes[0].GetTargetIsHost() || scopes[0].GetTargetHostRegionId() != 8801 || !scopes[1].GetTargetIsHost() || scopes[1].GetTargetHostRegionId() != 8802 {
t.Fatalf("gateway must inject each target host scope: %+v", scopes)
}
if !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 {
t.Fatalf("legacy first-target host scope must remain populated: %+v", roomClient.lastGift)
}
}
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
@ -7264,6 +7348,42 @@ func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) {
}
}
func TestH5RechargeContextRejectsMissingIdentityWithoutToken(t *testing.T) {
walletClient := &fakeWalletClient{}
identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: {
UserId: 66,
DisplayUserId: "163066",
Username: "normal66",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
Country: "PH",
RegionId: 7200,
}}}
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/context", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if identityClient.lastResolve != nil {
t.Fatalf("missing h5 identity must be rejected before user-service lookup: %+v", identityClient.lastResolve)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != httpkit.CodeInvalidArgument || response.Message != "invalid argument" {
t.Fatalf("missing h5 identity response mismatch: %+v", response)
}
}
func TestH5RechargeOptionsNormalizesSaudiCountryNameToPaymentCountryCode(t *testing.T) {
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{

View File

@ -26,26 +26,30 @@ type resourceData struct {
}
type giftConfigData struct {
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
CPRelationType string `json:"cp_relation_type,omitempty"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
CPRelationType string `json:"cp_relation_type,omitempty"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
Source string `json:"source,omitempty"`
EntitlementID string `json:"entitlement_id,omitempty"`
OwnedQuantity int64 `json:"owned_quantity,omitempty"`
RemainingQuantity int64 `json:"remaining_quantity,omitempty"`
}
type giftTypeConfigData struct {

View File

@ -732,16 +732,67 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
gifts = append(gifts, giftFromProto(gift))
}
gifts = filterGiftPanelGifts(gifts, giftTypes)
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
panelGifts := append([]giftConfigData{}, gifts...)
panelGifts = append(panelGifts, bagGifts...)
recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
httpkit.WriteOK(writer, request, roomGiftPanelData{
CoinBalance: coinBalanceFromProto(balances),
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
Tabs: roomGiftTabs(gifts, giftTypes),
Gifts: gifts,
Tabs: roomGiftTabs(panelGifts, giftTypes),
Gifts: panelGifts,
QuantityPresets: []int32{1, 9, 99, 999},
})
}
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
return nil, nil
}
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: viewerUserID,
ResourceType: "gift",
ActiveOnly: true,
})
if err != nil {
return nil, err
}
giftByResourceID := make(map[int64]giftConfigData, len(gifts))
for _, gift := range gifts {
if gift.ResourceID > 0 {
giftByResourceID[gift.ResourceID] = gift
}
}
bagGifts := make([]giftConfigData, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
remaining := item.GetRemainingQuantity()
if remaining <= 0 {
remaining = item.GetQuantity()
}
if remaining <= 0 {
continue
}
gift, ok := giftByResourceID[item.GetResourceId()]
if !ok {
continue
}
// Bag tab 只承载用户实际拥有的 gift 权益;仍复用 gift 配置的价格/动效,避免背包资源和房间送礼协议出现两套展示口径。
gift.GiftTypeCode = "bag"
gift.Source = "bag"
gift.EntitlementID = item.GetEntitlementId()
gift.OwnedQuantity = item.GetQuantity()
gift.RemainingQuantity = remaining
bagGifts = append(bagGifts, gift)
}
return bagGifts, nil
}
// getRoomRocket 返回房间火箭物料配置和当前进度。
func (h *Handler) getRoomRocket(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
@ -1486,6 +1537,8 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
PoolID string `json:"pool_id"`
EntitlementID string `json:"entitlement_id"`
Source string `json:"source"`
}
if !httpkit.Decode(writer, request, &body) {
@ -1505,11 +1558,12 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteRPCError(writer, request, err)
return
}
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs))
targetHostScopes, err := h.resolveGiftTargetHostScopes(request, targetUserIDs)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
@ -1521,9 +1575,12 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
PoolId: body.PoolID,
SenderCountryId: senderCountryID,
SenderRegionId: senderRegionID,
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
TargetIsHost: firstTargetScope.GetTargetIsHost(),
TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(),
TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(),
TargetHostScopes: targetHostScopes,
EntitlementId: strings.TrimSpace(body.EntitlementID),
Source: strings.TrimSpace(body.Source),
})
httpkit.Write(writer, request, resp, err)
}
@ -1572,6 +1629,33 @@ func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID
return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil
}
func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID)
if err != nil {
return nil, err
}
// 批量送礼必须按接收方分别固化主播快照;只解析第一个 target 会让后续主播丢失工资钻石入账。
scopes = append(scopes, &roomv1.SendGiftTargetHostScope{
TargetUserId: targetUserID,
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
})
}
return scopes, nil
}
func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope {
for _, scope := range scopes {
if scope.GetTargetUserId() == targetUserID {
return scope
}
}
return &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
}
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
@ -2213,10 +2297,15 @@ func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDi
func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []roomGiftTabData {
tabs := []roomGiftTabData{{Key: "all", Order: 0}}
hasBag := false
if len(giftTypes) > 0 {
hasGift := make(map[string]bool, len(gifts))
for _, gift := range gifts {
key := strings.TrimSpace(gift.GiftTypeCode)
if key == "bag" || strings.TrimSpace(gift.Source) == "bag" {
hasBag = true
continue
}
if key != "" {
hasGift[key] = true
}
@ -2237,6 +2326,9 @@ func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []room
Order: giftType.SortOrder,
})
}
if hasBag {
tabs = append(tabs, roomGiftTabData{Key: "bag", GiftTypeCode: "bag", Label: "Bag", Order: 5})
}
return tabs
}
seen := map[string]bool{"all": true}

View File

@ -132,6 +132,28 @@ func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
}
}
func TestRoomGiftTabsIncludesBagForOwnedGifts(t *testing.T) {
tabs := roomGiftTabs(
[]giftConfigData{
{GiftTypeCode: "normal"},
{GiftTypeCode: "bag", Source: "bag", EntitlementID: "ent-1"},
},
[]giftTypeConfigData{
{TypeCode: "normal", Name: "普通礼物", TabKey: "Gift", SortOrder: 10},
},
)
if len(tabs) != 3 {
t.Fatalf("tab count mismatch: got %d tabs=%+v", len(tabs), tabs)
}
if tabs[1].Key != "normal" || tabs[1].Label != "Gift" {
t.Fatalf("normal tab mismatch: %+v", tabs[1])
}
if tabs[2].Key != "bag" || tabs[2].GiftTypeCode != "bag" || tabs[2].Label != "Bag" || tabs[2].Order != 5 {
t.Fatalf("bag tab mismatch: %+v", tabs[2])
}
}
func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) {
data := createRoomDataFromProto(&roomv1.CreateRoomResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1, ServerTimeMs: 1700000000000},

View File

@ -3,12 +3,16 @@ package walletapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"hyapp/services/gateway-service/internal/transport/http/useridinput"
@ -18,6 +22,7 @@ const (
h5RechargeAudienceNormal = "normal"
h5RechargeAudienceCoinSeller = "coin_seller"
h5PaymentProviderMifaPay = "mifapay"
h5PaymentProviderV5Pay = "v5pay"
h5PaymentProviderUSDTTRC20 = "usdt_trc20"
)
@ -148,7 +153,7 @@ func (h *Handler) getH5RechargeContext(writer http.ResponseWriter, request *http
})
}
// listH5RechargeOptions 返回已确认账号的商品档位、USDT 和可下单的 MiFaPay 方式。
// listH5RechargeOptions 返回已确认账号的商品档位、USDT 和可下单的本地三方支付方式。
func (h *Handler) listH5RechargeOptions(writer http.ResponseWriter, request *http.Request) {
target, ok := h.resolveH5RechargeTarget(writer, request, request.URL.Query().Get("display_user_id"))
if !ok {
@ -169,7 +174,7 @@ func (h *Handler) listH5RechargeOptions(writer http.ResponseWriter, request *htt
httpkit.WriteOK(writer, request, h5RechargeOptionsFromProto(target, resp))
}
// createH5RechargeOrder 创建 MiFaPay 或 USDT 外部充值订单;目标账号始终由 gateway 重新解析。
// createH5RechargeOrder 创建本地三方支付或 USDT 外部充值订单;目标账号始终由 gateway 重新解析。
func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *http.Request) {
var body h5RechargeOrderRequestBody
if !httpkit.Decode(writer, request, &body) {
@ -300,6 +305,58 @@ func (h *Handler) handleMifapayNotify(writer http.ResponseWriter, request *http.
writeMifaPayNotifyText(writer, http.StatusOK, "SUCCESS")
}
// handleV5PayNotify 按 V5Pay 要求返回小写纯文本 success验签和订单校验都下沉到 wallet-service。
func (h *Handler) handleV5PayNotify(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeV5PayNotifyText(writer, http.StatusBadGateway, "fail")
return
}
raw, err := io.ReadAll(io.LimitReader(request.Body, 2<<20))
if err != nil {
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
fields := map[string]string{}
if len(raw) > 0 {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err == nil {
fields = v5PayNotifyFields(payload)
} else if values, formErr := url.ParseQuery(string(raw)); formErr == nil {
for key, candidates := range values {
if len(candidates) > 0 {
fields[key] = strings.TrimSpace(candidates[0])
}
}
} else {
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
}
if len(fields) == 0 {
if err := request.ParseForm(); err != nil {
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
for key, values := range request.Form {
if len(values) > 0 {
fields[key] = strings.TrimSpace(values[0])
}
}
}
resp, err := h.walletClient.HandleV5PayNotify(request.Context(), &walletv1.HandleV5PayNotifyRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
RawJson: strings.TrimSpace(string(raw)),
Fields: fields,
})
if err != nil || !resp.GetAccepted() {
logV5PayNotifyRejected(request, err, resp, fields)
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
writeV5PayNotifyText(writer, http.StatusOK, firstNonEmptyString(resp.GetResponseText(), "success"))
}
func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *http.Request, displayUserID string) (h5RechargeTargetContext, bool) {
if h.walletClient == nil || h.userProfileClient == nil || h.userIdentityClient == nil || h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -308,6 +365,10 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h
userID := auth.UserIDFromContext(request.Context())
resolvedByToken := userID > 0
if !resolvedByToken {
if strings.TrimSpace(displayUserID) == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return h5RechargeTargetContext{}, false
}
// 无 token 入口的输入是“用户标识”,可能是 active 靓号、当前短号、默认数字 ID 或真实 user_id。
// 所有传给 wallet-service 的目标都必须先在 gateway 统一收敛成内部 user_id避免各业务入口自行解析导致靓号报错。
resolvedUserID, err := useridinput.Resolve(request.Context(), h.userIdentityClient, httpkit.UserMeta(request, ""), displayUserID)
@ -675,6 +736,8 @@ func normalizeH5PaymentProvider(value string, paymentMethodID int64) string {
switch value {
case "usdt", "usdt-trc20", "usdt_trc20", "trc20":
return h5PaymentProviderUSDTTRC20
case "v5", "v5pay", "v5_pay":
return h5PaymentProviderV5Pay
case "mifa", "mifapay", "third_party":
return h5PaymentProviderMifaPay
case "":
@ -685,6 +748,40 @@ func normalizeH5PaymentProvider(value string, paymentMethodID int64) string {
return value
}
func v5PayNotifyFields(payload map[string]any) map[string]string {
fields := make(map[string]string, len(payload))
for key, value := range payload {
switch typed := value.(type) {
case nil:
fields[key] = ""
case string:
fields[key] = strings.TrimSpace(typed)
default:
fields[key] = strings.TrimSpace(fmt.Sprint(typed))
}
}
return fields
}
func logV5PayNotifyRejected(request *http.Request, err error, resp *walletv1.HandleV5PayNotifyResponse, fields map[string]string) {
attrs := []slog.Attr{
slog.String("request_id", httpkit.RequestIDFromContext(request.Context())),
slog.String("app_code", appcode.FromContext(request.Context())),
slog.String("order_no", firstNonEmptyString(fields["orderNo"], fields["order_no"])),
slog.String("product_type", firstNonEmptyString(fields["productType"], fields["product_type"])),
slog.String("order_status", firstNonEmptyString(fields["orderStatus"], fields["order_status"])),
slog.Int("field_count", len(fields)),
}
if resp != nil {
attrs = append(attrs, slog.Bool("accepted", resp.GetAccepted()), slog.String("response_text", resp.GetResponseText()), slog.String("wallet_order_id", resp.GetOrderId()))
}
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
}
// V5Pay 回调失败必须有足够排障信息,但不能把完整 payload/sign 写进 gateway 日志,避免泄露签名材料。
logx.Warn(request.Context(), "v5pay_notify_rejected", attrs...)
}
func h5RechargeAuthMode(resolvedByToken bool) string {
if resolvedByToken {
return "token"
@ -715,3 +812,9 @@ func writeMifaPayNotifyText(writer http.ResponseWriter, statusCode int, body str
writer.WriteHeader(statusCode)
_, _ = writer.Write([]byte(body))
}
func writeV5PayNotifyText(writer http.ResponseWriter, statusCode int, body string) {
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(statusCode)
_, _ = writer.Write([]byte(body))
}

View File

@ -52,6 +52,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
SubmitH5RechargeTx: h.submitH5RechargeTx,
GetH5RechargeOrder: h.getH5RechargeOrder,
HandleMifapayNotify: h.handleMifapayNotify,
HandleV5PayNotify: h.handleV5PayNotify,
ConfirmGooglePayment: h.confirmGooglePayment,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ListCoinTransactions: h.listCoinTransactions,

View File

@ -304,6 +304,9 @@ CREATE TABLE IF NOT EXISTS room_human_robot_configs (
lucky_pause_max_ms BIGINT NOT NULL DEFAULT 20000 COMMENT '幸运礼物连击后最大暂停毫秒',
max_gift_senders BIGINT NOT NULL DEFAULT 1 COMMENT '同一时间最多送礼机器人数量',
country_pools_json JSON NOT NULL COMMENT '国家机器人池配置',
allowed_owner_ids_json JSON NOT NULL COMMENT '限定进入的房主展示短号/内部用户 ID空数组表示所有真人房',
country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用国家进房上限配置',
country_rules_json JSON NOT NULL COMMENT '每个国家最多同时进几个真人房的配置',
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -416,6 +416,10 @@ type SendGift struct {
PoolID string `json:"pool_id,omitempty"`
// GiftCount 是礼物数量,必须为正数。
GiftCount int32 `json:"gift_count"`
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。
EntitlementID string `json:"entitlement_id,omitempty"`
// Source 区分 coin/bag为空按普通金币链路兼容旧客户端。
Source string `json:"source,omitempty"`
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
@ -426,6 +430,8 @@ type SendGift struct {
TargetHostRegionID int64 `json:"target_host_region_id,omitempty"`
// TargetAgencyOwnerUserID 是 gateway 从 user-service active host profile 注入的代理收款人快照。
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
// TargetHostScopes 是 gateway 为批量送礼逐接收方固化的主播工资入账快照。
TargetHostScopes []GiftTargetHostScope `json:"target_host_scopes,omitempty"`
// BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
BillingReceiptID string `json:"billing_receipt_id,omitempty"`
// CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。
@ -489,6 +495,14 @@ type SendGift struct {
// Type 返回命令类型。
func (SendGift) Type() string { return "send_gift" }
// GiftTargetHostScope 记录一个接收方在 gateway 入站时看到的主播工资入账资格。
type GiftTargetHostScope struct {
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host,omitempty"`
TargetHostRegionID int64 `json:"target_host_region_id,omitempty"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
}
// RocketRewardGrant 记录发射命令中已经结算出的资源组发放结果。
type RocketRewardGrant struct {
RewardRole string `json:"reward_role"`

View File

@ -75,12 +75,15 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
GiftID: req.GetGiftId(),
PoolID: strings.TrimSpace(req.GetPoolId()),
GiftCount: req.GetGiftCount(),
EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
Source: strings.TrimSpace(req.GetSource()),
SenderCountryID: req.GetSenderCountryId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()),
RobotGift: robotOptions.Enabled && !robotOptions.RealRoomHeat,
RobotWalletGift: robotOptions.Enabled,
SyntheticLuckyGift: robotOptions.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
@ -123,6 +126,10 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
}
if strings.EqualFold(cmd.Source, "bag") && strings.TrimSpace(cmd.EntitlementID) == "" {
// 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift")
}
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, nil, err
@ -276,9 +283,10 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
}
records := make([]outbox.Record, 0, len(giftEvents)+4)
robotRecords := make([]outbox.Record, 0, len(giftEvents)+3)
if cmd.RobotGift {
// 机器人送礼只保留客户端展示所需事实,并写入 robot outbox由独立 worker/topic 补偿投递。
robotRecords := make([]outbox.Record, 0, len(giftEvents)+5)
// RobotWalletGift 只表示这笔礼物由机器人钱包结算;真实房机器人仍会同步写房间贡献、火箭和麦位热度,但展示事件必须走独立 outbox避免机器人流量抢占真人主 outbox。
robotOutboxGift := cmd.RobotWalletGift
if robotOutboxGift {
robotRecords = append(robotRecords, giftEvents...)
} else {
records = append(records, giftEvents...)
@ -300,27 +308,35 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
if err != nil {
return mutationResult{}, nil, err
}
if cmd.RobotGift {
// 房间热度和麦位收礼热度仍要给客户端展示,但走机器人 outbox不能占用真人房间主流程
if robotOutboxGift {
// 房间热度和麦位收礼热度仍要给客户端展示;是否真实计入统计由 RobotGift 语义单独控制,不能再拿投递通道反推业务真实性
robotRecords = append(robotRecords, heatEvent, rankEvent)
} else {
records = append(records, heatEvent, rankEvent)
}
if !cmd.RobotGift && !cmd.RobotWalletGift {
// 火箭只属于真人礼物主业务链路,机器人展示礼物不能触发火箭燃料 outbox
if !cmd.RobotGift {
// 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递
if rocketApply.progressEvent != nil {
progressEvent, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent)
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, progressEvent)
if robotOutboxGift {
robotRecords = append(robotRecords, progressEvent)
} else {
records = append(records, progressEvent)
}
}
if rocketApply.ignited != nil {
ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited)
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, ignitedEvent)
if robotOutboxGift {
robotRecords = append(robotRecords, ignitedEvent)
} else {
records = append(records, ignitedEvent)
}
}
}
if cmd.SyntheticLuckyGift && luckyGift != nil {
@ -345,7 +361,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
if err != nil {
return mutationResult{}, nil, err
}
if cmd.RobotGift {
if robotOutboxGift {
robotRecords = append(robotRecords, drawEvent)
} else {
records = append(records, drawEvent)
@ -467,6 +483,7 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
return billing, []giftTargetBilling{{TargetUserID: cmd.TargetUserID, CommandID: cmd.ID(), Billing: billing}}, nil
}
if len(cmd.TargetUserIDs) == 1 {
targetScope := giftTargetHostScopeFor(cmd, cmd.TargetUserID)
// 单目标保留原 wallet command_id避免改变已有幂等键、账务回执和排障路径。
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
CommandId: cmd.ID(),
@ -478,9 +495,11 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
AppCode: appcode.FromContext(ctx),
RegionId: roomMeta.VisibleRegionID,
SenderRegionId: cmd.SenderRegionID,
TargetIsHost: cmd.TargetIsHost,
TargetHostRegionId: cmd.TargetHostRegionID,
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
TargetIsHost: targetScope.TargetIsHost,
TargetHostRegionId: targetScope.TargetHostRegionID,
TargetAgencyOwnerUserId: targetScope.TargetAgencyOwnerUserID,
EntitlementId: cmd.EntitlementID,
ChargeSource: cmd.Source,
})
if err != nil {
return nil, nil, err
@ -490,15 +509,16 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
targets := make([]*walletv1.DebitGiftTarget, 0, len(cmd.TargetUserIDs))
for _, targetUserID := range cmd.TargetUserIDs {
targetScope := giftTargetHostScopeFor(cmd, targetUserID)
target := &walletv1.DebitGiftTarget{
CommandId: giftTargetCommandID(cmd.ID(), targetUserID, len(cmd.TargetUserIDs)),
TargetUserId: targetUserID,
}
if targetUserID == cmd.TargetUserID && cmd.TargetIsHost {
// 现有 room proto 只有单个 host scope多目标时只允许把该快照用于对应的第一个目标不能错误扩散到其他接收方
if targetScope.TargetIsHost {
// 每个目标只使用 gateway 为该 target 固化的主播快照,避免批量送礼时第一个接收方以外的主播丢工资钻石
target.TargetIsHost = true
target.TargetHostRegionId = cmd.TargetHostRegionID
target.TargetAgencyOwnerUserId = cmd.TargetAgencyOwnerUserID
target.TargetHostRegionId = targetScope.TargetHostRegionID
target.TargetAgencyOwnerUserId = targetScope.TargetAgencyOwnerUserID
}
targets = append(targets, target)
}
@ -512,6 +532,8 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
RegionId: roomMeta.VisibleRegionID,
SenderRegionId: cmd.SenderRegionID,
Targets: targets,
EntitlementId: cmd.EntitlementID,
ChargeSource: cmd.Source,
})
if err != nil {
return nil, nil, err
@ -547,6 +569,7 @@ func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.Reques
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
},
})
if err != nil {
@ -739,6 +762,43 @@ func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int
return ids
}
func giftTargetHostScopesFromProto(scopes []*roomv1.SendGiftTargetHostScope) []command.GiftTargetHostScope {
result := make([]command.GiftTargetHostScope, 0, len(scopes))
seen := make(map[int64]bool, len(scopes))
for _, scope := range scopes {
targetUserID := scope.GetTargetUserId()
if targetUserID <= 0 || seen[targetUserID] {
continue
}
seen[targetUserID] = true
result = append(result, command.GiftTargetHostScope{
TargetUserID: targetUserID,
TargetIsHost: scope.GetTargetIsHost(),
TargetHostRegionID: scope.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: scope.GetTargetAgencyOwnerUserId(),
})
}
return result
}
func giftTargetHostScopeFor(cmd command.SendGift, targetUserID int64) command.GiftTargetHostScope {
for _, scope := range cmd.TargetHostScopes {
if scope.TargetUserID == targetUserID {
return scope
}
}
if targetUserID == cmd.TargetUserID && cmd.TargetIsHost {
// 老 gateway 只会传单目标字段;保留这个兜底,避免滚动发布期间新 room-service 丢旧请求的主播入账。
return command.GiftTargetHostScope{
TargetUserID: targetUserID,
TargetIsHost: true,
TargetHostRegionID: cmd.TargetHostRegionID,
TargetAgencyOwnerUserID: cmd.TargetAgencyOwnerUserID,
}
}
return command.GiftTargetHostScope{TargetUserID: targetUserID}
}
func giftTargetCommandID(commandID string, targetUserID int64, targetCount int) string {
if targetCount <= 1 {
return commandID

View File

@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"math/rand"
"sort"
"strings"
"time"
@ -98,6 +99,8 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
s.stopAllHumanRoomRobotRuntimes()
return
}
countryLimits := humanRoomRobotCountryLimits(config)
runtimeCounts := s.reconcileHumanRoomRobotRuntimeLimits(config, countryLimits)
pools, err := s.loadHumanRoomRobotPools(ctx, config.AppCode)
if err != nil {
logx.Warn(ctx, "human_room_robot_pool_load_failed", slog.String("error", err.Error()))
@ -109,19 +112,31 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
if countryCode == "" || len(pool.RobotUserIDs) == 0 {
continue
}
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50)
if config.CountryLimitEnabled {
maxRooms := countryLimits[countryCode]
if maxRooms <= 0 || runtimeCounts[countryCode] >= maxRooms {
continue
}
}
// 限定房主 ID 只影响本轮候选房 SQL不额外读取 Room Cell 或 presence为空时保持原来的全房间扫描语义。
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, config.AllowedOwnerIDs)
if err != nil {
logx.Warn(ctx, "human_room_robot_candidate_rooms_failed", slog.String("country_code", countryCode), slog.String("error", err.Error()))
continue
}
for _, room := range rooms {
if config.CountryLimitEnabled && runtimeCounts[countryCode] >= countryLimits[countryCode] {
break
}
if s.humanRoomRobotRuntimeConfigChanged(room.RoomID, config.UpdatedAtMS) {
s.stopHumanRoomRobotRuntime(room.RoomID)
}
if s.humanRoomRobotRuntimeExists(room.RoomID) {
continue
}
s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs)
if s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs) {
runtimeCounts[countryCode]++
}
}
}
}
@ -146,9 +161,9 @@ func (s *Service) loadHumanRoomRobotPools(ctx context.Context, appCode string) (
return pools, nil
}
func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) {
func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) bool {
if parent.Err() != nil || roomID == "" || len(pool) == 0 {
return
return false
}
s.humanRobotRuntimeMu.Lock()
if s.humanRobotRuntimes == nil {
@ -156,7 +171,7 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
}
if _, exists := s.humanRobotRuntimes[roomID]; exists {
s.humanRobotRuntimeMu.Unlock()
return
return false
}
s.humanRobotRuntimeMu.Unlock()
@ -165,20 +180,20 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
selected, err := s.prepareHumanRoomRobots(roomCtx, config, roomID, pool, targetOnline)
if err != nil {
logx.Warn(roomCtx, "human_room_robot_prepare_failed", slog.String("room_id", roomID), slog.String("country_code", countryCode), slog.String("error", err.Error()))
return
return false
}
if len(selected) == 0 {
return
return false
}
s.humanRobotRuntimeMu.Lock()
if _, exists := s.humanRobotRuntimes[roomID]; exists {
s.humanRobotRuntimeMu.Unlock()
return
return false
}
ctx, cancel := context.WithCancel(roomCtx)
token := fmt.Sprintf("%s:%d", roomID, time.Now().UTC().UnixNano())
s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, configUpdatedAtMS: config.UpdatedAtMS}
s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, countryCode: countryCode, configUpdatedAtMS: config.UpdatedAtMS}
s.humanRobotRuntimeMu.Unlock()
logx.Info(ctx, "human_room_robot_runtime_started",
@ -191,6 +206,7 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
defer s.clearHumanRoomRobotRuntime(roomID, token)
s.runHumanRoomRobotRuntime(ctx, config, roomID, pool, selected, targetOnline)
}()
return true
}
func (s *Service) prepareHumanRoomRobots(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, targetOnline int) ([]int64, error) {
@ -446,10 +462,28 @@ func (s *Service) sendHumanRoomRobotGiftBestEffort(ctx context.Context, config H
RealRoomHeat: true,
})
if err != nil {
reconcileHumanRoomRobotGiftPresence(activity, senderID, targetID, err)
logx.Warn(ctx, "human_room_robot_send_gift_failed", slog.String("room_id", roomID), slog.String("kind", kind), slog.Int64("sender_user_id", senderID), slog.Int64("target_user_id", targetID), slog.String("gift_id", giftID), slog.String("error", err.Error()))
}
}
func reconcileHumanRoomRobotGiftPresence(activity *robotRoomActivity, senderID int64, targetID int64, err error) {
if activity == nil || err == nil || xerr.CodeOf(err) != xerr.NotFound {
return
}
message := xerr.MessageOf(err)
switch {
case strings.Contains(message, "sender not in room"):
// 送礼前不额外读 snapshot避免把每次礼物尝试都放大成一次 Room Cell/恢复查询;
// 直接复用 RobotSendGift 在 Room Cell 内的强一致校验结果,把已离房 sender 从运行时活跃集合移除。
activity.markInactive(senderID)
case strings.Contains(message, "target not in room"):
// target 已经不在 Room Cell presence 时继续保留 active 会造成后续随机命中反复失败;
// 这里只更新本地 human-room 运行时状态,补位仍由 fill/presence 循环按原有节奏执行。
activity.markInactive(targetID)
}
}
func markInitialHumanRoomGiftSenders(activity *robotRoomActivity, initialActive []int64, maxSenders int) {
if activity == nil || maxSenders <= 0 || len(initialActive) == 0 {
return
@ -589,6 +623,40 @@ func (s *Service) firstHumanRoomFreeSeatNo(ctx context.Context, roomID string) i
return seats[0]
}
func (s *Service) reconcileHumanRoomRobotRuntimeLimits(config HumanRoomRobotConfig, countryLimits map[string]int) map[string]int {
counts := make(map[string]int, len(countryLimits))
cancels := make([]context.CancelFunc, 0)
s.humanRobotRuntimeMu.Lock()
for roomID, runtime := range s.humanRobotRuntimes {
countryCode := normalizeRoomCountryCode(runtime.countryCode)
// 配置版本变化后直接取消旧 runtime保证停留、礼物、国家限额等行为不会继续沿用旧快照。
if runtime.configUpdatedAtMS > 0 && config.UpdatedAtMS > 0 && runtime.configUpdatedAtMS != config.UpdatedAtMS {
if runtime.cancel != nil {
cancels = append(cancels, runtime.cancel)
}
delete(s.humanRobotRuntimes, roomID)
continue
}
// 国家限额开启后,未配置国家和超过本国家房间上限的 runtime 都必须停掉,避免旧扫描结果继续占用真人房。
if config.CountryLimitEnabled {
limit := countryLimits[countryCode]
if countryCode == "" || limit <= 0 || counts[countryCode] >= limit {
if runtime.cancel != nil {
cancels = append(cancels, runtime.cancel)
}
delete(s.humanRobotRuntimes, roomID)
continue
}
}
counts[countryCode]++
}
s.humanRobotRuntimeMu.Unlock()
for _, cancel := range cancels {
cancel()
}
return counts
}
func (s *Service) humanRoomRobotRuntimeExists(roomID string) bool {
s.humanRobotRuntimeMu.Lock()
defer s.humanRobotRuntimeMu.Unlock()
@ -645,6 +713,10 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
if input == nil {
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "human room robot config is required")
}
countryRules, err := normalizeHumanRoomRobotCountryRules(input.GetCountryRules())
if err != nil {
return HumanRoomRobotConfig{}, err
}
config := HumanRoomRobotConfig{
AppCode: appcode.Normalize(appCode),
Enabled: input.GetEnabled(),
@ -667,6 +739,9 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
LuckyPauseMinMS: input.GetLuckyPauseMinMs(),
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
MaxGiftSenders: input.GetMaxGiftSenders(),
AllowedOwnerIDs: normalizeStringSet(input.GetAllowedOwnerIds()),
CountryLimitEnabled: input.GetCountryLimitEnabled(),
CountryRules: countryRules,
UpdatedByAdminID: adminID,
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: nowMS,
@ -699,6 +774,9 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
if config.MaxGiftSenders <= 0 {
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "max_gift_senders is required")
}
if config.Enabled && config.CountryLimitEnabled && len(config.CountryRules) == 0 {
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "country_rules is required")
}
return config, nil
}
@ -759,6 +837,45 @@ func defaultHumanRoomRobotConfig(appCode string, nowMS int64) HumanRoomRobotConf
return withHumanRoomRobotDefaults(HumanRoomRobotConfig{AppCode: appcode.Normalize(appCode), CreatedAtMS: nowMS, UpdatedAtMS: nowMS})
}
func normalizeHumanRoomRobotCountryRules(values []*roomv1.AdminHumanRoomRobotCountryRule) ([]HumanRoomRobotCountryRule, error) {
seen := make(map[string]bool, len(values))
rules := make([]HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
countryCode := normalizeRoomCountryCode(value.GetCountryCode())
if countryCode == "" {
return nil, xerr.New(xerr.InvalidArgument, "country_code is invalid")
}
if seen[countryCode] {
return nil, xerr.New(xerr.InvalidArgument, "country_code is duplicated")
}
if value.GetMaxRoomCount() <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "country max_room_count is invalid")
}
seen[countryCode] = true
rules = append(rules, HumanRoomRobotCountryRule{CountryCode: countryCode, MaxRoomCount: value.GetMaxRoomCount()})
}
sort.Slice(rules, func(i, j int) bool {
return rules[i].CountryCode < rules[j].CountryCode
})
return rules, nil
}
func humanRoomRobotCountryLimits(config HumanRoomRobotConfig) map[string]int {
limits := make(map[string]int, len(config.CountryRules))
if !config.CountryLimitEnabled {
return limits
}
for _, rule := range config.CountryRules {
countryCode := normalizeRoomCountryCode(rule.CountryCode)
if countryCode == "" || rule.MaxRoomCount <= 0 {
continue
}
// 同国家重复规则在保存阶段已被拒绝;这里保留最后一次赋值,保证历史脏数据不会导致 panic。
limits[countryCode] = int(rule.MaxRoomCount)
}
return limits
}
func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHumanRoomRobotConfig {
config = withHumanRoomRobotDefaults(config)
out := &roomv1.AdminHumanRoomRobotConfig{
@ -783,10 +900,18 @@ func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHuman
LuckyPauseMinMs: config.LuckyPauseMinMS,
LuckyPauseMaxMs: config.LuckyPauseMaxMS,
MaxGiftSenders: config.MaxGiftSenders,
AllowedOwnerIds: append([]string(nil), config.AllowedOwnerIDs...),
CountryLimitEnabled: config.CountryLimitEnabled,
UpdatedByAdminId: config.UpdatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
for _, rule := range config.CountryRules {
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
})
}
return out
}

View File

@ -4,6 +4,9 @@ import (
"math/rand"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/xerr"
)
func TestHumanRoomRobotGiftSenderLimit(t *testing.T) {
@ -21,6 +24,32 @@ func TestHumanRoomRobotGiftSenderLimit(t *testing.T) {
}
}
func TestHumanRoomRobotGiftPresenceDriftRemovesStaleUsers(t *testing.T) {
activity := newRobotRoomActivity([]int64{101, 102, 103})
for _, userID := range []int64{101, 102, 103} {
activity.markActive(userID)
}
activity.markGiftSender(101)
reconcileHumanRoomRobotGiftPresence(activity, 101, 102, xerr.New(xerr.NotFound, "sender not in room"))
if activity.isActive(101) || activity.isGiftSender(101) {
t.Fatalf("stale sender must be removed from active and gift sender sets")
}
if !activity.isActive(102) {
t.Fatalf("target must stay active when sender is stale")
}
activity.markActive(101)
activity.markGiftSender(101)
reconcileHumanRoomRobotGiftPresence(activity, 101, 102, xerr.New(xerr.NotFound, "target not in room"))
if !activity.isActive(101) || !activity.isGiftSender(101) {
t.Fatalf("sender must stay active when target is stale")
}
if activity.isActive(102) {
t.Fatalf("stale target must be removed from active set")
}
}
func TestHumanRoomRobotNormalGiftDelayRange(t *testing.T) {
rng := rand.New(rand.NewSource(1))
config := HumanRoomRobotConfig{NormalGiftIntervalMinMS: 1000, NormalGiftIntervalMaxMS: 20000}
@ -50,3 +79,29 @@ func TestRandomHumanRoomTargetOnlineLegacyFixedValue(t *testing.T) {
}
}
}
func TestNormalizeHumanRoomRobotCountryRules(t *testing.T) {
rules, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
{CountryCode: "sa", MaxRoomCount: 2},
{CountryCode: "AE", MaxRoomCount: 1},
})
if err != nil {
t.Fatalf("normalize country rules failed: %v", err)
}
if len(rules) != 2 || rules[0].CountryCode != "AE" || rules[1].CountryCode != "SA" || rules[1].MaxRoomCount != 2 {
t.Fatalf("country rules mismatch: %+v", rules)
}
limits := humanRoomRobotCountryLimits(HumanRoomRobotConfig{CountryLimitEnabled: true, CountryRules: rules})
if limits["SA"] != 2 || limits["AE"] != 1 || len(limits) != 2 {
t.Fatalf("country limits mismatch: %+v", limits)
}
}
func TestNormalizeHumanRoomRobotCountryRulesRejectsDuplicate(t *testing.T) {
if _, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
{CountryCode: "SA", MaxRoomCount: 2},
{CountryCode: "sa", MaxRoomCount: 3},
}); err == nil {
t.Fatalf("duplicated country rules must be rejected")
}
}

View File

@ -132,3 +132,75 @@ func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) {
t.Fatalf("room-service must pass host period scope to wallet: %+v", wallet.lastDebit)
}
}
func TestSendGiftPassesBagSourceToWallet(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{
BillingReceiptId: "receipt-bag-single",
CoinSpent: 20,
ChargeAmount: 20,
ChargeAssetType: "BAG",
HeatValue: 20,
GiftTypeCode: "normal",
},
{
BillingReceiptId: "receipt-bag-batch-1",
CoinSpent: 10,
ChargeAmount: 10,
ChargeAssetType: "BAG",
HeatValue: 10,
GiftTypeCode: "normal",
},
{
BillingReceiptId: "receipt-bag-batch-2",
CoinSpent: 10,
ChargeAmount: 10,
ChargeAssetType: "BAG",
HeatValue: 10,
GiftTypeCode: "normal",
},
}}
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, wallet, now)
roomID := "room-bag-source"
senderID := int64(2101)
targetOneID := int64(2102)
targetTwoID := int64(2103)
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetOneID)
joinRocketRoom(t, ctx, svc, roomID, targetTwoID)
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, senderID, "bag-single"),
TargetType: "user",
TargetUserId: targetOneID,
GiftId: "gift-bag-single",
GiftCount: 2,
EntitlementId: "ent-bag-single",
Source: "bag",
}); err != nil {
t.Fatalf("send single bag gift failed: %v", err)
}
if wallet.lastDebit == nil || wallet.lastDebit.GetEntitlementId() != "ent-bag-single" || wallet.lastDebit.GetChargeSource() != "bag" {
t.Fatalf("single bag gift must pass entitlement/source to wallet: %+v", wallet.lastDebit)
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, senderID, "bag-batch"),
TargetType: "user",
TargetUserId: targetOneID,
TargetUserIds: []int64{targetOneID, targetTwoID},
GiftId: "gift-bag-batch",
GiftCount: 1,
EntitlementId: "ent-bag-batch",
Source: "bag",
}); err != nil {
t.Fatalf("send batch bag gift failed: %v", err)
}
if wallet.lastBatch == nil || wallet.lastBatch.GetEntitlementId() != "ent-bag-batch" || wallet.lastBatch.GetChargeSource() != "bag" {
t.Fatalf("batch bag gift must pass entitlement/source to wallet: %+v", wallet.lastBatch)
}
}

View File

@ -265,6 +265,11 @@ type HumanRoomRobotCountryPool struct {
RobotUserIDs []int64
}
type HumanRoomRobotCountryRule struct {
CountryCode string
MaxRoomCount int32
}
type HumanRoomRobotConfig struct {
AppCode string
Enabled bool
@ -288,6 +293,9 @@ type HumanRoomRobotConfig struct {
LuckyPauseMaxMS int64
MaxGiftSenders int64
CountryPools []HumanRoomRobotCountryPool
AllowedOwnerIDs []string
CountryLimitEnabled bool
CountryRules []HumanRoomRobotCountryRule
UpdatedByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64
@ -601,8 +609,24 @@ type RoomRelatedFeedQuery struct {
CursorRoomID string
}
// Repository 聚合 room-service 在首版需要的全部持久化读写。
// Repository 聚合 room-service 当前运行需要的全部持久化能力。
//
// 总接口继续保留给 Service 装配使用,子接口按变化原因拆开,后续单测可以只依赖
// CommandStore、RoomListStore 或 OutboxStore 这类窄接口,避免为一个用例 mock 整个仓储面。
type Repository interface {
RoomMetaStore
CommandStore
SnapshotStore
RoomConfigStore
OutboxStore
RoomListStore
PresenceStore
AdminRoomStore
RobotRoomStore
}
// RoomMetaStore 保存房间基础元数据和房主背景素材;它是 Room Cell 恢复前的轻量入口。
type RoomMetaStore interface {
// SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。
SaveRoomMeta(ctx context.Context, meta RoomMeta) error
// GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。
@ -615,12 +639,30 @@ type Repository interface {
GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (RoomBackgroundImage, bool, error)
// ListRoomBackgrounds 按房间读取背景图素材列表,不读取 Room Cell 状态。
ListRoomBackgrounds(ctx context.Context, roomID string) ([]RoomBackgroundImage, error)
}
// CommandStore 保存命令幂等记录和一次 Room Cell mutation 的事务提交边界。
type CommandStore interface {
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error)
// SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。
SaveCommand(ctx context.Context, record CommandRecord) error
// SaveMutation 在同一事务中提交命令日志、outbox 事件和可选房间生命周期状态。
SaveMutation(ctx context.Context, commit MutationCommit) error
// ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。
ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error)
}
// SnapshotStore 保存和读取最新快照;它只负责降低恢复成本,不替代 command log。
type SnapshotStore interface {
// SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。
SaveSnapshot(ctx context.Context, snapshot SnapshotRecord) error
// GetLatestSnapshot 读取当前最新快照,恢复时优先使用它减少回放成本。
GetLatestSnapshot(ctx context.Context, roomID string) (SnapshotRecord, bool, error)
}
// RoomConfigStore 管理后台低频配置;读取失败由调用方决定是否使用内置默认值。
type RoomConfigStore interface {
// GetRoomSeatConfig 读取后台配置的房间座位数规则;不存在时 service 层使用内置默认值。
GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, bool, error)
// UpsertRoomSeatConfig 写入房间座位数配置,供本地验证和后台配置共享同一张表。
@ -629,12 +671,10 @@ type Repository interface {
GetRoomRocketConfig(ctx context.Context) (RoomRocketConfig, bool, error)
// UpsertRoomRocketConfig 写入语音房火箭配置,主要供集成测试和本地验证复用生产表结构。
UpsertRoomRocketConfig(ctx context.Context, config RoomRocketConfig) error
// ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。
ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error)
// SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。
SaveSnapshot(ctx context.Context, snapshot SnapshotRecord) error
// GetLatestSnapshot 读取当前最新快照,恢复时优先使用它减少回放成本。
GetLatestSnapshot(ctx context.Context, roomID string) (SnapshotRecord, bool, error)
}
// OutboxStore 管理 room_outbox 和 robot outbox 的补偿投递生命周期。
type OutboxStore interface {
// SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义。
SaveOutbox(ctx context.Context, records []outbox.Record) error
// ListPendingOutbox 扫描待补偿投递事件。
@ -657,6 +697,10 @@ type Repository interface {
MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error
// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。
MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error
}
// RoomListStore 管理发现页、Mine 页和关系房间流读模型;它不读取 Room Cell 内存。
type RoomListStore interface {
// UpsertRoomListEntry 写入或更新房间列表读模型;失败不应破坏 Room Cell 已提交状态。
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
// ListRoomListEntries 按区域和 tab 查询房间列表,不访问 Room Cell 内存。
@ -677,17 +721,29 @@ type Repository interface {
ListRoomFollowEntries(ctx context.Context, query RoomFollowQuery) ([]RoomListEntry, error)
// ListRoomRelatedFeedEntries 根据 user-service 关系事实查询 active 房间卡片,不持久化关系状态。
ListRoomRelatedFeedEntries(ctx context.Context, query RoomRelatedFeedQuery) ([]RoomListEntry, error)
}
// PresenceStore 管理当前房间 presence 投影;权威状态仍然来自 Room Cell 快照。
type PresenceStore interface {
// ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。
ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error
// GetCurrentRoomPresence 查询用户当前 active 房间 presence不扫描房间列表或快照。
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
ListRoomOnlineUsers(ctx context.Context, query RoomOnlineUserQuery) (RoomOnlineUserPage, error)
}
// AdminRoomStore 服务后台房间列表、详情和置顶管理;后台仍通过 room-service 访问 owner 数据。
type AdminRoomStore interface {
AdminListRooms(ctx context.Context, query AdminRoomListQuery) ([]AdminRoomListEntry, int64, error)
AdminGetRoom(ctx context.Context, roomID string, nowMS int64) (AdminRoomListEntry, bool, error)
AdminListRoomPins(ctx context.Context, query AdminRoomPinQuery) ([]AdminRoomPinEntry, int64, error)
AdminCreateRoomPin(ctx context.Context, input AdminCreateRoomPinInput) (AdminRoomPinEntry, bool, error)
AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (AdminRoomPinEntry, bool, error)
}
// RobotRoomStore 管理机器人房间配置、账号占用和真人房补机器人候选查询。
type RobotRoomStore interface {
ListRobotRooms(ctx context.Context, query RobotRoomListQuery) ([]RobotRoomConfig, int64, error)
GetRobotRoom(ctx context.Context, roomID string) (RobotRoomConfig, bool, error)
CreateRobotRoomConfig(ctx context.Context, input CreateRobotRoomConfigInput) (RobotRoomConfig, error)
@ -696,7 +752,7 @@ type Repository interface {
ListActiveRobotRooms(ctx context.Context) ([]RobotRoomConfig, error)
GetHumanRoomRobotConfig(ctx context.Context) (HumanRoomRobotConfig, bool, error)
UpsertHumanRoomRobotConfig(ctx context.Context, config HumanRoomRobotConfig) (HumanRoomRobotConfig, error)
ListHumanRobotCandidateRooms(ctx context.Context, appCode string, countryCode string, maxOnline int32, fullStopOnline int32, limit int) ([]RoomListEntry, error)
ListHumanRobotCandidateRooms(ctx context.Context, appCode string, countryCode string, maxOnline int32, fullStopOnline int32, limit int, allowedOwnerIDs []string) ([]RoomListEntry, error)
}
// RoomPresenceSnapshot 是 repository 投影接口的稳定入参,避免存储层反向依赖 protobuf。

View File

@ -131,6 +131,10 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "rose",
GiftCount: 2,
TargetHostScopes: []*roomv1.SendGiftTargetHostScope{
{TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001},
{TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002},
},
})
if err != nil {
t.Fatalf("send multi-target gift failed: %v", err)
@ -153,6 +157,10 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
if wallet.lastBatch.GetTargets()[0].GetCommandId() != "cmd-gift-multi:target:202" || wallet.lastBatch.GetTargets()[1].GetCommandId() != "cmd-gift-multi:target:303" {
t.Fatalf("wallet batch target command ids mismatch: %+v", wallet.lastBatch.GetTargets())
}
if !wallet.lastBatch.GetTargets()[0].GetTargetIsHost() || wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[0].GetTargetAgencyOwnerUserId() != 30001 ||
!wallet.lastBatch.GetTargets()[1].GetTargetIsHost() || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8802 || wallet.lastBatch.GetTargets()[1].GetTargetAgencyOwnerUserId() != 30002 {
t.Fatalf("wallet batch target host scopes mismatch: %+v", wallet.lastBatch.GetTargets())
}
events := roomGiftSentEvents(t, ctx, repository)
if len(events) != 2 {

View File

@ -114,6 +114,8 @@ type robotRoomRuntime struct {
cancel context.CancelFunc
// token 区分同一房间的前后两次启动,旧 goroutine 退出时不能误删新 runtime。
token string
// countryCode 记录真人房间机器人 runtime 所属国家,用于后台国家限额变更后精确清理。
countryCode string
// configUpdatedAtMS 记录启动 runtime 时采用的配置版本;后台改配置后扫描器据此重启真人房间机器人 runtime。
configUpdatedAtMS int64
}

View File

@ -153,6 +153,98 @@ func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) {
}
}
func TestRealRoomRobotGiftKeepsRoomContributionAndUsesRobotOutbox(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-real-room-robot",
CoinSpent: 100,
ChargeAmount: 100,
HeatValue: 100,
ChargeAssetType: "ROBOT_COIN",
GiftTypeCode: "normal",
}}}
leaderboard := &recordingRoomGiftLeaderboard{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-real-room-robot-outbox",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 100,
Clock: now,
RoomGiftLeaderboard: leaderboard,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "real-room-robot-outbox"
senderID := int64(64001)
targetID := int64(64002)
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, targetID, "real-room-robot-target-up"),
SeatNo: 1,
}); err != nil {
t.Fatalf("target mic up failed: %v", err)
}
beforeMain := outboxEventCounts(t, ctx, repository)
beforeRobot := robotOutboxEventCounts(t, ctx, repository)
resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
Meta: rocketMeta(roomID, senderID, "real-room-robot-gift"),
TargetUserID: targetID,
GiftID: "real-room-robot-gift",
GiftCount: 1,
PoolID: "human_robot_super_lucky_display",
RobotUserIDs: []int64{senderID, targetID},
SyntheticMultiplierPPM: 5_000_000,
RealRoomHeat: true,
})
if err != nil {
t.Fatalf("real room robot gift failed: %v", err)
}
if resp.GetRoomHeat() != 100 {
t.Fatalf("real room robot gift must update room heat, got %d", resp.GetRoomHeat())
}
if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 100 {
t.Fatalf("real room robot gift must update target gift value: %+v", user)
}
if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 100 {
t.Fatalf("real room robot gift must update mic gift value: %+v", seat)
}
if len(resp.GetGiftRank()) == 0 || resp.GetGiftRank()[0].GetUserId() != senderID || resp.GetGiftRank()[0].GetGiftValue() != 100 {
t.Fatalf("real room robot gift must update room gift rank: %+v", resp.GetGiftRank())
}
if resp.GetRocket() == nil || len(resp.GetRocket().GetPendingLaunches()) != 1 {
t.Fatalf("real room robot gift must fuel and ignite room rocket: %+v", resp.GetRocket())
}
if len(leaderboard.increments) != 1 || leaderboard.increments[0].RoomID != roomID || leaderboard.increments[0].CoinSpent != 100 {
t.Fatalf("real room robot gift must keep cross-room contribution increment: %+v", leaderboard.increments)
}
afterMain := outboxEventCounts(t, ctx, repository)
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {
if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 {
t.Fatalf("real room robot gift must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain)
}
}
afterRobot := robotOutboxEventCounts(t, ctx, repository)
wantRobotDeltas := map[string]int{
"RoomGiftSent": 1,
"RoomHeatChanged": 1,
"RoomRankChanged": 1,
"RoomRobotLuckyGiftDrawn": 1,
"RoomRocketFuelChanged": 1,
"RoomRocketIgnited": 1,
}
for eventType, want := range wantRobotDeltas {
if delta := afterRobot[eventType] - beforeRobot[eventType]; delta != want {
t.Fatalf("real room robot gift robot outbox %s delta=%d want=%d counts=%+v", eventType, delta, want, afterRobot)
}
}
}
func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -0,0 +1,471 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
// GetRoomSeatConfig 读取当前 App 的房间座位数配置。
func (r *Repository) GetRoomSeatConfig(ctx context.Context) (roomservice.RoomSeatConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, allowed_seat_counts, default_seat_count, updated_at_ms
FROM room_seat_configs
WHERE app_code = ?`,
appcode.FromContext(ctx),
)
var config roomservice.RoomSeatConfig
var rawAllowed string
if err := row.Scan(&config.AppCode, &rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomSeatConfig{}, false, nil
}
return roomservice.RoomSeatConfig{}, false, err
}
if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
return roomservice.RoomSeatConfig{}, false, err
}
return config, true, nil
}
// UpsertRoomSeatConfig 写入当前 App 的房间座位数配置。
func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservice.RoomSeatConfig) error {
appCode := normalizedRecordAppCode(ctx, config.AppCode)
rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
if err != nil {
return err
}
nowMS := config.UpdatedAtMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
allowed_seat_counts = VALUES(allowed_seat_counts),
default_seat_count = VALUES(default_seat_count),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
string(rawAllowed),
config.DefaultSeatCount,
nowMS,
nowMS,
)
return err
}
// GetRoomRocketConfig 读取当前 App 的语音房火箭配置。
func (r *Repository) GetRoomRocketConfig(ctx context.Context) (roomservice.RoomRocketConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, enabled, config_version, fuel_source, launch_delay_ms,
broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
FROM room_rocket_configs
WHERE app_code = ?`,
appcode.FromContext(ctx),
)
var config roomservice.RoomRocketConfig
var enabled int
var broadcastEnabled int
var rawLevels string
var rawRules string
if err := row.Scan(
&config.AppCode,
&enabled,
&config.ConfigVersion,
&config.FuelSource,
&config.LaunchDelayMS,
&broadcastEnabled,
&config.BroadcastScope,
&config.BroadcastDelayMS,
&config.RewardStackPolicy,
&rawLevels,
&rawRules,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomRocketConfig{}, false, nil
}
return roomservice.RoomRocketConfig{}, false, err
}
config.Enabled = enabled == 1
config.BroadcastEnabled = broadcastEnabled == 1
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
return roomservice.RoomRocketConfig{}, false, err
}
if err := json.Unmarshal([]byte(rawRules), &config.GiftFuelRules); err != nil {
return roomservice.RoomRocketConfig{}, false, err
}
return config, true, nil
}
// UpsertRoomRocketConfig 写入当前 App 的语音房火箭配置。
func (r *Repository) UpsertRoomRocketConfig(ctx context.Context, config roomservice.RoomRocketConfig) error {
appCode := normalizedRecordAppCode(ctx, config.AppCode)
rawLevels, err := json.Marshal(config.Levels)
if err != nil {
return err
}
rawRules, err := json.Marshal(config.GiftFuelRules)
if err != nil {
return err
}
nowMS := config.UpdatedAtMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
createdAtMS := config.CreatedAtMS
if createdAtMS <= 0 {
createdAtMS = nowMS
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO room_rocket_configs (
app_code, enabled, config_version, fuel_source, launch_delay_ms,
broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
config_version = VALUES(config_version),
fuel_source = VALUES(fuel_source),
launch_delay_ms = VALUES(launch_delay_ms),
broadcast_enabled = VALUES(broadcast_enabled),
broadcast_scope = VALUES(broadcast_scope),
broadcast_delay_ms = VALUES(broadcast_delay_ms),
reward_stack_policy = VALUES(reward_stack_policy),
levels_json = VALUES(levels_json),
gift_fuel_rules_json = VALUES(gift_fuel_rules_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
boolToInt(config.Enabled),
config.ConfigVersion,
config.FuelSource,
config.LaunchDelayMS,
boolToInt(config.BroadcastEnabled),
config.BroadcastScope,
config.BroadcastDelayMS,
config.RewardStackPolicy,
string(rawLevels),
string(rawRules),
config.UpdatedByAdminID,
createdAtMS,
nowMS,
)
return err
}
func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) {
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
if query.NowMS <= 0 {
query.NowMS = time.Now().UTC().UnixMilli()
}
where, args := adminRoomWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_list_entries rle `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
contributionExpr := adminRoomContributionExpr(query.NowMS)
rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL(contributionExpr)+where+` ORDER BY `+adminRoomOrderBy(query, contributionExpr)+` LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]roomservice.AdminRoomListEntry, 0, query.PageSize)
for rows.Next() {
item, err := scanAdminRoom(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *Repository) AdminGetRoom(ctx context.Context, roomID string, nowMS int64) (roomservice.AdminRoomListEntry, bool, error) {
contributionExpr := adminRoomContributionExpr(nowMS)
row := r.db.QueryRowContext(ctx, adminRoomSelectSQL(contributionExpr)+` WHERE rle.app_code = ? AND rle.room_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
item, err := scanAdminRoom(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.AdminRoomListEntry{}, false, nil
}
if err != nil {
return roomservice.AdminRoomListEntry{}, false, err
}
return item, true, nil
}
func adminRoomSelectSQL(contributionExpr string) string {
return `
SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id,
rle.title, rle.cover_url, rle.mode, rle.status, rle.close_reason,
rle.closed_by_admin_id, rle.closed_by_admin_name, rle.closed_at_ms,
` + contributionExpr + ` AS room_contribution, rle.online_count, rle.seat_count, rle.occupied_seat_count,
rle.sort_score, rle.created_at_ms, rle.updated_at_ms
FROM room_list_entries rle `
}
func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (string, []any) {
where := []string{"rle.app_code = ?"}
app := appcode.Normalize(query.AppCode)
if app == "" {
app = appcode.FromContext(ctx)
}
args := []any{app}
switch strings.TrimSpace(query.Status) {
case "closed":
where = append(where, "rle.status <> ?")
args = append(args, "active")
case "":
default:
where = append(where, "rle.status = ?")
args = append(args, strings.TrimSpace(query.Status))
}
if query.RegionID > 0 {
where = append(where, "rle.visible_region_id = ?")
args = append(args, query.RegionID)
}
if query.OwnerUserID > 0 {
where = append(where, "rle.owner_user_id = ?")
args = append(args, query.OwnerUserID)
}
if strings.TrimSpace(query.Keyword) != "" {
like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
where = append(where, "(rle.room_id LIKE ? ESCAPE '\\\\' OR rle.room_short_id LIKE ? ESCAPE '\\\\' OR rle.title LIKE ? ESCAPE '\\\\' OR CAST(rle.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
args = append(args, like, like, like, like)
}
return " WHERE " + strings.Join(where, " AND "), args
}
func adminRoomOrderBy(query roomservice.AdminRoomListQuery, contributionExpr string) string {
direction := "DESC"
if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") {
direction = "ASC"
}
switch strings.TrimSpace(query.SortBy) {
case "room_contribution", "heat":
return contributionExpr + " " + direction + ", rle.created_at_ms DESC, rle.room_id DESC"
default:
return "rle.created_at_ms DESC, rle.room_id DESC"
}
}
func adminRoomContributionExpr(nowMS int64) string {
weekStartMS := roomContributionWeekStartMS(nowMS)
return "CASE WHEN rle.contribution_week_start_ms = " + strconv.FormatInt(weekStartMS, 10) + " THEN rle.weekly_contribution ELSE 0 END"
}
func roomContributionWeekStartMS(nowMS int64) int64 {
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
dayStart := time.UnixMilli(nowMS).UTC()
dayStart = time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day(), 0, 0, 0, 0, time.UTC)
// 房间贡献榜按 UTC 周一 01:00 切周,给 00:00 后的结算和榜单消费留出缓冲窗口。
cutoff := dayStart.Add(time.Hour)
if time.UnixMilli(nowMS).UTC().Before(cutoff) {
dayStart = dayStart.AddDate(0, 0, -1)
}
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
return dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour).UnixMilli()
}
func scanAdminRoom(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomListEntry, error) {
var item roomservice.AdminRoomListEntry
err := scanner.Scan(
&item.RoomID,
&item.RoomShortID,
&item.VisibleRegionID,
&item.OwnerUserID,
&item.Title,
&item.CoverURL,
&item.Mode,
&item.Status,
&item.CloseReason,
&item.ClosedByAdminID,
&item.ClosedByAdminName,
&item.ClosedAtMS,
&item.Heat,
&item.OnlineCount,
&item.SeatCount,
&item.OccupiedSeatCount,
&item.SortScore,
&item.CreatedAtMS,
&item.UpdatedAtMS,
)
return item, err
}
func (r *Repository) AdminListRoomPins(ctx context.Context, query roomservice.AdminRoomPinQuery) ([]roomservice.AdminRoomPinEntry, int64, error) {
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
where, args := adminRoomPinWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, adminRoomPinSelectSQL()+where+` ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]roomservice.AdminRoomPinEntry, 0, query.PageSize)
for rows.Next() {
item, err := scanAdminRoomPin(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
func (r *Repository) AdminCreateRoomPin(ctx context.Context, input roomservice.AdminCreateRoomPinInput) (roomservice.AdminRoomPinEntry, bool, error) {
app := appcode.FromContext(ctx)
room, exists, err := r.AdminGetRoom(ctx, input.RoomID, input.NowMS)
if err != nil || !exists {
return roomservice.AdminRoomPinEntry{}, false, err
}
if room.Status != "active" {
return roomservice.AdminRoomPinEntry{}, false, nil
}
pinType := strings.TrimSpace(input.PinType)
if pinType == "" {
pinType = "region"
}
visibleRegionID := room.VisibleRegionID
if pinType == "global" {
visibleRegionID = 0
}
pinnedAtMS := input.PinnedAtMS
if pinnedAtMS <= 0 {
pinnedAtMS = input.NowMS
}
expiresAtMS := input.ExpiresAtMS
if expiresAtMS <= 0 {
expiresAtMS = pinnedAtMS + input.DurationDays*24*60*60*1000
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO room_region_pins (
app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms,
cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
weight = VALUES(weight), status = VALUES(status), pinned_at_ms = VALUES(pinned_at_ms),
expires_at_ms = VALUES(expires_at_ms), cancelled_at_ms = 0,
created_by_admin_id = VALUES(created_by_admin_id), cancelled_by_admin_id = 0,
created_at_ms = VALUES(created_at_ms), updated_at_ms = VALUES(updated_at_ms)
`, app, visibleRegionID, pinType, room.RoomID, input.Weight, pinnedAtMS, expiresAtMS, input.AdminID, input.NowMS, input.NowMS); err != nil {
return roomservice.AdminRoomPinEntry{}, false, err
}
return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.pin_type = ? AND p.visible_region_id = ? AND p.room_id = ?`, app, pinType, visibleRegionID, room.RoomID)
}
func (r *Repository) AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (roomservice.AdminRoomPinEntry, bool, error) {
app := appcode.FromContext(ctx)
if _, err := r.db.ExecContext(ctx, `
UPDATE room_region_pins
SET status = 'cancelled', cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND id = ? AND status <> 'cancelled'
`, nowMS, adminID, nowMS, app, pinID); err != nil {
return roomservice.AdminRoomPinEntry{}, false, err
}
return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.id = ?`, app, pinID)
}
func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...any) (roomservice.AdminRoomPinEntry, bool, error) {
item, err := scanAdminRoomPin(r.db.QueryRowContext(ctx, adminRoomPinSelectSQL()+where+` LIMIT 1`, args...))
if errors.Is(err, sql.ErrNoRows) {
return roomservice.AdminRoomPinEntry{}, false, nil
}
if err != nil {
return roomservice.AdminRoomPinEntry{}, false, err
}
return item, true, nil
}
func adminRoomPinSelectSQL() string {
return `
SELECT p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms,
p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms,
r.room_short_id, r.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id `
}
func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery) (string, []any) {
nowMS := query.NowMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
where := []string{"p.app_code = ?"}
args := []any{appcode.FromContext(ctx)}
switch strings.TrimSpace(query.Status) {
case "active":
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
args = append(args, "active", nowMS)
case "expired":
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
args = append(args, "active", nowMS)
case "cancelled":
where = append(where, "p.status = ?")
args = append(args, "cancelled")
}
if query.RegionID > 0 {
where = append(where, "p.visible_region_id = ?")
args = append(args, query.RegionID)
}
if strings.TrimSpace(query.PinType) != "" {
where = append(where, "p.pin_type = ?")
args = append(args, strings.TrimSpace(query.PinType))
}
if strings.TrimSpace(query.Keyword) != "" {
like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
where = append(where, "(p.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\' OR CAST(r.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
args = append(args, like, like, like, like)
}
return ` FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id WHERE ` + strings.Join(where, " AND "), args
}
func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
var item roomservice.AdminRoomPinEntry
err := scanner.Scan(&item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
return item, err
}

View File

@ -0,0 +1,403 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"errors"
"strings"
"google.golang.org/protobuf/proto"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/outbox"
roomservice "hyapp/services/room-service/internal/room/service"
)
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
// 幂等检查只看 command log只有成功提交过的命令才算 seen。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
FROM room_command_log
WHERE app_code = ? AND room_id = ? AND command_id = ?
LIMIT 1`,
appcode.FromContext(ctx),
roomID,
commandID,
)
var record roomservice.CommandRecord
if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有命令日志表示本命令尚未成功提交。
return roomservice.CommandRecord{}, false, nil
}
return roomservice.CommandRecord{}, false, err
}
return record, true, nil
}
// SaveCommand 追加一条成功命令日志。
func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error {
// ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。
appCode := normalizedRecordAppCode(ctx, record.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE command_id = command_id`,
appCode,
record.RoomID,
record.RoomVersion,
record.CommandID,
record.CommandType,
record.OwnerNodeID,
record.LeaseToken,
record.Payload,
record.Replayable,
record.CreatedAtMS,
)
return err
}
// SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。
func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error {
appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE command_id = command_id`,
appCode,
commit.Command.RoomID,
commit.Command.RoomVersion,
commit.Command.CommandID,
commit.Command.CommandType,
commit.Command.OwnerNodeID,
commit.Command.LeaseToken,
commit.Command.Payload,
commit.Command.Replayable,
commit.Command.CreatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
if err := r.insertRoomOutboxRecords(ctx, tx, roomOutboxTable, commit.OutboxRecords); err != nil {
// 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。
_ = tx.Rollback()
return err
}
if err := r.insertRoomOutboxRecords(ctx, tx, roomRobotOutboxTable, commit.RobotOutboxRecords); err != nil {
// robot outbox 也和命令日志同事务提交,避免机器人礼物状态变了但客户端展示事件丢失。
_ = tx.Rollback()
return err
}
if strings.TrimSpace(commit.RoomStatus) != "" {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
commit.RoomStatus,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
commit.RoomStatus,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.RoomSeatCount != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
SET seat_count = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
*commit.RoomSeatCount,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET seat_count = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
*commit.RoomSeatCount,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.RoomPasswordHash != nil {
locked := strings.TrimSpace(*commit.RoomPasswordHash) != ""
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
SET room_password_hash = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
*commit.RoomPasswordHash,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET locked = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
locked,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.RoomMode != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms SET mode = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`,
*commit.RoomMode, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.VisibleRegionID != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms SET visible_region_id = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`,
*commit.VisibleRegionID, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.CloseInfo != nil {
info := commit.CloseInfo
reason, adminName, closedAt := info.Reason, info.AdminName, info.ClosedAtMS
adminID := info.AdminID
if info.ClearOnOpen {
reason, adminName, closedAt, adminID = "", "", 0, 0
}
for _, table := range []string{"rooms", "room_list_entries"} {
if _, err := tx.ExecContext(ctx,
`UPDATE `+table+`
SET close_reason = ?, closed_by_admin_id = ?, closed_by_admin_name = ?, closed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
reason, adminID, adminName, closedAt, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
}
for _, stat := range commit.RoomUserGiftStats {
if stat.UserID <= 0 || stat.GiftValue <= 0 {
continue
}
statAppCode := normalizedRecordAppCode(ctx, stat.AppCode)
statRoomID := strings.TrimSpace(stat.RoomID)
if statRoomID == "" {
statRoomID = commit.Command.RoomID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_gift_stats (
app_code, room_id, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_value = gift_value + VALUES(gift_value),
last_gift_at_ms = VALUES(last_gift_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
statAppCode,
statRoomID,
stat.UserID,
stat.GiftValue,
stat.LastGiftAtMS,
commit.Command.CreatedAtMS,
commit.Command.CreatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
}
if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 {
weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
weeklyRoomID := strings.TrimSpace(weekly.RoomID)
if weeklyRoomID == "" {
weeklyRoomID = commit.Command.RoomID
}
occurredMS := weekly.OccurredMS
if occurredMS <= 0 {
occurredMS = commit.Command.CreatedAtMS
}
weekStartMS := roomContributionWeekStartMS(occurredMS)
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET weekly_contribution = CASE
WHEN contribution_week_start_ms = ? THEN weekly_contribution + ?
ELSE ?
END,
contribution_week_start_ms = ?,
updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
weekStartMS,
weekly.GiftValue,
weekly.GiftValue,
weekStartMS,
commit.Command.CreatedAtMS,
weeklyAppCode,
weeklyRoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, table string, records []outbox.Record) error {
table = roomOutboxTableName(table)
if len(records) == 0 {
return nil
}
for _, record := range records {
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
if record.Envelope != nil {
record.Envelope.AppCode = record.AppCode
}
envelopeBytes, err := proto.Marshal(record.Envelope)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO `+table+` (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
record.EventID,
record.EventType,
record.RoomID,
record.Status,
envelopeBytes,
record.CreatedAtMS,
record.RetryCount,
record.NextRetryAtMS,
nullableString(record.LastError),
record.CreatedAtMS,
); err != nil {
return err
}
}
return nil
}
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
FROM room_command_log
WHERE app_code = ? AND room_id = ? AND room_version > ?
ORDER BY room_version ASC, id ASC`,
appcode.FromContext(ctx),
roomID,
roomVersion,
)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]roomservice.CommandRecord, 0)
for rows.Next() {
// payload 保持原始字节,领域层根据 command_type 反序列化。
var record roomservice.CommandRecord
if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
// rows.Err 捕获迭代过程中发生的底层连接或扫描错误。
return nil, err
}
return records, nil
}
// SaveSnapshot 保存房间最新快照。
func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error {
// 快照按 room_id 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version),
payload = IF(VALUES(room_version) >= room_version, VALUES(payload), payload),
created_at_ms = IF(VALUES(room_version) >= room_version, VALUES(created_at_ms), created_at_ms),
updated_at_ms = IF(VALUES(room_version) >= room_version, VALUES(updated_at_ms), updated_at_ms)`,
appCode,
snapshot.RoomID,
snapshot.RoomVersion,
snapshot.Payload,
snapshot.CreatedAtMS,
snapshot.CreatedAtMS,
)
return err
}
// GetLatestSnapshot 读取房间最新快照。
func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) {
// room_snapshots 主键是 room_id因此查询结果最多一条。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_version, payload, created_at_ms
FROM room_snapshots
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
roomID,
)
var snapshot roomservice.SnapshotRecord
if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有快照时上层会从 RoomMeta 和 command log 从头恢复。
return roomservice.SnapshotRecord{}, false, nil
}
return roomservice.SnapshotRecord{}, false, err
}
return snapshot, true, nil
}

View File

@ -0,0 +1,365 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"strings"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/outbox"
)
// SaveOutbox 追加待投递 outbox 事件。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
if len(records) == 0 {
// 没有事件时保持无副作用,方便领域层统一调用。
return nil
}
// 多条事件用一个事务写入,保证同一命令产生的事件不会部分入库。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, record := range records {
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
if record.Envelope != nil {
record.Envelope.AppCode = record.AppCode
}
// envelope 是 protobuf 信封,存储层不解析具体事件 body。
envelopeBytes, err := proto.Marshal(record.Envelope)
if err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
record.EventID,
record.EventType,
record.RoomID,
record.Status,
envelopeBytes,
record.CreatedAtMS,
record.RetryCount,
record.NextRetryAtMS,
nullableString(record.LastError),
record.CreatedAtMS,
); err != nil {
// 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。
_ = tx.Rollback()
return err
}
}
// commit 成功后事件才对 outbox worker 可见。
return tx.Commit()
}
// ListPendingOutbox 返回待投递事件。
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
return r.listPendingOutboxFromTable(ctx, roomOutboxTable, limit)
}
// ListPendingRobotOutbox 返回机器人房间待投递展示事件。
func (r *Repository) ListPendingRobotOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
return r.listPendingOutboxFromTable(ctx, roomRobotOutboxTable, limit)
}
func (r *Repository) listPendingOutboxFromTable(ctx context.Context, table string, limit int) ([]outbox.Record, error) {
if limit <= 0 {
// 默认批量上限避免调用方传 0 导致全表扫描。
limit = 100
}
table = roomOutboxTableName(table)
pendingIndex, _ := roomOutboxIndexes(table)
// 按创建时间顺序扫描 pending尽量保持事件投递顺序。
nowMS := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM `+table+` FORCE INDEX (`+pendingIndex+`)
WHERE app_code = ? AND status IN (?, ?)
AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC
LIMIT ?`,
appcode.FromContext(ctx),
outbox.StatusPending,
outbox.StatusRetryable,
nowMS,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]outbox.Record, 0, limit)
for rows.Next() {
// 每条 outbox 记录恢复 envelope发布器只消费 protobuf 信封。
var envelopeBytes []byte
var record outbox.Record
var lockUntil sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
return nil, err
}
if lockUntil.Valid {
record.LockUntilMS = lockUntil.V
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
// envelope 损坏说明 outbox 数据不可投递,需要暴露错误而不是跳过。
return nil, err
}
record.Envelope = &envelope
record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// ClaimPendingOutbox 抢占一批待投递事件,避免多 worker 同时投递同一批。
func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) {
return r.claimPendingOutboxFromTable(ctx, roomOutboxTable, workerID, limit, lockUntilMS)
}
// ClaimPendingRobotOutbox 抢占机器人房间展示事件,避免机器人流量占用主 room_outbox worker。
func (r *Repository) ClaimPendingRobotOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) {
return r.claimPendingOutboxFromTable(ctx, roomRobotOutboxTable, workerID, limit, lockUntilMS)
}
func (r *Repository) claimPendingOutboxFromTable(ctx context.Context, table string, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) {
if limit <= 0 {
limit = 100
}
if strings.TrimSpace(workerID) == "" {
workerID = "room-outbox-worker"
}
table = roomOutboxTableName(table)
pendingIndex, claimIndex := roomOutboxIndexes(table)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
nowMS := time.Now().UTC().UnixMilli()
appCode := appcode.FromContext(ctx)
records := make([]outbox.Record, 0, limit)
claimBranches := []struct {
query string
args []any
}{
{
query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM ` + table + ` FORCE INDEX (` + pendingIndex + `)
WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outbox.StatusPending, nowMS},
},
{
query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM ` + table + ` FORCE INDEX (` + pendingIndex + `)
WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outbox.StatusRetryable, nowMS},
},
{
query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM ` + table + ` FORCE INDEX (` + claimIndex + `)
WHERE app_code = ? AND status = ? AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outbox.StatusDelivering, nowMS},
},
}
for _, branch := range claimBranches {
remaining := limit - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := r.queryRoomOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
_ = tx.Rollback()
return nil, err
}
records = append(records, branchRecords...)
}
for _, record := range records {
if _, err := tx.ExecContext(ctx,
`UPDATE `+table+`
SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outbox.StatusDelivering,
workerID,
lockUntilMS,
nowMS,
appCode,
record.EventID,
); err != nil {
_ = tx.Rollback()
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].Status = outbox.StatusDelivering
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func (r *Repository) queryRoomOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]outbox.Record, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]outbox.Record, 0)
for rows.Next() {
var envelopeBytes []byte
var record outbox.Record
var lockUntilValue sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
return nil, err
}
if lockUntilValue.Valid {
record.LockUntilMS = lockUntilValue.V
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
return nil, err
}
record.Envelope = &envelope
record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// MarkOutboxDelivered 标记事件投递成功。
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
return r.markOutboxDeliveredInTable(ctx, roomOutboxTable, eventID)
}
// MarkRobotOutboxDelivered 标记机器人展示事件已经投递成功。
func (r *Repository) MarkRobotOutboxDelivered(ctx context.Context, eventID string) error {
return r.markOutboxDeliveredInTable(ctx, roomRobotOutboxTable, eventID)
}
func (r *Repository) markOutboxDeliveredInTable(ctx context.Context, table string, eventID string) error {
table = roomOutboxTableName(table)
// 成功后清空 last_error后续扫描不再返回该事件。
_, err := r.db.ExecContext(ctx,
`UPDATE `+table+`
SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = NULL, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outbox.StatusDelivered,
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
// MarkOutboxRetryable 标记事件投递失败并写入下一次重试时间。
func (r *Repository) MarkOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
return r.markOutboxRetryableInTable(ctx, roomOutboxTable, eventID, lastErr, nextRetryAtMS)
}
// MarkRobotOutboxRetryable 记录机器人展示事件投递失败,并写入独立重试退避。
func (r *Repository) MarkRobotOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
return r.markOutboxRetryableInTable(ctx, roomRobotOutboxTable, eventID, lastErr, nextRetryAtMS)
}
func (r *Repository) markOutboxRetryableInTable(ctx context.Context, table string, eventID string, lastErr string, nextRetryAtMS int64) error {
table = roomOutboxTableName(table)
// 失败转入 retryableworker 只有到 next_retry_at_ms 后才会重新抢占。
_, err := r.db.ExecContext(ctx,
`UPDATE `+table+`
SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outbox.StatusRetryable,
nextRetryAtMS,
lastErr,
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
// MarkOutboxDead 把超过最大重试次数的事件转入 failed。
func (r *Repository) MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error {
return r.markOutboxDeadInTable(ctx, roomOutboxTable, eventID, lastErr)
}
// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。
func (r *Repository) MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error {
return r.markOutboxDeadInTable(ctx, roomRobotOutboxTable, eventID, lastErr)
}
func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, eventID string, lastErr string) error {
table = roomOutboxTableName(table)
_, err := r.db.ExecContext(ctx,
`UPDATE `+table+`
SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outbox.StatusFailed,
lastErr,
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
func roomOutboxTableName(table string) string {
switch table {
case roomRobotOutboxTable:
return roomRobotOutboxTable
default:
return roomOutboxTable
}
}
func roomOutboxIndexes(table string) (pending string, claim string) {
if roomOutboxTableName(table) == roomRobotOutboxTable {
return "idx_room_robot_outbox_pending", "idx_room_robot_outbox_claim"
}
return "idx_room_outbox_pending", "idx_room_outbox_claim"
}

View File

@ -0,0 +1,196 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"errors"
"strings"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservice.RoomPresenceSnapshot) error {
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
if strings.TrimSpace(snapshot.RoomID) == "" {
return nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
// 先把该房间上一版 active presence 全部置为 left再把本次快照里的在线用户 upsert 回 active。
// 这样 Join/Leave/Kick/Close 的投影逻辑统一,避免按命令类型维护多份分支。
if _, err := tx.ExecContext(ctx,
`UPDATE room_user_presence
SET status = ?, publish_state = '', mic_session_id = '', room_version = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND status = ?`,
roomPresenceStatusLeftSQL,
snapshot.RoomVersion,
snapshot.UpdatedAtMS,
appCode,
snapshot.RoomID,
roomPresenceStatusActiveSQL,
); err != nil {
_ = tx.Rollback()
return err
}
for _, user := range snapshot.Users {
if user.UserID <= 0 {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_presence (
app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_id = VALUES(room_id),
role = VALUES(role),
publish_state = VALUES(publish_state),
mic_session_id = VALUES(mic_session_id),
room_version = VALUES(room_version),
status = VALUES(status),
joined_at_ms = VALUES(joined_at_ms),
last_seen_at_ms = VALUES(last_seen_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
user.UserID,
snapshot.RoomID,
user.Role,
user.PublishState,
user.MicSessionID,
snapshot.RoomVersion,
roomPresenceStatusActiveSQL,
user.JoinedAtMS,
user.LastSeenAtMS,
snapshot.UpdatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
FROM room_user_presence
WHERE app_code = ? AND user_id = ? AND status = ?
LIMIT 1`,
appcode.FromContext(ctx),
userID,
roomPresenceStatusActiveSQL,
)
var presence roomservice.RoomPresence
if err := row.Scan(
&presence.AppCode,
&presence.UserID,
&presence.RoomID,
&presence.Role,
&presence.PublishState,
&presence.MicSessionID,
&presence.RoomVersion,
&presence.Status,
&presence.JoinedAtMS,
&presence.LastSeenAtMS,
&presence.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomPresence{}, false, nil
}
return roomservice.RoomPresence{}, false, err
}
return presence, true, nil
}
// ListRoomOnlineUsers 分页查询某个房间当前 active presence。
func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) {
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = 50
}
offset := (page - 1) * pageSize
appCode := normalizedRecordAppCode(ctx, query.AppCode)
var total int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM room_user_presence
WHERE app_code = ? AND room_id = ? AND status = ?`,
appCode,
query.RoomID,
roomPresenceStatusActiveSQL,
).Scan(&total); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
orderBy := "p.last_seen_at_ms DESC, p.user_id ASC"
if strings.EqualFold(strings.TrimSpace(query.Sort), "gift_value") {
orderBy = "COALESCE(g.gift_value, 0) DESC, p.last_seen_at_ms DESC, p.user_id ASC"
}
rows, err := r.db.QueryContext(ctx,
`SELECT p.app_code, p.user_id, p.room_id, p.role, p.publish_state, p.mic_session_id, p.room_version, p.status,
p.joined_at_ms, p.last_seen_at_ms, p.updated_at_ms, COALESCE(g.gift_value, 0)
FROM room_user_presence p
LEFT JOIN room_user_gift_stats g
ON g.app_code = p.app_code AND g.room_id = p.room_id AND g.user_id = p.user_id
WHERE p.app_code = ? AND p.room_id = ? AND p.status = ?
ORDER BY `+orderBy+`
LIMIT ? OFFSET ?`,
appCode,
query.RoomID,
roomPresenceStatusActiveSQL,
pageSize,
offset,
)
if err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
defer rows.Close()
users := make([]roomservice.RoomPresence, 0, pageSize)
for rows.Next() {
var presence roomservice.RoomPresence
if err := rows.Scan(
&presence.AppCode,
&presence.UserID,
&presence.RoomID,
&presence.Role,
&presence.PublishState,
&presence.MicSessionID,
&presence.RoomVersion,
&presence.Status,
&presence.JoinedAtMS,
&presence.LastSeenAtMS,
&presence.UpdatedAtMS,
&presence.GiftValue,
); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
users = append(users, presence)
}
if err := rows.Err(); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
return roomservice.RoomOnlineUserPage{
Users: users,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
package mysql
import (
"reflect"
"testing"
)
func TestSplitHumanRobotAllowedOwnerIDs(t *testing.T) {
shortIDs, userIDs := splitHumanRobotAllowedOwnerIDs([]string{" 1001 ", "abc", "1001", "1e3", "-2", ""})
if !reflect.DeepEqual(shortIDs, []string{"1001", "abc", "1e3", "-2"}) {
t.Fatalf("short ids mismatch: got %+v", shortIDs)
}
if !reflect.DeepEqual(userIDs, []int64{1001}) {
t.Fatalf("owner user ids mismatch: got %+v", userIDs)
}
}

View File

@ -0,0 +1,649 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
roomservice "hyapp/services/room-service/internal/room/service"
)
// ListRobotRooms 读取后台机器人房间列表;机器人房间配置归 room-service 所有,后台不直连房间库。
func (r *Repository) ListRobotRooms(ctx context.Context, query roomservice.RobotRoomListQuery) ([]roomservice.RobotRoomConfig, int64, error) {
appCode := normalizedRecordAppCode(ctx, query.AppCode)
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 || pageSize > 100 {
pageSize = 50
}
status := strings.TrimSpace(query.Status)
args := []any{appCode}
where := `WHERE app_code = ?`
if status != "" {
where += ` AND status = ?`
args = append(args, status)
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_robot_rooms `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, pageSize, (page-1)*pageSize)
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms `+where+`
ORDER BY updated_at_ms DESC, room_id DESC
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanRobotRoomConfigs(rows)
return items, total, err
}
// GetRobotRoom 精确读取一个机器人房间配置。
func (r *Repository) GetRobotRoom(ctx context.Context, roomID string) (roomservice.RobotRoomConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
strings.TrimSpace(roomID),
)
item, err := scanRobotRoomConfig(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RobotRoomConfig{}, false, nil
}
return item, err == nil, err
}
// CreateRobotRoomConfig 写入机器人房间配置,并用事务锁定候选账号避免两个 active 机器人房复用同一批账号。
func (r *Repository) CreateRobotRoomConfig(ctx context.Context, input roomservice.CreateRobotRoomConfigInput) (roomservice.RobotRoomConfig, error) {
config := input.Config
appCode := normalizedRecordAppCode(ctx, config.AppCode)
nowMS := input.NowMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
config.AppCode = appCode
config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS)
config.UpdatedAtMS = firstPositiveInt64(config.UpdatedAtMS, nowMS)
if config.Status == "" {
config.Status = "active"
}
rawRobots, rawGifts, rawLuckyGifts, err := robotRoomJSON(config)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
defer func() { _ = tx.Rollback() }()
occupied, err := occupiedRobotUserIDsTx(ctx, tx, appCode, config.RobotUserIDs)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
if len(occupied) > 0 {
return roomservice.RobotRoomConfig{}, xerr.New(xerr.Conflict, "robot user already assigned to active robot room")
}
_, err = tx.ExecContext(ctx,
`INSERT INTO room_robot_rooms (
app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
config.AppCode,
config.RoomID,
config.RoomShortID,
config.Title,
config.CoverURL,
config.VisibleRegionID,
config.Status,
config.OwnerRobotUserID,
string(rawRobots),
config.ActiveRobotCount,
config.SeatCount,
string(rawGifts),
string(rawLuckyGifts),
config.GiftRule.NormalGiftIntervalMS,
config.GiftRule.LuckyComboMin,
config.GiftRule.LuckyComboMax,
config.GiftRule.LuckyPauseMinMS,
config.GiftRule.LuckyPauseMaxMS,
config.GiftRule.RobotStayMinMS,
config.GiftRule.RobotStayMaxMS,
config.GiftRule.RobotReplaceMinMS,
config.GiftRule.RobotReplaceMaxMS,
config.GiftRule.MaxGiftSenders,
config.CreatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := tx.Commit(); err != nil {
return roomservice.RobotRoomConfig{}, err
}
return config, nil
}
// UpdateRobotRoomStatus 只切换机器人房间运行态;停止后账号会从“已占用”集合释放。
func (r *Repository) UpdateRobotRoomStatus(ctx context.Context, roomID string, status string, nowMS int64) (roomservice.RobotRoomConfig, bool, error) {
status = strings.TrimSpace(status)
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
res, err := r.db.ExecContext(ctx,
`UPDATE room_robot_rooms
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
status,
nowMS,
appcode.FromContext(ctx),
strings.TrimSpace(roomID),
)
if err != nil {
return roomservice.RobotRoomConfig{}, false, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return roomservice.RobotRoomConfig{}, false, nil
}
item, exists, err := r.GetRobotRoom(ctx, roomID)
return item, exists, err
}
// OccupiedRobotUserIDs 返回仍被 active 机器人房占用的机器人账号集合。
func (r *Repository) OccupiedRobotUserIDs(ctx context.Context, userIDs []int64) (map[int64]bool, error) {
return occupiedRobotUserIDsQuery(ctx, r.db, appcode.FromContext(ctx), userIDs, false)
}
// ListActiveRobotRooms 为 room-service 重启恢复运行时循环提供当前 active 配置。
func (r *Repository) ListActiveRobotRooms(ctx context.Context) ([]roomservice.RobotRoomConfig, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms
WHERE status = 'active'
ORDER BY app_code ASC, updated_at_ms DESC, room_id DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRobotRoomConfigs(rows)
}
// GetHumanRoomRobotConfig 读取真人房间机器人行为配置;没有配置时由 service 层补默认值。
func (r *Repository) GetHumanRoomRobotConfig(ctx context.Context) (roomservice.HumanRoomRobotConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, enabled, candidate_room_max_online, room_full_stop_online, room_target_min_online, room_target_max_online,
robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms,
normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms,
gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json,
lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders,
country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()),
country_limit_enabled, COALESCE(country_rules_json, JSON_ARRAY()),
updated_by_admin_id, created_at_ms, updated_at_ms
FROM room_human_robot_configs
WHERE app_code = ?`,
appcode.FromContext(ctx),
)
config, err := scanHumanRoomRobotConfig(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.HumanRoomRobotConfig{}, false, nil
}
return config, err == nil, err
}
// UpsertHumanRoomRobotConfig 保存真人房间机器人行为配置;运行时扫描每轮读取最新配置。
func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config roomservice.HumanRoomRobotConfig) (roomservice.HumanRoomRobotConfig, error) {
appCode := normalizedRecordAppCode(ctx, config.AppCode)
nowMS := config.UpdatedAtMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
config.AppCode = appCode
config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS)
config.UpdatedAtMS = nowMS
rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, err := humanRoomRobotConfigJSON(config)
if err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO room_human_robot_configs (
app_code, enabled, candidate_room_max_online, room_full_stop_online, room_target_min_online, room_target_max_online,
robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms,
normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms,
gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json,
lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders,
country_pools_json, allowed_owner_ids_json, country_limit_enabled, country_rules_json,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
candidate_room_max_online = VALUES(candidate_room_max_online),
room_full_stop_online = VALUES(room_full_stop_online),
room_target_min_online = VALUES(room_target_min_online),
room_target_max_online = VALUES(room_target_max_online),
robot_stay_min_ms = VALUES(robot_stay_min_ms),
robot_stay_max_ms = VALUES(robot_stay_max_ms),
robot_replace_min_ms = VALUES(robot_replace_min_ms),
robot_replace_max_ms = VALUES(robot_replace_max_ms),
normal_gift_interval_ms = VALUES(normal_gift_interval_ms),
normal_gift_interval_min_ms = VALUES(normal_gift_interval_min_ms),
normal_gift_interval_max_ms = VALUES(normal_gift_interval_max_ms),
gift_ids_json = VALUES(gift_ids_json),
lucky_gift_ids_json = VALUES(lucky_gift_ids_json),
super_lucky_gift_ids_json = VALUES(super_lucky_gift_ids_json),
lucky_combo_min = VALUES(lucky_combo_min),
lucky_combo_max = VALUES(lucky_combo_max),
lucky_pause_min_ms = VALUES(lucky_pause_min_ms),
lucky_pause_max_ms = VALUES(lucky_pause_max_ms),
max_gift_senders = VALUES(max_gift_senders),
country_pools_json = VALUES(country_pools_json),
allowed_owner_ids_json = VALUES(allowed_owner_ids_json),
country_limit_enabled = VALUES(country_limit_enabled),
country_rules_json = VALUES(country_rules_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
config.AppCode,
boolToInt(config.Enabled),
config.CandidateRoomMaxOnline,
config.RoomFullStopOnline,
config.RoomTargetMinOnline,
config.RoomTargetMaxOnline,
config.RobotStayMinMS,
config.RobotStayMaxMS,
config.RobotReplaceMinMS,
config.RobotReplaceMaxMS,
config.NormalGiftIntervalMS,
config.NormalGiftIntervalMinMS,
config.NormalGiftIntervalMaxMS,
string(rawGifts),
string(rawLucky),
string(rawSuperLucky),
config.LuckyComboMin,
config.LuckyComboMax,
config.LuckyPauseMinMS,
config.LuckyPauseMaxMS,
config.MaxGiftSenders,
string(rawPools),
string(rawAllowedOwners),
boolToInt(config.CountryLimitEnabled),
string(rawCountryRules),
config.UpdatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
)
if err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
saved, _, err := r.GetHumanRoomRobotConfig(ctx)
return saved, err
}
// ListHumanRobotCandidateRooms 找出同国家低人数真人房allowedOwnerIDs 为空时保持全量扫描,非空时只命中房主短号或内部 owner_user_id。
func (r *Repository) ListHumanRobotCandidateRooms(ctx context.Context, appCode string, countryCode string, maxOnline int32, fullStopOnline int32, limit int, allowedOwnerIDs []string) ([]roomservice.RoomListEntry, error) {
appCode = normalizedRecordAppCode(ctx, appCode)
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if maxOnline <= 0 {
maxOnline = 5
}
if fullStopOnline <= 0 {
fullStopOnline = 10
}
if maxOnline > fullStopOnline {
maxOnline = fullStopOnline
}
if limit <= 0 || limit > 100 {
limit = 50
}
shortIDs, ownerUserIDs := splitHumanRobotAllowedOwnerIDs(allowedOwnerIDs)
where := []string{
"app_code = ?",
"status = 'active'",
"owner_country_code = ?",
"online_count < ?",
"online_count < ?",
"room_id NOT LIKE 'robot\\_%'",
}
args := []any{appCode, countryCode, maxOnline, fullStopOnline}
if len(shortIDs) > 0 {
ownerWhere := make([]string, 0, 2)
shortPlaceholders := strings.TrimRight(strings.Repeat("?,", len(shortIDs)), ",")
ownerWhere = append(ownerWhere, "room_short_id IN ("+shortPlaceholders+")")
for _, id := range shortIDs {
args = append(args, id)
}
if len(ownerUserIDs) > 0 {
userPlaceholders := strings.TrimRight(strings.Repeat("?,", len(ownerUserIDs)), ",")
ownerWhere = append(ownerWhere, "owner_user_id IN ("+userPlaceholders+")")
for _, id := range ownerUserIDs {
args = append(args, id)
}
}
where = append(where, "("+strings.Join(ownerWhere, " OR ")+")")
}
args = append(args, limit)
query := `SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code,
owner_user_id, title, cover_url, mode, status, locked, heat,
online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
FROM room_list_entries
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY online_count ASC, updated_at_ms DESC, room_id DESC
LIMIT ?`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]roomservice.RoomListEntry, 0, limit)
for rows.Next() {
var entry roomservice.RoomListEntry
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
); err != nil {
return nil, err
}
items = append(items, entry)
}
return items, rows.Err()
}
type humanRoomRobotConfigScanner interface {
Scan(dest ...any) error
}
func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.HumanRoomRobotConfig, error) {
var config roomservice.HumanRoomRobotConfig
var enabled int
var rawGifts string
var rawLucky string
var rawSuperLucky string
var rawPools string
var rawAllowedOwners string
var countryLimitEnabled int
var rawCountryRules string
if err := row.Scan(
&config.AppCode,
&enabled,
&config.CandidateRoomMaxOnline,
&config.RoomFullStopOnline,
&config.RoomTargetMinOnline,
&config.RoomTargetMaxOnline,
&config.RobotStayMinMS,
&config.RobotStayMaxMS,
&config.RobotReplaceMinMS,
&config.RobotReplaceMaxMS,
&config.NormalGiftIntervalMS,
&config.NormalGiftIntervalMinMS,
&config.NormalGiftIntervalMaxMS,
&rawGifts,
&rawLucky,
&rawSuperLucky,
&config.LuckyComboMin,
&config.LuckyComboMax,
&config.LuckyPauseMinMS,
&config.LuckyPauseMaxMS,
&config.MaxGiftSenders,
&rawPools,
&rawAllowedOwners,
&countryLimitEnabled,
&rawCountryRules,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
config.Enabled = enabled == 1
if err := json.Unmarshal([]byte(rawGifts), &config.GiftIDs); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
if err := json.Unmarshal([]byte(rawLucky), &config.LuckyGiftIDs); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
if err := json.Unmarshal([]byte(rawSuperLucky), &config.SuperLuckyGiftIDs); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
if err := json.Unmarshal([]byte(rawPools), &config.CountryPools); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
if err := json.Unmarshal([]byte(rawAllowedOwners), &config.AllowedOwnerIDs); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
config.CountryLimitEnabled = countryLimitEnabled == 1
if err := json.Unmarshal([]byte(rawCountryRules), &config.CountryRules); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
return config, nil
}
func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, []byte, error) {
rawGifts, err := json.Marshal(config.GiftIDs)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
rawLucky, err := json.Marshal(config.LuckyGiftIDs)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
rawSuperLucky, err := json.Marshal(config.SuperLuckyGiftIDs)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
rawPools, err := json.Marshal(config.CountryPools)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
rawAllowedOwners, err := json.Marshal(config.AllowedOwnerIDs)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
rawCountryRules, err := json.Marshal(config.CountryRules)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, nil
}
func splitHumanRobotAllowedOwnerIDs(values []string) ([]string, []int64) {
seenShortIDs := make(map[string]bool, len(values))
seenUserIDs := make(map[int64]bool, len(values))
shortIDs := make([]string, 0, len(values))
userIDs := make([]int64, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seenShortIDs[value] {
continue
}
seenShortIDs[value] = true
shortIDs = append(shortIDs, value)
if strings.ContainsAny(value, ".eE") {
continue
}
userID, err := strconv.ParseInt(value, 10, 64)
if err != nil || userID <= 0 || seenUserIDs[userID] {
continue
}
seenUserIDs[userID] = true
userIDs = append(userIDs, userID)
}
return shortIDs, userIDs
}
type robotRoomScanner interface {
Scan(dest ...any) error
}
func scanRobotRoomConfigs(rows *sql.Rows) ([]roomservice.RobotRoomConfig, error) {
items := make([]roomservice.RobotRoomConfig, 0)
for rows.Next() {
item, err := scanRobotRoomConfig(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func scanRobotRoomConfig(row robotRoomScanner) (roomservice.RobotRoomConfig, error) {
var item roomservice.RobotRoomConfig
var rawRobots string
var rawGifts string
var rawLuckyGifts string
if err := row.Scan(
&item.AppCode,
&item.RoomID,
&item.RoomShortID,
&item.Title,
&item.CoverURL,
&item.VisibleRegionID,
&item.Status,
&item.OwnerRobotUserID,
&rawRobots,
&item.ActiveRobotCount,
&item.SeatCount,
&rawGifts,
&rawLuckyGifts,
&item.GiftRule.NormalGiftIntervalMS,
&item.GiftRule.LuckyComboMin,
&item.GiftRule.LuckyComboMax,
&item.GiftRule.LuckyPauseMinMS,
&item.GiftRule.LuckyPauseMaxMS,
&item.GiftRule.RobotStayMinMS,
&item.GiftRule.RobotStayMaxMS,
&item.GiftRule.RobotReplaceMinMS,
&item.GiftRule.RobotReplaceMaxMS,
&item.GiftRule.MaxGiftSenders,
&item.CreatedByAdminID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawRobots), &item.RobotUserIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawGifts), &item.GiftRule.GiftIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawLuckyGifts), &item.GiftRule.LuckyGiftIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if item.ActiveRobotCount <= 0 {
item.ActiveRobotCount = int32(len(item.RobotUserIDs))
}
if item.SeatCount <= 0 {
item.SeatCount = 10
}
item.GiftRule = roomservice.WithRobotRoomGiftRuleStorageDefaults(item.GiftRule)
return item, nil
}
func robotRoomJSON(config roomservice.RobotRoomConfig) ([]byte, []byte, []byte, error) {
rawRobots, err := json.Marshal(config.RobotUserIDs)
if err != nil {
return nil, nil, nil, err
}
rawGifts, err := json.Marshal(config.GiftRule.GiftIDs)
if err != nil {
return nil, nil, nil, err
}
rawLuckyGifts, err := json.Marshal(config.GiftRule.LuckyGiftIDs)
if err != nil {
return nil, nil, nil, err
}
return rawRobots, rawGifts, rawLuckyGifts, nil
}
func occupiedRobotUserIDsTx(ctx context.Context, tx *sql.Tx, appCode string, userIDs []int64) (map[int64]bool, error) {
return occupiedRobotUserIDsQuery(ctx, tx, appCode, userIDs, true)
}
type dbQuerier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
func occupiedRobotUserIDsQuery(ctx context.Context, db dbQuerier, appCode string, userIDs []int64, lock bool) (map[int64]bool, error) {
ids := uniquePositiveInt64(userIDs)
occupied := make(map[int64]bool, len(ids))
if len(ids) == 0 {
return occupied, nil
}
query := `SELECT robot_user_ids_json FROM room_robot_rooms WHERE app_code = ? AND status = 'active'`
if lock {
query += ` FOR UPDATE`
}
rows, err := db.QueryContext(ctx, query, appcode.Normalize(appCode))
if err != nil {
return nil, err
}
defer rows.Close()
wanted := make(map[int64]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var roomRobotIDs []int64
if err := json.Unmarshal([]byte(raw), &roomRobotIDs); err != nil {
return nil, err
}
for _, id := range roomRobotIDs {
if wanted[id] {
occupied[id] = true
}
}
}
return occupied, rows.Err()
}

View File

@ -0,0 +1,713 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"errors"
"sort"
"strings"
"time"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
// UpsertRoomListEntry 写入房间列表读模型。
func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.RoomListEntry) error {
// created_at_ms 首次插入后保持不变,避免 Join/Leave/SendGift 等更新把 new tab 顺序洗掉。
appCode := normalizedRecordAppCode(ctx, entry.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_list_entries (
app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_short_id = VALUES(room_short_id),
visible_region_id = VALUES(visible_region_id),
owner_country_code = VALUES(owner_country_code),
owner_user_id = VALUES(owner_user_id),
title = VALUES(title),
cover_url = VALUES(cover_url),
mode = VALUES(mode),
status = VALUES(status),
locked = VALUES(locked),
heat = VALUES(heat),
online_count = VALUES(online_count),
seat_count = VALUES(seat_count),
occupied_seat_count = VALUES(occupied_seat_count),
sort_score = VALUES(sort_score),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
entry.RoomID,
entry.RoomShortID,
entry.VisibleRegionID,
entry.OwnerCountryCode,
entry.OwnerUserID,
entry.Title,
entry.CoverURL,
entry.Mode,
entry.Status,
entry.Locked,
entry.Heat,
entry.OnlineCount,
entry.SeatCount,
entry.OccupiedSeatCount,
entry.SortScore,
entry.CreatedAtMS,
entry.UpdatedAtMS,
)
return err
}
// ListRoomListEntries 读取公共发现列表读模型,并先按查询区域隔离,再在区域内部按置顶、用户国家和在线状态排成稳定分页序列。
func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 {
query.Limit = 20
}
query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
if query.NowMS <= 0 {
query.NowMS = time.Now().UTC().UnixMilli()
}
sqlText, args := buildRoomListQuerySQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
for rows.Next() {
var entry roomservice.RoomListEntry
var pinned int64
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
&pinned,
&entry.PinListRank,
&entry.PinWeight,
&entry.PinnedUntilMS,
); err != nil {
return nil, err
}
entry.IsPinned = pinned > 0
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
// ListRoomListEntriesByIDs 按榜单已命中的 room_id 批量读取列表卡片。
func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomservice.RoomListEntriesByIDQuery) (map[string]roomservice.RoomListEntry, error) {
appCode := normalizedRecordAppCode(ctx, query.AppCode)
roomIDs := uniqueRoomListIDs(query.RoomIDs)
result := make(map[string]roomservice.RoomListEntry, len(roomIDs))
if len(roomIDs) == 0 {
return result, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(roomIDs)), ",")
args := make([]any, 0, len(roomIDs)+1)
args = append(args, appCode)
for _, roomID := range roomIDs {
args = append(args, roomID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
FROM room_list_entries
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var entry roomservice.RoomListEntry
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
); err != nil {
return nil, err
}
result[entry.RoomID] = entry
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func uniqueRoomListIDs(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
// UpsertRoomUserFeedEntry 写入用户房间关系流索引。
func (r *Repository) UpsertRoomUserFeedEntry(ctx context.Context, entry roomservice.RoomUserFeedEntry) error {
// feed 只保存用户到房间的索引;房间卡片字段仍以 room_list_entries 为准,避免复制不一致。
appCode := normalizedRecordAppCode(ctx, entry.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_user_feed_entries (app_code, user_id, feed_type, room_id, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
appCode,
entry.UserID,
entry.FeedType,
entry.RoomID,
entry.CreatedAtMS,
entry.UpdatedAtMS,
)
return err
}
// ListRoomUserFeedEntries 按用户关系流读取房间卡片。
func (r *Repository) ListRoomUserFeedEntries(ctx context.Context, query roomservice.RoomUserFeedQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 {
query.Limit = 20
}
query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
sqlText, args := buildRoomUserFeedQuerySQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
for rows.Next() {
var entry roomservice.RoomListEntry
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
); err != nil {
return nil, err
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
// FollowRoom 建立或恢复用户对房间的关注关系。
func (r *Repository) FollowRoom(ctx context.Context, userID int64, roomID string, nowMS int64) (roomservice.RoomFollowRecord, error) {
appCode := appcode.FromContext(ctx)
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
if _, err := r.db.ExecContext(ctx,
`INSERT INTO room_follows (app_code, user_id, room_id, status, followed_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
followed_at_ms = IF(status = ?, followed_at_ms, VALUES(followed_at_ms)),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
userID,
roomID,
roomFollowStatusActiveSQL,
nowMS,
nowMS,
roomFollowStatusActiveSQL,
); err != nil {
return roomservice.RoomFollowRecord{}, err
}
record, _, err := r.getRoomFollowRecord(ctx, userID, roomID)
return record, err
}
// UnfollowRoom 取消用户对房间的关注关系;没有现存关系时不写入新行。
func (r *Repository) UnfollowRoom(ctx context.Context, userID int64, roomID string, nowMS int64) error {
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
_, err := r.db.ExecContext(ctx,
`UPDATE room_follows
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND room_id = ? AND status = ?`,
roomFollowStatusCancelledSQL,
nowMS,
appcode.FromContext(ctx),
userID,
roomID,
roomFollowStatusActiveSQL,
)
return err
}
// IsRoomFollowed 查询用户是否正在关注指定房间。
func (r *Repository) IsRoomFollowed(ctx context.Context, userID int64, roomID string) (bool, error) {
record, exists, err := r.getRoomFollowRecord(ctx, userID, roomID)
if err != nil || !exists {
return false, err
}
return record.Status == roomFollowStatusActiveSQL, nil
}
func (r *Repository) getRoomFollowRecord(ctx context.Context, userID int64, roomID string) (roomservice.RoomFollowRecord, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, user_id, room_id, status, followed_at_ms, updated_at_ms
FROM room_follows
WHERE app_code = ? AND user_id = ? AND room_id = ?
LIMIT 1`,
appcode.FromContext(ctx),
userID,
roomID,
)
var record roomservice.RoomFollowRecord
if err := row.Scan(&record.AppCode, &record.UserID, &record.RoomID, &record.Status, &record.FollowedAtMS, &record.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomFollowRecord{}, false, nil
}
return roomservice.RoomFollowRecord{}, false, err
}
return record, true, nil
}
// ListRoomFollowEntries 读取当前用户关注的 active 房间卡片。
func (r *Repository) ListRoomFollowEntries(ctx context.Context, query roomservice.RoomFollowQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 {
query.Limit = 20
}
query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
sqlText, args := buildRoomFollowQuerySQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
for rows.Next() {
var entry roomservice.RoomListEntry
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
); err != nil {
return nil, err
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
// ListRoomRelatedFeedEntries 根据 user-service 给出的好友/关注用户查询 active 房间卡片。
func (r *Repository) ListRoomRelatedFeedEntries(ctx context.Context, query roomservice.RoomRelatedFeedQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 {
query.Limit = 20
}
query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
relations := normalizeRoomFeedRelations(query.RelatedUsers)
if len(relations) == 0 {
return []roomservice.RoomListEntry{}, nil
}
sqlText, args := buildRoomRelatedFeedQuerySQL(query, relations)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
for rows.Next() {
var entry roomservice.RoomListEntry
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
); err != nil {
return nil, err
}
entry.FeedSubjectUserID, entry.UpdatedAtMS = relatedFeedSortKey(entry, relations)
if entry.FeedSubjectUserID == 0 || !roomRelatedFeedAfterCursor(entry, query.CursorUpdatedAtMS, query.CursorSubjectUserID, query.CursorRoomID) {
continue
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].UpdatedAtMS != entries[j].UpdatedAtMS {
return entries[i].UpdatedAtMS > entries[j].UpdatedAtMS
}
if entries[i].FeedSubjectUserID != entries[j].FeedSubjectUserID {
return entries[i].FeedSubjectUserID > entries[j].FeedSubjectUserID
}
return entries[i].RoomID < entries[j].RoomID
})
if len(entries) > query.Limit {
entries = entries[:query.Limit]
}
return entries, nil
}
const roomListSelectColumns = `
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
FROM room_list_entries`
const roomUserFeedSelectColumns = `
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, f.updated_at_ms
FROM room_user_feed_entries f
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
const roomFollowSelectColumns = `
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, f.followed_at_ms
FROM room_follows f
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
regionID := query.VisibleRegionID
if regionID < 0 {
regionID = 0
}
viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode)
filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode)
sortCountryCode := viewerCountryCode
if filterCountryCode != "" {
// 国家 tab 已经把结果集收敛到一个国家,国家置顶和国家优先桶都应按该 tab 国家解释。
sortCountryCode = filterCountryCode
}
normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 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"
}
regionPinScopeSQL := "AND rp.visible_region_id = ?"
countryPinScopeSQL := "AND cp.visible_region_id = ?"
if query.AllVisibleRegions {
// 白名单用户查看全区列表时,区域/国家置顶按房间自身区域命中,不能再固定到 viewer 区域。
regionPinScopeSQL = "AND rp.visible_region_id = r.visible_region_id"
countryPinScopeSQL = "AND cp.visible_region_id = r.visible_region_id"
}
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 := `
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,
` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
FROM room_list_entries r
LEFT JOIN room_region_pins gp
ON gp.app_code = r.app_code
AND gp.pin_type = 'global'
AND gp.visible_region_id = 0
AND gp.room_id = r.room_id
AND gp.status = ?
AND gp.pinned_at_ms <= ?
AND gp.expires_at_ms > ?
LEFT JOIN room_region_pins rp
ON rp.app_code = r.app_code
AND rp.pin_type = 'region'
` + regionPinScopeSQL + `
AND rp.room_id = r.room_id
AND rp.status = ?
AND rp.pinned_at_ms <= ?
AND rp.expires_at_ms > ?
LEFT JOIN room_region_pins cp
ON cp.app_code = r.app_code
AND cp.pin_type = 'country'
` + countryPinScopeSQL + `
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 = ?"}
args := []any{"active", query.NowMS, query.NowMS}
if !query.AllVisibleRegions {
args = append(args, regionID)
}
args = append(args, "active", query.NowMS, query.NowMS)
if !query.AllVisibleRegions {
args = append(args, regionID)
}
args = append(args, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active")
if !query.AllVisibleRegions {
// 普通发现页必须利用区域索引隔离结果;白名单模式只保留 app/status/可选国家/关键词条件。
where = append(where, "r.visible_region_id = ?")
args = append(args, regionID)
}
if query.OwnerUserID > 0 {
where = append(where, "r.owner_user_id = ?")
args = append(args, query.OwnerUserID)
}
if strings.TrimSpace(query.CountryCode) != "" {
where = append(where, "r.owner_country_code = ?")
args = append(args, strings.ToUpper(strings.TrimSpace(query.CountryCode)))
}
if strings.TrimSpace(query.Query) != "" {
where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
like := "%" + escapeRoomListLike(query.Query) + "%"
args = append(args, like, like, like)
}
if query.Tab == "new" {
if query.CursorRoomID != "" {
where = append(where, roomListAfterCursorSQL(pinRankExpr, pinWeightExpr, pinExpiresExpr, "r.created_at_ms"))
args = append(args, roomListAfterCursorArgs(query.CursorPinListRank, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorCreatedAtMS, query.CursorRoomID)...)
}
args = append(args, query.Limit)
return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.created_at_ms DESC, r.room_id ASC\n\tLIMIT ?", args
}
if query.CursorRoomID != "" {
where = append(where, roomListAfterCursorSQL(pinRankExpr, pinWeightExpr, pinExpiresExpr, "r.sort_score"))
args = append(args, roomListAfterCursorArgs(query.CursorPinListRank, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorSortScore, query.CursorRoomID)...)
}
args = append(args, query.Limit)
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 roomListAfterCursorSQL(pinRankExpr string, pinWeightExpr string, pinExpiresExpr string, sortExpr string) string {
// 排序桶、置顶权重、过期时间和 tab 排序键共同组成游标,避免跨全区置顶/区域置顶/普通房分区翻页时重复或跳过。
return "(" +
pinRankExpr + " > ? OR (" +
pinRankExpr + " = ? AND " + pinWeightExpr + " < ?) OR (" +
pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " < ?) OR (" +
pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " < ?) OR (" +
pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " = ? AND r.room_id > ?))"
}
func roomListAfterCursorArgs(pinListRank int64, pinWeight int64, pinnedUntilMS int64, sortValue int64, roomID string) []any {
return []any{
pinListRank,
pinListRank, pinWeight,
pinListRank, pinWeight, pinnedUntilMS,
pinListRank, pinWeight, pinnedUntilMS, sortValue,
pinListRank, pinWeight, pinnedUntilMS, sortValue, roomID,
}
}
func buildRoomUserFeedQuerySQL(query roomservice.RoomUserFeedQuery) (string, []any) {
where := []string{"f.app_code = ?", "f.user_id = ?", "f.feed_type = ?", "r.visible_region_id = ?", "r.status = ?"}
args := []any{appcode.Normalize(query.AppCode), query.UserID, query.FeedType, query.VisibleRegionID, "active"}
if strings.TrimSpace(query.Query) != "" {
where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
like := "%" + escapeRoomListLike(query.Query) + "%"
args = append(args, like, like, like)
}
if query.CursorRoomID != "" {
where = append(where, "(f.updated_at_ms < ? OR (f.updated_at_ms = ? AND f.room_id > ?))")
args = append(args, query.CursorUpdatedAtMS, query.CursorUpdatedAtMS, query.CursorRoomID)
}
args = append(args, query.Limit)
return roomUserFeedSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.updated_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
}
func buildRoomFollowQuerySQL(query roomservice.RoomFollowQuery) (string, []any) {
where := []string{"f.app_code = ?", "f.user_id = ?", "f.status = ?", "r.visible_region_id = ?", "r.status = ?"}
args := []any{appcode.Normalize(query.AppCode), query.UserID, roomFollowStatusActiveSQL, query.VisibleRegionID, "active"}
if strings.TrimSpace(query.Query) != "" {
where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
like := "%" + escapeRoomListLike(query.Query) + "%"
args = append(args, like, like, like)
}
if query.CursorRoomID != "" {
where = append(where, "(f.followed_at_ms < ? OR (f.followed_at_ms = ? AND f.room_id > ?))")
args = append(args, query.CursorFollowedAtMS, query.CursorFollowedAtMS, query.CursorRoomID)
}
args = append(args, query.Limit)
return roomFollowSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.followed_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
}
func buildRoomRelatedFeedQuerySQL(query roomservice.RoomRelatedFeedQuery, relations map[int64]int64) (string, []any) {
where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"}
args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"}
placeholders := make([]string, 0, len(relations))
relatedIDs := make([]any, 0, len(relations))
for userID := range relations {
placeholders = append(placeholders, "?")
relatedIDs = append(relatedIDs, userID)
}
inClause := strings.Join(placeholders, ",")
where = append(where, "owner_user_id IN ("+inClause+")")
args = append(args, relatedIDs...)
if strings.TrimSpace(query.Query) != "" {
where = append(where, "(room_id LIKE ? ESCAPE '\\\\' OR room_short_id LIKE ? ESCAPE '\\\\' OR title LIKE ? ESCAPE '\\\\')")
like := "%" + escapeRoomListLike(query.Query) + "%"
args = append(args, like, like, like)
}
return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND "), args
}
func normalizeRoomFeedRelations(items []roomservice.RoomFeedRelatedUser) map[int64]int64 {
relations := make(map[int64]int64, len(items))
for _, item := range items {
if item.UserID <= 0 || item.RelationUpdatedAtMS <= 0 {
continue
}
if item.RelationUpdatedAtMS > relations[item.UserID] {
relations[item.UserID] = item.RelationUpdatedAtMS
}
}
return relations
}
func relatedFeedSortKey(entry roomservice.RoomListEntry, relations map[int64]int64) (int64, int64) {
bestUserID, bestUpdatedAtMS := int64(0), int64(0)
choose := func(userID int64) {
updatedAtMS := relations[userID]
if updatedAtMS == 0 {
return
}
if updatedAtMS > bestUpdatedAtMS || (updatedAtMS == bestUpdatedAtMS && userID > bestUserID) {
bestUserID = userID
bestUpdatedAtMS = updatedAtMS
}
}
choose(entry.OwnerUserID)
return bestUserID, bestUpdatedAtMS
}
func roomRelatedFeedAfterCursor(entry roomservice.RoomListEntry, cursorUpdatedAtMS int64, cursorSubjectUserID int64, cursorRoomID string) bool {
if cursorUpdatedAtMS <= 0 || cursorSubjectUserID <= 0 || cursorRoomID == "" {
return true
}
if entry.UpdatedAtMS != cursorUpdatedAtMS {
return entry.UpdatedAtMS < cursorUpdatedAtMS
}
if entry.FeedSubjectUserID != cursorSubjectUserID {
return entry.FeedSubjectUserID < cursorSubjectUserID
}
return entry.RoomID > cursorRoomID
}
// ProjectRoomPresence 用最新 RoomSnapshot 投影用户当前房间读模型。

View File

@ -0,0 +1,171 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
// SaveRoomMeta 插入房间元数据。
func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error {
// rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。
appCode := normalizedRecordAppCode(ctx, meta.AppCode)
nowMS := time.Now().UTC().UnixMilli()
_, err := r.db.ExecContext(ctx,
`INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode,
meta.RoomID,
meta.RoomShortID,
meta.OwnerUserID,
meta.SeatCount,
meta.Mode,
meta.Status,
meta.RoomPasswordHash,
meta.VisibleRegionID,
nowMS,
nowMS,
)
return mapRoomMetaDuplicateError(err)
}
// GetRoomMeta 查询房间元数据。
func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) {
// meta 是无内存 Cell 恢复的入口;不存在时上层返回 room not found。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
FROM rooms
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
roomID,
)
var meta roomservice.RoomMeta
if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 未创建房间不是数据库错误,用 exists=false 表达。
return roomservice.RoomMeta{}, false, nil
}
return roomservice.RoomMeta{}, false, err
}
return meta, true, nil
}
// GetRoomMetaByOwner 查询用户已经创建的房间。
func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) {
// rooms.owner_user_id 有唯一约束,查询最多返回一条;该读路径用于 CreateRoom 前置失败。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
FROM rooms
WHERE app_code = ? AND owner_user_id = ?
LIMIT 1`,
appcode.FromContext(ctx),
ownerUserID,
)
var meta roomservice.RoomMeta
if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomMeta{}, false, nil
}
return roomservice.RoomMeta{}, false, err
}
return meta, true, nil
}
// SaveRoomBackground 保存房间背景图素材;相同房间相同 URL 按幂等返回原记录。
func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) {
appCode := normalizedRecordAppCode(ctx, background.AppCode)
roomID := strings.TrimSpace(background.RoomID)
imageURL := strings.TrimSpace(background.ImageURL)
nowMS := background.CreatedAtMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO room_background_images (
app_code, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)
`, appCode, roomID, imageURL, background.CreatedByUserID, nowMS, nowMS)
if err != nil {
return roomservice.RoomBackgroundImage{}, err
}
row := r.db.QueryRowContext(ctx, `
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
FROM room_background_images
WHERE app_code = ? AND room_id = ? AND image_url = ?
LIMIT 1
`, appCode, roomID, imageURL)
return scanRoomBackground(row)
}
// GetRoomBackground 精确读取一个房间背景图素材。
func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
FROM room_background_images
WHERE app_code = ? AND room_id = ? AND background_id = ?
LIMIT 1
`, appcode.FromContext(ctx), strings.TrimSpace(roomID), backgroundID)
background, err := scanRoomBackground(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomBackgroundImage{}, false, nil
}
return roomservice.RoomBackgroundImage{}, false, err
}
return background, true, nil
}
// ListRoomBackgrounds 返回某个房间保存过的背景图素材,按最近保存优先。
func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
FROM room_background_images
WHERE app_code = ? AND room_id = ?
ORDER BY created_at_ms DESC, background_id DESC
`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
if err != nil {
return nil, err
}
defer rows.Close()
backgrounds := []roomservice.RoomBackgroundImage{}
for rows.Next() {
background, err := scanRoomBackground(rows)
if err != nil {
return nil, err
}
backgrounds = append(backgrounds, background)
}
return backgrounds, rows.Err()
}
func scanRoomBackground(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomBackgroundImage, error) {
var background roomservice.RoomBackgroundImage
err := scanner.Scan(
&background.AppCode,
&background.BackgroundID,
&background.RoomID,
&background.ImageURL,
&background.CreatedByUserID,
&background.CreatedAtMS,
&background.UpdatedAtMS,
)
return background, err
}

View File

@ -0,0 +1,686 @@
// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import "context"
// Migrate 创建 room-service 首版需要的 MySQL 表。
func (r *Repository) Migrate(ctx context.Context) error {
// 这里保持与 deploy/mysql/initdb 的表结构一致,服务本地启动可自动建表。
statements := []string{
`CREATE TABLE IF NOT EXISTS rooms (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
owner_user_id BIGINT NOT NULL,
seat_count INT NOT NULL,
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
room_password_hash VARCHAR(128) NOT NULL DEFAULT '',
close_reason VARCHAR(512) NOT NULL DEFAULT '',
closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
closed_at_ms BIGINT NOT NULL DEFAULT 0,
visible_region_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id),
UNIQUE KEY uk_rooms_short_id (app_code, room_short_id),
UNIQUE KEY uk_rooms_owner_user (app_code, owner_user_id),
KEY idx_rooms_region_status (app_code, visible_region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_list_entries (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
visible_region_id BIGINT NOT NULL DEFAULT 0,
owner_country_code VARCHAR(3) NOT NULL DEFAULT '',
owner_user_id BIGINT NOT NULL,
title VARCHAR(128) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
locked BOOLEAN NOT NULL DEFAULT FALSE,
close_reason VARCHAR(512) NOT NULL DEFAULT '',
closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
closed_at_ms BIGINT NOT NULL DEFAULT 0,
heat BIGINT NOT NULL DEFAULT 0,
weekly_contribution BIGINT NOT NULL DEFAULT 0,
contribution_week_start_ms BIGINT NOT NULL DEFAULT 0,
online_count INT NOT NULL DEFAULT 0,
seat_count INT NOT NULL DEFAULT 0,
occupied_seat_count INT NOT NULL DEFAULT 0,
sort_score BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id),
KEY idx_room_list_short_id (app_code, room_short_id),
KEY idx_room_list_region_hot (app_code, visible_region_id, status, sort_score DESC, room_id),
KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id),
KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id),
KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id),
KEY idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id),
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_presence (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL,
publish_state VARCHAR(32) NOT NULL DEFAULT '',
mic_session_id VARCHAR(128) NOT NULL DEFAULT '',
room_version BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
joined_at_ms BIGINT NOT NULL DEFAULT 0,
last_seen_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id),
KEY idx_room_user_presence_room (app_code, room_id, status),
KEY idx_room_user_presence_updated (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_gift_stats (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
gift_value BIGINT NOT NULL DEFAULT 0,
last_gift_at_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, user_id),
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_background_images (
app_code VARCHAR(32) NOT NULL,
background_id BIGINT NOT NULL AUTO_INCREMENT,
room_id VARCHAR(64) NOT NULL,
image_url VARCHAR(256) NOT NULL,
created_by_user_id BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (background_id),
UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
room_version BIGINT NOT NULL,
payload LONGBLOB NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id),
INDEX idx_room_snapshots_version (app_code, room_id, room_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_command_log (
app_code VARCHAR(32) NOT NULL,
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
room_id VARCHAR(64) NOT NULL,
room_version BIGINT NOT NULL,
command_id VARCHAR(128) NOT NULL,
command_type VARCHAR(64) NOT NULL,
owner_node_id VARCHAR(128) NOT NULL DEFAULT '',
lease_token VARCHAR(128) NOT NULL DEFAULT '',
payload LONGBLOB NOT NULL,
replayable BOOLEAN NOT NULL,
created_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_room_command (app_code, room_id, command_id),
KEY idx_room_command_replay (app_code, room_id, room_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_outbox (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
room_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
worker_id VARCHAR(128) NOT NULL DEFAULT '',
lock_until_ms BIGINT NULL,
envelope LONGBLOB NOT NULL,
created_at_ms BIGINT NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id),
KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_robot_outbox (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
room_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
worker_id VARCHAR(128) NOT NULL DEFAULT '',
lock_until_ms BIGINT NULL,
envelope LONGBLOB NOT NULL,
created_at_ms BIGINT NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_robot_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_room_robot_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_room_robot_outbox_event_created (app_code, event_type, created_at_ms, event_id),
KEY idx_room_robot_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_feed_entries (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
feed_type VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, feed_type, room_id),
KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_follows (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
followed_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, room_id),
KEY idx_room_follows_user_list (app_code, user_id, status, followed_at_ms DESC, room_id),
KEY idx_room_follows_room (app_code, room_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_region_pins (
id BIGINT NOT NULL AUTO_INCREMENT,
app_code VARCHAR(32) NOT NULL,
visible_region_id BIGINT NOT NULL,
pin_type VARCHAR(32) NOT NULL DEFAULT 'region',
room_id VARCHAR(64) NOT NULL,
weight BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
pinned_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
cancelled_at_ms BIGINT NOT NULL DEFAULT 0,
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
cancelled_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id),
KEY idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id),
KEY idx_room_region_pins_room (app_code, room_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间置顶表'`,
`CREATE TABLE IF NOT EXISTS room_seat_configs (
app_code VARCHAR(32) NOT NULL,
allowed_seat_counts VARCHAR(128) NOT NULL,
default_seat_count INT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_rocket_configs (
app_code VARCHAR(32) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
config_version BIGINT NOT NULL DEFAULT 1,
fuel_source VARCHAR(32) NOT NULL,
launch_delay_ms BIGINT NOT NULL,
broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1,
broadcast_scope VARCHAR(32) NOT NULL,
broadcast_delay_ms BIGINT NOT NULL DEFAULT 0,
reward_stack_policy VARCHAR(32) NOT NULL,
levels_json JSON NOT NULL,
gift_fuel_rules_json JSON NOT NULL,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code),
KEY idx_room_rocket_enabled (app_code, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_robot_rooms (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(128) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
visible_region_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
owner_robot_user_id BIGINT NOT NULL,
robot_user_ids_json JSON NOT NULL,
active_robot_count INT NOT NULL DEFAULT 0,
seat_count INT NOT NULL DEFAULT 10,
gift_ids_json JSON NOT NULL,
lucky_gift_ids_json JSON NOT NULL,
normal_gift_interval_ms BIGINT NOT NULL,
lucky_combo_min BIGINT NOT NULL,
lucky_combo_max BIGINT NOT NULL,
lucky_pause_min_ms BIGINT NOT NULL,
lucky_pause_max_ms BIGINT NOT NULL,
robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000,
robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000,
robot_replace_min_ms BIGINT NOT NULL DEFAULT 0,
robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000,
max_gift_senders BIGINT NOT NULL DEFAULT 1,
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id),
KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id),
KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_human_robot_configs (
app_code VARCHAR(32) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
candidate_room_max_online INT NOT NULL DEFAULT 5,
room_full_stop_online INT NOT NULL DEFAULT 10,
room_target_min_online INT NOT NULL DEFAULT 8,
room_target_max_online INT NOT NULL DEFAULT 10,
robot_stay_min_ms BIGINT NOT NULL DEFAULT 60000,
robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000,
robot_replace_min_ms BIGINT NOT NULL DEFAULT 60000,
robot_replace_max_ms BIGINT NOT NULL DEFAULT 180000,
normal_gift_interval_ms BIGINT NOT NULL DEFAULT 10000,
normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000,
normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000,
gift_ids_json JSON NOT NULL,
lucky_gift_ids_json JSON NOT NULL,
super_lucky_gift_ids_json JSON NOT NULL,
lucky_combo_min BIGINT NOT NULL DEFAULT 1,
lucky_combo_max BIGINT NOT NULL DEFAULT 3,
lucky_pause_min_ms BIGINT NOT NULL DEFAULT 5000,
lucky_pause_max_ms BIGINT NOT NULL DEFAULT 20000,
max_gift_senders BIGINT NOT NULL DEFAULT 1,
country_pools_json JSON NOT NULL,
allowed_owner_ids_json JSON NOT NULL,
country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
country_rules_json JSON NOT NULL,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code),
KEY idx_room_human_robot_enabled (app_code, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`,
`INSERT IGNORE INTO room_rocket_configs (
app_code,
enabled,
config_version,
fuel_source,
launch_delay_ms,
broadcast_enabled,
broadcast_scope,
broadcast_delay_ms,
reward_stack_policy,
levels_json,
gift_fuel_rules_json,
updated_by_admin_id,
created_at_ms,
updated_at_ms
) VALUES (
'lalu',
0,
1,
'heat_value',
30000,
1,
'region',
0,
'allow_stack',
JSON_ARRAY(
JSON_OBJECT('level', 1, 'fuelThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 2, 'fuelThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 3, 'fuelThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 4, 'fuelThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 5, 'fuelThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY())
),
JSON_ARRAY(),
0,
0,
0
)`,
`ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_robot_rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
}
for _, statement := range statements {
// 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
if err := r.ensureRoomListLockSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomListCountrySchema(ctx); err != nil {
return err
}
if err := r.ensureRoomPasswordSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomAdminCloseSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomWeeklyContributionSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomUserPresenceSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomPinSchema(ctx); err != nil {
return err
}
if err := r.ensureRobotRoomBehaviorSchema(ctx); err != nil {
return err
}
if err := r.ensureHumanRobotConfigBehaviorSchema(ctx); err != nil {
return err
}
return r.ensureOutboxRetrySchema(ctx)
}
func (r *Repository) ensureHumanRobotConfigBehaviorSchema(ctx context.Context) error {
columns := []struct {
name string
statement string
}{
{name: "normal_gift_interval_min_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000 AFTER normal_gift_interval_ms`},
{name: "normal_gift_interval_max_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000 AFTER normal_gift_interval_min_ms`},
{name: "room_target_min_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_min_online INT NOT NULL DEFAULT 8 AFTER room_full_stop_online`},
{name: "room_target_max_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_max_online INT NOT NULL DEFAULT 10 AFTER room_target_min_online`},
{name: "allowed_owner_ids_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN allowed_owner_ids_json JSON NULL AFTER country_pools_json`},
{name: "country_limit_enabled", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER allowed_owner_ids_json`},
{name: "country_rules_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_rules_json JSON NULL AFTER country_limit_enabled`},
}
for _, column := range columns {
exists, err := r.columnExists(ctx, "room_human_robot_configs", column.name)
if err != nil {
return err
}
if exists {
continue
}
if _, err := r.db.ExecContext(ctx, column.statement); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRobotRoomBehaviorSchema(ctx context.Context) error {
columns := []struct {
name string
statement string
}{
{name: "active_robot_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN active_robot_count INT NOT NULL DEFAULT 0 AFTER robot_user_ids_json`},
{name: "seat_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN seat_count INT NOT NULL DEFAULT 10 AFTER active_robot_count`},
{name: "robot_stay_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000 AFTER lucky_pause_max_ms`},
{name: "robot_stay_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000 AFTER robot_stay_min_ms`},
{name: "robot_replace_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_min_ms BIGINT NOT NULL DEFAULT 0 AFTER robot_stay_max_ms`},
{name: "robot_replace_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000 AFTER robot_replace_min_ms`},
{name: "max_gift_senders", statement: `ALTER TABLE room_robot_rooms ADD COLUMN max_gift_senders BIGINT NOT NULL DEFAULT 1 AFTER robot_replace_max_ms`},
}
for _, column := range columns {
exists, err := r.columnExists(ctx, "room_robot_rooms", column.name)
if err != nil {
return err
}
if exists {
continue
}
if _, err := r.db.ExecContext(ctx, column.statement); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomWeeklyContributionSchema(ctx context.Context) error {
columns := []struct {
column string
statement string
}{
{
column: "weekly_contribution",
statement: `ALTER TABLE room_list_entries ADD COLUMN weekly_contribution BIGINT NOT NULL DEFAULT 0 AFTER heat`,
},
{
column: "contribution_week_start_ms",
statement: `ALTER TABLE room_list_entries ADD COLUMN contribution_week_start_ms BIGINT NOT NULL DEFAULT 0 AFTER weekly_contribution`,
},
}
for _, item := range columns {
hasColumn, err := r.columnExists(ctx, "room_list_entries", item.column)
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
return err
}
}
}
hasIndex, err := r.indexExists(ctx, "room_list_entries", "idx_room_list_weekly_contribution")
if err != nil {
return err
}
if !hasIndex {
// 后台按房间贡献排序时只读当前 UTC 周贡献;索引用于收敛 active/region 过滤后的排序成本。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD INDEX idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id)`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomPinSchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type")
if err != nil {
return err
}
if !hasColumn {
// pin_type 把“全区置顶”和“区域置顶”的作用域写进同一 owner 表;历史数据默认都是区域置顶。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD COLUMN pin_type VARCHAR(32) NOT NULL DEFAULT 'region' AFTER visible_region_id`); err != nil {
return err
}
}
hasScopeUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_scope")
if err != nil {
return err
}
if !hasScopeUnique {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id)`); err != nil {
return err
}
}
hasLegacyUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_room")
if err != nil {
return err
}
if hasLegacyUnique {
// 新唯一键允许 region_id=0 的区域置顶和全区置顶并存;旧唯一键会错误地把两者互斥。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins DROP INDEX uk_room_region_pins_room`); err != nil {
return err
}
}
hasActiveScope, err := r.indexExists(ctx, "room_region_pins", "idx_room_region_pins_active_scope")
if err != nil {
return err
}
if !hasActiveScope {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD INDEX idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id)`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms")
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash")
if err != nil {
return err
}
if !hasColumn {
// rooms 是最新快照恢复密码哈希的持久来源;列表只使用 locked 布尔投影。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE rooms ADD COLUMN room_password_hash VARCHAR(128) NOT NULL DEFAULT '' AFTER status`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_list_entries", "locked")
if err != nil {
return err
}
if !hasColumn {
// locked 是列表/搜索需要的纯投影字段,历史行默认 false后续房间状态变更会刷新为真实值。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE AFTER status`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomListCountrySchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_list_entries", "owner_country_code")
if err != nil {
return err
}
if !hasColumn {
// owner_country_code 是房主国家快照,只服务发现页国家筛选;历史行先为空,后续房间投影刷新会补真实值。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN owner_country_code VARCHAR(3) NOT NULL DEFAULT '' AFTER visible_region_id`); err != nil {
return err
}
}
indexes := []struct {
name string
statement string
}{
{
name: "idx_room_list_region_country_hot",
statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id)`,
},
{
name: "idx_room_list_region_country_new",
statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id)`,
},
}
for _, item := range indexes {
hasIndex, err := r.indexExists(ctx, "room_list_entries", item.name)
if err != nil {
return err
}
if !hasIndex {
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error {
columns := []struct {
table string
column string
statement string
}{
{"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`},
{"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
{"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
{"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
{"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`},
{"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
{"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
{"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
}
for _, item := range columns {
hasColumn, err := r.columnExists(ctx, item.table, item.column)
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms")
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil {
return err
}
}
indexes := []struct {
name string
statement string
}{
{"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
{"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
{"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`},
{"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`},
{"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`},
}
for _, index := range indexes {
hasIndex, err := r.indexExists(ctx, "room_outbox", index.name)
if err != nil {
return err
}
if !hasIndex {
if _, err := r.db.ExecContext(ctx, index.statement); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
`, tableName, columnName).Scan(&exists)
return exists > 0, err
}
func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
`, tableName, indexName).Scan(&exists)
return exists > 0, err
}

View File

@ -470,6 +470,7 @@ func selfGameMatchEvent(body []byte) (mysqlstorage.SelfGameMatchEvent, bool, err
GameID: firstNonEmpty(mysqlstorage.String(payload, "game_id"), message.GameID),
PlatformCode: firstNonEmpty(mysqlstorage.String(payload, "platform_code"), message.PlatformCode),
RoomID: mysqlstorage.String(payload, "room_id"),
CountryID: mysqlstorage.Int64(payload, "country_id"),
RegionID: mysqlstorage.Int64(payload, "region_id"),
StakeCoin: mysqlstorage.Int64(payload, "stake_coin"),
Status: mysqlstorage.String(payload, "status"),

View File

@ -65,6 +65,49 @@ func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
}
}
func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_registration_idempotent_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
event := UserRegisteredEvent{
AppCode: "lalu",
EventID: "user_registered:327300172394536960",
UserID: 327300172394536960,
CountryID: 15,
RegionID: 10,
OccurredAtMS: time.Date(2026, 6, 22, 1, 2, 3, 0, time.UTC).UnixMilli(),
}
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration first time: %v", err)
}
// 生产环境会遇到 MQ 重投或历史补偿;注册 cohort 表去重后,国家日新增只能按真实新行累加。
event.EventID = "user_registered:327300172394536960:replay"
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration replay: %v", err)
}
var newUsers int64
if err := repository.db.QueryRowContext(ctx, `
SELECT new_users
FROM stat_app_day_country
WHERE app_code = 'lalu' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10
`).Scan(&newUsers); err != nil {
t.Fatalf("query app day country: %v", err)
}
if newUsers != 1 {
t.Fatalf("registration replay must not duplicate new users, got %d", newUsers)
}
}
func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)

View File

@ -463,6 +463,7 @@ type SelfGameMatchEvent struct {
GameID string
PlatformCode string
RoomID string
CountryID int64
RegionID int64
StakeCoin int64
Status string
@ -504,17 +505,20 @@ func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegist
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
inserted, err := insertUnique(ctx, tx, `
INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
`, appcode.Normalize(event.AppCode), event.UserID, day, countryID, regionID, event.OccurredAtMS); err != nil {
`, appcode.Normalize(event.AppCode), event.UserID, day, countryID, regionID, event.OccurredAtMS)
if err != nil {
return err
}
_, err := tx.ExecContext(ctx, `
// MQ 重投和补偿任务可能带来新的 event_id但同一用户注册 cohort 只能入库一次;
// new_users 必须按 INSERT IGNORE 的真实插入行数累加,避免回放把国家新增人数重复放大。
_, err = tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET new_users = new_users + 1, updated_at_ms = ?
SET new_users = new_users + ?, updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID)
`, inserted, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID)
return err
})
}
@ -541,7 +545,7 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
case "google", "google_play":
googleUSD = event.USDMinor
case "mifapay":
case "mifapay", "v5pay":
mifapayUSD = event.USDMinor
case "usdt_trc20":
// USDT-TRC20 是站外 H5 充值渠道;当前大盘只单列 MiFaPay/Google/币商转账,
@ -816,7 +820,7 @@ func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH
func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMatchEvent) error {
return r.withEvent(ctx, SourceGame, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS)
countryID, regionID := normalizeDimension(0, event.RegionID)
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
gameID := normalizeShortText(event.GameID)
if gameID == "" {
gameID = "unknown"

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