Merge branch 'main' into test
# Conflicts: # services/room-service/internal/storage/mysql/repository.go
This commit is contained in:
commit
0f9a73642a
6
Makefile
6
Makefile
@ -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
@ -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
@ -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
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
12
deploy/rocketmq/broker.local.conf
Normal file
12
deploy/rocketmq/broker.local.conf
Normal 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
|
||||
@ -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
|
||||
|
||||
172
docs/幸运礼物产品文档.md
Normal file
172
docs/幸运礼物产品文档.md
Normal 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` 再候选过滤。 |
|
||||
| 房间气氛池 | 当前保留房间气氛池账本和结果字段,且气氛奖励口径独立于基础 RTP;v2 主候选默认仍来自基础 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*`。 |
|
||||
46
scripts/apply-local-rocketmq-topics.sh
Executable file
46
scripts/apply-local-rocketmq-topics.sh
Executable 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
75
scripts/mysql/048_v5pay_payment_methods.sql
Normal file
75
scripts/mysql/048_v5pay_payment_methods.sql
Normal 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'));
|
||||
2
scripts/mysql/049_external_recharge_reconcile_index.sql
Normal file
2
scripts/mysql/049_external_recharge_reconcile_index.sql
Normal 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);
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
354
server/admin/internal/modules/cprelation/list.go
Normal file
354
server/admin/internal/modules/cprelation/list.go
Normal 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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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`,
|
||||
} {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -152,6 +152,7 @@ type DrawCommand struct {
|
||||
CoinSpent int64
|
||||
PaidAtMS int64
|
||||
VisibleRegionID int64
|
||||
CountryID int64
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
148
services/activity-service/internal/service/wheel/service_test.go
Normal file
148
services/activity-service/internal/service/wheel/service_test.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 '当前参与人数',
|
||||
|
||||
@ -165,6 +165,7 @@ type Match struct {
|
||||
ProviderGameID string
|
||||
RoomID string
|
||||
RegionID int64
|
||||
CountryID int64
|
||||
MinPlayers int32
|
||||
MaxPlayers int32
|
||||
CurrentPlayers int32
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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) != ""
|
||||
}
|
||||
|
||||
@ -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{{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -114,6 +114,8 @@ type robotRoomRuntime struct {
|
||||
cancel context.CancelFunc
|
||||
// token 区分同一房间的前后两次启动,旧 goroutine 退出时不能误删新 runtime。
|
||||
token string
|
||||
// countryCode 记录真人房间机器人 runtime 所属国家,用于后台国家限额变更后精确清理。
|
||||
countryCode string
|
||||
// configUpdatedAtMS 记录启动 runtime 时采用的配置版本;后台改配置后扫描器据此重启真人房间机器人 runtime。
|
||||
configUpdatedAtMS int64
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
471
services/room-service/internal/storage/mysql/admin_room.go
Normal file
471
services/room-service/internal/storage/mysql/admin_room.go
Normal 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
|
||||
}
|
||||
403
services/room-service/internal/storage/mysql/command_log.go
Normal file
403
services/room-service/internal/storage/mysql/command_log.go
Normal 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
|
||||
}
|
||||
365
services/room-service/internal/storage/mysql/outbox.go
Normal file
365
services/room-service/internal/storage/mysql/outbox.go
Normal 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)
|
||||
// 失败转入 retryable,worker 只有到 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"
|
||||
}
|
||||
196
services/room-service/internal/storage/mysql/presence.go
Normal file
196
services/room-service/internal/storage/mysql/presence.go
Normal 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
@ -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)
|
||||
}
|
||||
}
|
||||
649
services/room-service/internal/storage/mysql/robot_room.go
Normal file
649
services/room-service/internal/storage/mysql/robot_room.go
Normal 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()
|
||||
}
|
||||
713
services/room-service/internal/storage/mysql/room_list.go
Normal file
713
services/room-service/internal/storage/mysql/room_list.go
Normal 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 投影用户当前房间读模型。
|
||||
171
services/room-service/internal/storage/mysql/room_meta.go
Normal file
171
services/room-service/internal/storage/mysql/room_meta.go
Normal 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
|
||||
}
|
||||
686
services/room-service/internal/storage/mysql/schema.go
Normal file
686
services/room-service/internal/storage/mysql/schema.go
Normal 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
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user