diff --git a/Makefile b/Makefile index 73ceb68b..8091f678 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ build: go build ./... go build ./server/admin/... -# `run` 拉起 MySQL/Redis 后在本机用 go run 启动所有 Go 服务,并在源码或配置变更时热重启。 +# `run` 拉起 MySQL/Redis 后在本机用 go run 启动所有 Go 服务,不做源码监听或热重启。 # 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。 run: ./scripts/resolve-compose-container-conflicts.sh diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 067f2d25..4e8bb3c2 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -6510,8 +6510,14 @@ type SendGiftRequest struct { TargetType string `protobuf:"bytes,5,opt,name=target_type,json=targetType,proto3" json:"target_type,omitempty"` TargetUserIds []int64 `protobuf:"varint,6,rep,packed,name=target_user_ids,json=targetUserIds,proto3" json:"target_user_ids,omitempty"` PoolId string `protobuf:"bytes,7,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // target_is_host 由 gateway 根据 user-service active host profile 注入,room-service 只透传给 wallet-service。 + TargetIsHost bool `protobuf:"varint,8,opt,name=target_is_host,json=targetIsHost,proto3" json:"target_is_host,omitempty"` + // target_host_region_id 是主播所属区域,后续结算按该区域匹配 Host & Agency 政策。 + TargetHostRegionId int64 `protobuf:"varint,9,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"` + // target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。 + TargetAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftRequest) Reset() { @@ -6593,6 +6599,27 @@ func (x *SendGiftRequest) GetPoolId() string { return "" } +func (x *SendGiftRequest) GetTargetIsHost() bool { + if x != nil { + return x.TargetIsHost + } + return false +} + +func (x *SendGiftRequest) GetTargetHostRegionId() int64 { + if x != nil { + return x.TargetHostRegionId + } + return 0 +} + +func (x *SendGiftRequest) GetTargetAgencyOwnerUserId() int64 { + if x != nil { + return x.TargetAgencyOwnerUserId + } + return 0 +} + // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 type SendGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9345,7 +9372,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" + "\n" + "rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" + - "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x81\x02\n" + + "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x98\x03\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -9355,7 +9382,11 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\vtarget_type\x18\x05 \x01(\tR\n" + "targetType\x12&\n" + "\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\x12\x17\n" + - "\apool_id\x18\a \x01(\tR\x06poolId\"\xfb\x02\n" + + "\apool_id\x18\a \x01(\tR\x06poolId\x12$\n" + + "\x0etarget_is_host\x18\b \x01(\bR\ftargetIsHost\x121\n" + + "\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" + + "\x1btarget_agency_owner_user_id\x18\n" + + " \x01(\x03R\x17targetAgencyOwnerUserId\"\xfb\x02\n" + "\x10SendGiftResponse\x124\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 99e549cf..fb6360e3 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -776,6 +776,12 @@ message SendGiftRequest { string target_type = 5; repeated int64 target_user_ids = 6; string pool_id = 7; + // target_is_host 由 gateway 根据 user-service active host profile 注入,room-service 只透传给 wallet-service。 + bool target_is_host = 8; + // target_host_region_id 是主播所属区域,后续结算按该区域匹配 Host & Agency 政策。 + int64 target_host_region_id = 9; + // target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。 + int64 target_agency_owner_user_id = 10; } // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 63b93855..675a067b 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -33,8 +33,10 @@ type HostProfile struct { FirstBecameHostAtMs int64 `protobuf:"varint,7,opt,name=first_became_host_at_ms,json=firstBecameHostAtMs,proto3" json:"first_became_host_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // current_agency_owner_user_id 是当前 Agency 的收款用户快照,wallet-service 入账时固化该值,避免月底按新归属错发代理工资。 + CurrentAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=current_agency_owner_user_id,json=currentAgencyOwnerUserId,proto3" json:"current_agency_owner_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HostProfile) Reset() { @@ -130,6 +132,13 @@ func (x *HostProfile) GetUpdatedAtMs() int64 { return 0 } +func (x *HostProfile) GetCurrentAgencyOwnerUserId() int64 { + if x != nil { + return x.CurrentAgencyOwnerUserId + } + return 0 +} + // Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。 type Agency struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -144,6 +153,8 @@ type Agency struct { CreatedByUserId int64 `protobuf:"varint,9,opt,name=created_by_user_id,json=createdByUserId,proto3" json:"created_by_user_id,omitempty"` CreatedAtMs int64 `protobuf:"varint,10,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,11,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + Avatar string `protobuf:"bytes,12,opt,name=avatar,proto3" json:"avatar,omitempty"` + HostCount int32 `protobuf:"varint,13,opt,name=host_count,json=hostCount,proto3" json:"host_count,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -255,6 +266,20 @@ func (x *Agency) GetUpdatedAtMs() int64 { return 0 } +func (x *Agency) GetAvatar() string { + if x != nil { + return x.Avatar + } + return "" +} + +func (x *Agency) GetHostCount() int32 { + if x != nil { + return x.HostCount + } + return 0 +} + // BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。 type BDProfile struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3903,7 +3928,7 @@ var File_proto_user_v1_host_proto protoreflect.FileDescriptor const file_proto_user_v1_host_proto_rawDesc = "" + "\n" + - "\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\xd1\x02\n" + + "\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\x91\x03\n" + "\vHostProfile\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" + @@ -3913,7 +3938,9 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x06source\x18\x06 \x01(\tR\x06source\x124\n" + "\x17first_became_host_at_ms\x18\a \x01(\x03R\x13firstBecameHostAtMs\x12\"\n" + "\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xf2\x02\n" + + "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12>\n" + + "\x1ccurrent_agency_owner_user_id\x18\n" + + " \x01(\x03R\x18currentAgencyOwnerUserId\"\xa9\x03\n" + "\x06Agency\x12\x1b\n" + "\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x1b\n" + @@ -3926,7 +3953,10 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x12created_by_user_id\x18\t \x01(\x03R\x0fcreatedByUserId\x12\"\n" + "\rcreated_at_ms\x18\n" + " \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\x95\x02\n" + + "\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\x12\x16\n" + + "\x06avatar\x18\f \x01(\tR\x06avatar\x12\x1d\n" + + "\n" + + "host_count\x18\r \x01(\x05R\thostCount\"\x95\x02\n" + "\tBDProfile\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" + "\x04role\x18\x02 \x01(\tR\x04role\x12\x1b\n" + diff --git a/api/proto/user/v1/host.proto b/api/proto/user/v1/host.proto index f7686d7b..586ad0df 100644 --- a/api/proto/user/v1/host.proto +++ b/api/proto/user/v1/host.proto @@ -17,6 +17,8 @@ message HostProfile { int64 first_became_host_at_ms = 7; int64 created_at_ms = 8; int64 updated_at_ms = 9; + // current_agency_owner_user_id 是当前 Agency 的收款用户快照,wallet-service 入账时固化该值,避免月底按新归属错发代理工资。 + int64 current_agency_owner_user_id = 10; } // Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。 @@ -32,6 +34,8 @@ message Agency { int64 created_by_user_id = 9; int64 created_at_ms = 10; int64 updated_at_ms = 11; + string avatar = 12; + int32 host_count = 13; } // BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。 diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index d4955e05..c712ebc6 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -34,9 +34,15 @@ type DebitGiftRequest struct { PriceVersion string `protobuf:"bytes,7,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"` AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` // region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。 - RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + // target_is_host 由 gateway 根据 user-service active host profile 注入,客户端不能直接声明。 + TargetIsHost bool `protobuf:"varint,10,opt,name=target_is_host,json=targetIsHost,proto3" json:"target_is_host,omitempty"` + // target_host_region_id 是主播身份所属区域,用于后续按主播区域匹配工资政策。 + TargetHostRegionId int64 `protobuf:"varint,11,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"` + // target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。 + TargetAgencyOwnerUserId int64 `protobuf:"varint,12,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebitGiftRequest) Reset() { @@ -132,6 +138,27 @@ func (x *DebitGiftRequest) GetRegionId() int64 { return 0 } +func (x *DebitGiftRequest) GetTargetIsHost() bool { + if x != nil { + return x.TargetIsHost + } + return false +} + +func (x *DebitGiftRequest) GetTargetHostRegionId() int64 { + if x != nil { + return x.TargetHostRegionId + } + return 0 +} + +func (x *DebitGiftRequest) GetTargetAgencyOwnerUserId() int64 { + if x != nil { + return x.TargetAgencyOwnerUserId + } + return 0 +} + // DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。 type DebitGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -146,8 +173,12 @@ type DebitGiftResponse struct { ChargeAssetType string `protobuf:"bytes,8,opt,name=charge_asset_type,json=chargeAssetType,proto3" json:"charge_asset_type,omitempty"` ChargeAmount int64 `protobuf:"varint,9,opt,name=charge_amount,json=chargeAmount,proto3" json:"charge_amount,omitempty"` GiftTypeCode string `protobuf:"bytes,10,opt,name=gift_type_code,json=giftTypeCode,proto3" json:"gift_type_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // host_period_diamond_added 是本次送礼给主播周期钻石账户增加的钻石;非主播为 0。 + HostPeriodDiamondAdded int64 `protobuf:"varint,11,opt,name=host_period_diamond_added,json=hostPeriodDiamondAdded,proto3" json:"host_period_diamond_added,omitempty"` + // host_period_cycle_key 是 UTC 月周期,例如 2026-05;非主播为空。 + HostPeriodCycleKey string `protobuf:"bytes,12,opt,name=host_period_cycle_key,json=hostPeriodCycleKey,proto3" json:"host_period_cycle_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebitGiftResponse) Reset() { @@ -250,6 +281,20 @@ func (x *DebitGiftResponse) GetGiftTypeCode() string { return "" } +func (x *DebitGiftResponse) GetHostPeriodDiamondAdded() int64 { + if x != nil { + return x.HostPeriodDiamondAdded + } + return 0 +} + +func (x *DebitGiftResponse) GetHostPeriodCycleKey() string { + if x != nil { + return x.HostPeriodCycleKey + } + return "" +} + // AssetBalance 是用户某类资产的余额投影。 type AssetBalance struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -10294,18 +10339,19 @@ func (x *CreditTaskRewardResponse) GetGrantedAtMs() int64 { // CreditLuckyGiftRewardRequest 是 activity-service 抽奖 outbox 补偿 worker 调用的钱包入账命令。 type CreditLuckyGiftRewardRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` - AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - TargetUserId int64 `protobuf:"varint,3,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` - Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` - DrawId string `protobuf:"bytes,5,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` - RoomId string `protobuf:"bytes,6,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` - PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` - Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + TargetUserId int64 `protobuf:"varint,3,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` + Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` + DrawId string `protobuf:"bytes,5,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` + RoomId string `protobuf:"bytes,6,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` + PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"` + VisibleRegionId int64 `protobuf:"varint,10,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreditLuckyGiftRewardRequest) Reset() { @@ -10401,6 +10447,13 @@ func (x *CreditLuckyGiftRewardRequest) GetReason() string { return "" } +func (x *CreditLuckyGiftRewardRequest) GetVisibleRegionId() int64 { + if x != nil { + return x.VisibleRegionId + } + return 0 +} + // CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。 type CreditLuckyGiftRewardResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -12276,11 +12329,173 @@ func (x *RetryRedPacketRefundResponse) GetServerTimeMs() int64 { return 0 } +// CronBatchRequest 是 cron-service 触发 wallet-service 后台批处理的统一请求。 +type CronBatchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + RunId string `protobuf:"bytes,3,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + WorkerId string `protobuf:"bytes,4,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"` + BatchSize int32 `protobuf:"varint,5,opt,name=batch_size,json=batchSize,proto3" json:"batch_size,omitempty"` + LockTtlMs int64 `protobuf:"varint,6,opt,name=lock_ttl_ms,json=lockTtlMs,proto3" json:"lock_ttl_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CronBatchRequest) Reset() { + *x = CronBatchRequest{} + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[139] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CronBatchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CronBatchRequest) ProtoMessage() {} + +func (x *CronBatchRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[139] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CronBatchRequest.ProtoReflect.Descriptor instead. +func (*CronBatchRequest) Descriptor() ([]byte, []int) { + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{139} +} + +func (x *CronBatchRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *CronBatchRequest) GetAppCode() string { + if x != nil { + return x.AppCode + } + return "" +} + +func (x *CronBatchRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *CronBatchRequest) GetWorkerId() string { + if x != nil { + return x.WorkerId + } + return "" +} + +func (x *CronBatchRequest) GetBatchSize() int32 { + if x != nil { + return x.BatchSize + } + return 0 +} + +func (x *CronBatchRequest) GetLockTtlMs() int64 { + if x != nil { + return x.LockTtlMs + } + return 0 +} + +// CronBatchResponse 返回一个批处理 run 的执行摘要。 +type CronBatchResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClaimedCount int32 `protobuf:"varint,1,opt,name=claimed_count,json=claimedCount,proto3" json:"claimed_count,omitempty"` + ProcessedCount int32 `protobuf:"varint,2,opt,name=processed_count,json=processedCount,proto3" json:"processed_count,omitempty"` + SuccessCount int32 `protobuf:"varint,3,opt,name=success_count,json=successCount,proto3" json:"success_count,omitempty"` + FailureCount int32 `protobuf:"varint,4,opt,name=failure_count,json=failureCount,proto3" json:"failure_count,omitempty"` + HasMore bool `protobuf:"varint,5,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CronBatchResponse) Reset() { + *x = CronBatchResponse{} + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[140] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CronBatchResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CronBatchResponse) ProtoMessage() {} + +func (x *CronBatchResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[140] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CronBatchResponse.ProtoReflect.Descriptor instead. +func (*CronBatchResponse) Descriptor() ([]byte, []int) { + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{140} +} + +func (x *CronBatchResponse) GetClaimedCount() int32 { + if x != nil { + return x.ClaimedCount + } + return 0 +} + +func (x *CronBatchResponse) GetProcessedCount() int32 { + if x != nil { + return x.ProcessedCount + } + return 0 +} + +func (x *CronBatchResponse) GetSuccessCount() int32 { + if x != nil { + return x.SuccessCount + } + return 0 +} + +func (x *CronBatchResponse) GetFailureCount() int32 { + if x != nil { + return x.FailureCount + } + return 0 +} + +func (x *CronBatchResponse) GetHasMore() bool { + if x != nil { + return x.HasMore + } + return false +} + var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\n" + - "\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xab\x02\n" + + "\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xc2\x03\n" + "\x10DebitGiftRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + @@ -12292,7 +12507,11 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" + "\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" + "\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" + - "\tregion_id\x18\t \x01(\x03R\bregionId\"\x91\x03\n" + + "\tregion_id\x18\t \x01(\x03R\bregionId\x12$\n" + + "\x0etarget_is_host\x18\n" + + " \x01(\bR\ftargetIsHost\x121\n" + + "\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" + + "\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\"\xff\x03\n" + "\x11DebitGiftResponse\x12,\n" + "\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" + "\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" + @@ -12306,7 +12525,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\x12#\n" + "\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\x12$\n" + "\x0egift_type_code\x18\n" + - " \x01(\tR\fgiftTypeCode\"\xb2\x01\n" + + " \x01(\tR\fgiftTypeCode\x129\n" + + "\x19host_period_diamond_added\x18\v \x01(\x03R\x16hostPeriodDiamondAdded\x121\n" + + "\x15host_period_cycle_key\x18\f \x01(\tR\x12hostPeriodCycleKey\"\xb2\x01\n" + "\fAssetBalance\x12\x1d\n" + "\n" + "asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" + @@ -13353,7 +13574,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" + "\x06amount\x18\x03 \x01(\x03R\x06amount\x12\"\n" + - "\rgranted_at_ms\x18\x04 \x01(\x03R\vgrantedAtMs\"\x92\x02\n" + + "\rgranted_at_ms\x18\x04 \x01(\x03R\vgrantedAtMs\"\xbe\x02\n" + "\x1cCreditLuckyGiftRewardRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" + @@ -13364,7 +13585,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\aroom_id\x18\x06 \x01(\tR\x06roomId\x12\x17\n" + "\agift_id\x18\a \x01(\tR\x06giftId\x12\x17\n" + "\apool_id\x18\b \x01(\tR\x06poolId\x12\x16\n" + - "\x06reason\x18\t \x01(\tR\x06reason\"\xbb\x01\n" + + "\x06reason\x18\t \x01(\tR\x06reason\x12*\n" + + "\x11visible_region_id\x18\n" + + " \x01(\x03R\x0fvisibleRegionId\"\xbb\x01\n" + "\x1dCreditLuckyGiftRewardResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" + @@ -13549,7 +13772,26 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x1cRetryRedPacketRefundResponse\x122\n" + "\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12'\n" + "\x0frefunded_amount\x18\x02 \x01(\x03R\x0erefundedAmount\x12$\n" + - "\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs2\xe52\n" + + "\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xbf\x01\n" + + "\x10CronBatchRequest\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x15\n" + + "\x06run_id\x18\x03 \x01(\tR\x05runId\x12\x1b\n" + + "\tworker_id\x18\x04 \x01(\tR\bworkerId\x12\x1d\n" + + "\n" + + "batch_size\x18\x05 \x01(\x05R\tbatchSize\x12\x1e\n" + + "\vlock_ttl_ms\x18\x06 \x01(\x03R\tlockTtlMs\"\xc6\x01\n" + + "\x11CronBatchResponse\x12#\n" + + "\rclaimed_count\x18\x01 \x01(\x05R\fclaimedCount\x12'\n" + + "\x0fprocessed_count\x18\x02 \x01(\x05R\x0eprocessedCount\x12#\n" + + "\rsuccess_count\x18\x03 \x01(\x05R\fsuccessCount\x12#\n" + + "\rfailure_count\x18\x04 \x01(\x05R\ffailureCount\x12\x19\n" + + "\bhas_more\x18\x05 \x01(\bR\ahasMore2\xe0\x02\n" + + "\x11WalletCronService\x12n\n" + + "%ProcessHostSalaryDailySettlementBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12r\n" + + ")ProcessHostSalaryHalfMonthSettlementBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12g\n" + + "\x1eProcessHostSalaryMonthEndBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse2\xe52\n" + "\rWalletService\x12R\n" + "\tDebitGift\x12!.hyapp.wallet.v1.DebitGiftRequest\x1a\".hyapp.wallet.v1.DebitGiftResponse\x12X\n" + "\vGetBalances\x12#.hyapp.wallet.v1.GetBalancesRequest\x1a$.hyapp.wallet.v1.GetBalancesResponse\x12g\n" + @@ -13624,7 +13866,7 @@ func file_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte { return file_proto_wallet_v1_wallet_proto_rawDescData } -var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 139) +var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 141) var file_proto_wallet_v1_wallet_proto_goTypes = []any{ (*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest (*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse @@ -13765,6 +14007,8 @@ var file_proto_wallet_v1_wallet_proto_goTypes = []any{ (*ExpireRedPacketsResponse)(nil), // 136: hyapp.wallet.v1.ExpireRedPacketsResponse (*RetryRedPacketRefundRequest)(nil), // 137: hyapp.wallet.v1.RetryRedPacketRefundRequest (*RetryRedPacketRefundResponse)(nil), // 138: hyapp.wallet.v1.RetryRedPacketRefundResponse + (*CronBatchRequest)(nil), // 139: hyapp.wallet.v1.CronBatchRequest + (*CronBatchResponse)(nil), // 140: hyapp.wallet.v1.CronBatchResponse } var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{ 2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance @@ -13834,128 +14078,134 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{ 122, // 64: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket 122, // 65: hyapp.wallet.v1.GetRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket 122, // 66: hyapp.wallet.v1.RetryRedPacketRefundResponse.packet:type_name -> hyapp.wallet.v1.RedPacket - 0, // 67: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest - 3, // 68: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest - 5, // 69: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest - 7, // 70: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest - 9, // 71: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest - 21, // 72: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest - 23, // 73: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest - 25, // 74: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest - 26, // 75: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest - 27, // 76: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest - 29, // 77: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest - 31, // 78: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest - 33, // 79: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest - 34, // 80: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest - 35, // 81: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest - 37, // 82: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest - 39, // 83: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest - 43, // 84: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest - 44, // 85: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest - 45, // 86: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest - 41, // 87: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest - 47, // 88: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest - 48, // 89: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest - 50, // 90: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest - 52, // 91: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest - 54, // 92: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest - 57, // 93: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest - 59, // 94: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest - 61, // 95: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest - 63, // 96: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest - 66, // 97: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest - 69, // 98: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest - 72, // 99: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest - 75, // 100: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest - 78, // 101: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest - 80, // 102: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest - 82, // 103: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest - 84, // 104: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest - 85, // 105: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest - 86, // 106: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest - 90, // 107: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest - 93, // 108: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest - 96, // 109: hyapp.wallet.v1.WalletService.ApplyWithdrawal:input_type -> hyapp.wallet.v1.ApplyWithdrawalRequest - 101, // 110: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest - 103, // 111: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest - 105, // 112: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest - 107, // 113: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest - 110, // 114: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest - 112, // 115: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest - 114, // 116: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest - 116, // 117: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest - 118, // 118: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest - 123, // 119: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest - 125, // 120: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest - 127, // 121: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest - 129, // 122: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest - 131, // 123: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest - 133, // 124: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest - 135, // 125: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest - 137, // 126: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest - 1, // 127: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse - 4, // 128: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse - 6, // 129: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse - 8, // 130: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse - 10, // 131: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse - 22, // 132: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse - 24, // 133: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse - 28, // 134: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse - 28, // 135: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse - 28, // 136: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse - 30, // 137: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse - 32, // 138: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse - 36, // 139: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse - 36, // 140: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse - 36, // 141: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse - 38, // 142: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse - 40, // 143: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse - 46, // 144: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse - 46, // 145: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse - 46, // 146: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse - 42, // 147: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse - 49, // 148: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse - 49, // 149: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse - 51, // 150: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse - 53, // 151: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse - 55, // 152: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse - 58, // 153: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse - 60, // 154: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse - 62, // 155: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse - 64, // 156: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse - 67, // 157: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse - 70, // 158: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse - 73, // 159: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse - 76, // 160: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse - 79, // 161: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse - 81, // 162: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse - 83, // 163: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse - 87, // 164: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse - 87, // 165: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse - 88, // 166: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse - 91, // 167: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse - 94, // 168: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse - 97, // 169: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse - 102, // 170: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse - 104, // 171: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse - 106, // 172: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse - 108, // 173: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse - 111, // 174: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse - 113, // 175: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse - 115, // 176: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse - 117, // 177: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse - 119, // 178: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse - 124, // 179: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse - 126, // 180: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse - 128, // 181: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse - 130, // 182: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse - 132, // 183: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse - 134, // 184: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse - 136, // 185: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse - 138, // 186: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse - 127, // [127:187] is the sub-list for method output_type - 67, // [67:127] is the sub-list for method input_type + 139, // 67: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest + 139, // 68: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest + 139, // 69: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest + 0, // 70: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest + 3, // 71: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest + 5, // 72: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest + 7, // 73: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest + 9, // 74: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest + 21, // 75: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest + 23, // 76: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest + 25, // 77: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest + 26, // 78: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest + 27, // 79: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest + 29, // 80: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest + 31, // 81: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest + 33, // 82: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest + 34, // 83: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest + 35, // 84: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest + 37, // 85: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest + 39, // 86: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest + 43, // 87: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest + 44, // 88: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest + 45, // 89: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest + 41, // 90: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest + 47, // 91: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest + 48, // 92: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest + 50, // 93: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest + 52, // 94: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest + 54, // 95: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest + 57, // 96: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest + 59, // 97: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest + 61, // 98: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest + 63, // 99: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest + 66, // 100: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest + 69, // 101: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest + 72, // 102: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest + 75, // 103: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest + 78, // 104: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest + 80, // 105: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest + 82, // 106: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest + 84, // 107: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest + 85, // 108: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest + 86, // 109: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest + 90, // 110: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest + 93, // 111: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest + 96, // 112: hyapp.wallet.v1.WalletService.ApplyWithdrawal:input_type -> hyapp.wallet.v1.ApplyWithdrawalRequest + 101, // 113: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest + 103, // 114: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest + 105, // 115: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest + 107, // 116: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest + 110, // 117: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest + 112, // 118: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest + 114, // 119: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest + 116, // 120: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest + 118, // 121: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest + 123, // 122: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest + 125, // 123: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest + 127, // 124: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest + 129, // 125: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest + 131, // 126: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest + 133, // 127: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest + 135, // 128: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest + 137, // 129: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest + 140, // 130: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse + 140, // 131: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse + 140, // 132: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse + 1, // 133: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse + 4, // 134: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse + 6, // 135: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse + 8, // 136: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse + 10, // 137: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse + 22, // 138: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse + 24, // 139: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse + 28, // 140: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse + 28, // 141: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse + 28, // 142: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse + 30, // 143: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse + 32, // 144: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse + 36, // 145: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse + 36, // 146: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse + 36, // 147: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse + 38, // 148: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse + 40, // 149: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse + 46, // 150: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse + 46, // 151: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse + 46, // 152: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse + 42, // 153: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse + 49, // 154: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse + 49, // 155: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse + 51, // 156: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse + 53, // 157: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse + 55, // 158: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse + 58, // 159: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse + 60, // 160: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse + 62, // 161: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse + 64, // 162: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse + 67, // 163: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse + 70, // 164: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse + 73, // 165: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse + 76, // 166: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse + 79, // 167: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse + 81, // 168: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse + 83, // 169: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse + 87, // 170: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse + 87, // 171: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse + 88, // 172: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse + 91, // 173: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse + 94, // 174: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse + 97, // 175: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse + 102, // 176: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse + 104, // 177: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse + 106, // 178: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse + 108, // 179: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse + 111, // 180: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse + 113, // 181: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse + 115, // 182: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse + 117, // 183: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse + 119, // 184: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse + 124, // 185: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse + 126, // 186: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse + 128, // 187: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse + 130, // 188: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse + 132, // 189: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse + 134, // 190: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse + 136, // 191: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse + 138, // 192: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse + 130, // [130:193] is the sub-list for method output_type + 67, // [67:130] is the sub-list for method input_type 67, // [67:67] is the sub-list for extension type_name 67, // [67:67] is the sub-list for extension extendee 0, // [0:67] is the sub-list for field type_name @@ -13974,9 +14224,9 @@ func file_proto_wallet_v1_wallet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_wallet_v1_wallet_proto_rawDesc), len(file_proto_wallet_v1_wallet_proto_rawDesc)), NumEnums: 0, - NumMessages: 139, + NumMessages: 141, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_proto_wallet_v1_wallet_proto_goTypes, DependencyIndexes: file_proto_wallet_v1_wallet_proto_depIdxs, diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 50aab175..a06d6ba5 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -17,6 +17,12 @@ message DebitGiftRequest { string app_code = 8; // region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。 int64 region_id = 9; + // target_is_host 由 gateway 根据 user-service active host profile 注入,客户端不能直接声明。 + bool target_is_host = 10; + // target_host_region_id 是主播身份所属区域,用于后续按主播区域匹配工资政策。 + int64 target_host_region_id = 11; + // target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。 + int64 target_agency_owner_user_id = 12; } // DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。 @@ -32,6 +38,10 @@ message DebitGiftResponse { string charge_asset_type = 8; int64 charge_amount = 9; string gift_type_code = 10; + // host_period_diamond_added 是本次送礼给主播周期钻石账户增加的钻石;非主播为 0。 + int64 host_period_diamond_added = 11; + // host_period_cycle_key 是 UTC 月周期,例如 2026-05;非主播为空。 + string host_period_cycle_key = 12; } // AssetBalance 是用户某类资产的余额投影。 @@ -1145,6 +1155,7 @@ message CreditLuckyGiftRewardRequest { string gift_id = 7; string pool_id = 8; string reason = 9; + int64 visible_region_id = 10; } // CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。 @@ -1351,6 +1362,32 @@ message RetryRedPacketRefundResponse { int64 server_time_ms = 3; } +// CronBatchRequest 是 cron-service 触发 wallet-service 后台批处理的统一请求。 +message CronBatchRequest { + string request_id = 1; + string app_code = 2; + string run_id = 3; + string worker_id = 4; + int32 batch_size = 5; + int64 lock_ttl_ms = 6; +} + +// CronBatchResponse 返回一个批处理 run 的执行摘要。 +message CronBatchResponse { + int32 claimed_count = 1; + int32 processed_count = 2; + int32 success_count = 3; + int32 failure_count = 4; + bool has_more = 5; +} + +// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。 +service WalletCronService { + rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse); + rpc ProcessHostSalaryHalfMonthSettlementBatch(CronBatchRequest) returns (CronBatchResponse); + rpc ProcessHostSalaryMonthEndBatch(CronBatchRequest) returns (CronBatchResponse); +} + // WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。 service WalletService { rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse); diff --git a/api/proto/wallet/v1/wallet_grpc.pb.go b/api/proto/wallet/v1/wallet_grpc.pb.go index c3b30f7e..97346b9c 100644 --- a/api/proto/wallet/v1/wallet_grpc.pb.go +++ b/api/proto/wallet/v1/wallet_grpc.pb.go @@ -18,6 +18,188 @@ import ( // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 +const ( + WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryDailySettlementBatch" + WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryHalfMonthSettlementBatch" + WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryMonthEndBatch" +) + +// WalletCronServiceClient is the client API for WalletCronService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。 +type WalletCronServiceClient interface { + ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) + ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) + ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) +} + +type walletCronServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewWalletCronServiceClient(cc grpc.ClientConnInterface) WalletCronServiceClient { + return &walletCronServiceClient{cc} +} + +func (c *walletCronServiceClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CronBatchResponse) + err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletCronServiceClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CronBatchResponse) + err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletCronServiceClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CronBatchResponse) + err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// WalletCronServiceServer is the server API for WalletCronService service. +// All implementations must embed UnimplementedWalletCronServiceServer +// for forward compatibility. +// +// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。 +type WalletCronServiceServer interface { + ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) + ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) + ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) + mustEmbedUnimplementedWalletCronServiceServer() +} + +// UnimplementedWalletCronServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedWalletCronServiceServer struct{} + +func (UnimplementedWalletCronServiceServer) ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryDailySettlementBatch not implemented") +} +func (UnimplementedWalletCronServiceServer) ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryHalfMonthSettlementBatch not implemented") +} +func (UnimplementedWalletCronServiceServer) ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryMonthEndBatch not implemented") +} +func (UnimplementedWalletCronServiceServer) mustEmbedUnimplementedWalletCronServiceServer() {} +func (UnimplementedWalletCronServiceServer) testEmbeddedByValue() {} + +// UnsafeWalletCronServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to WalletCronServiceServer will +// result in compilation errors. +type UnsafeWalletCronServiceServer interface { + mustEmbedUnimplementedWalletCronServiceServer() +} + +func RegisterWalletCronServiceServer(s grpc.ServiceRegistrar, srv WalletCronServiceServer) { + // If the following call panics, it indicates UnimplementedWalletCronServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&WalletCronService_ServiceDesc, srv) +} + +func _WalletCronService_ProcessHostSalaryDailySettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CronBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletCronServiceServer).ProcessHostSalaryDailySettlementBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletCronServiceServer).ProcessHostSalaryDailySettlementBatch(ctx, req.(*CronBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CronBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletCronServiceServer).ProcessHostSalaryHalfMonthSettlementBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletCronServiceServer).ProcessHostSalaryHalfMonthSettlementBatch(ctx, req.(*CronBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CronBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletCronServiceServer).ProcessHostSalaryMonthEndBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletCronServiceServer).ProcessHostSalaryMonthEndBatch(ctx, req.(*CronBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// WalletCronService_ServiceDesc is the grpc.ServiceDesc for WalletCronService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var WalletCronService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "hyapp.wallet.v1.WalletCronService", + HandlerType: (*WalletCronServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ProcessHostSalaryDailySettlementBatch", + Handler: _WalletCronService_ProcessHostSalaryDailySettlementBatch_Handler, + }, + { + MethodName: "ProcessHostSalaryHalfMonthSettlementBatch", + Handler: _WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_Handler, + }, + { + MethodName: "ProcessHostSalaryMonthEndBatch", + Handler: _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/wallet/v1/wallet.proto", +} + const ( WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift" WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances" diff --git a/cmd/dev-run/main.go b/cmd/dev-run/main.go index 7c316c0b..1e874aac 100644 --- a/cmd/dev-run/main.go +++ b/cmd/dev-run/main.go @@ -1,4 +1,4 @@ -// Package main provides the repository-local hot reload runner behind `make run`. +// Package main provides the repository-local go run launcher behind `make run`. package main import ( @@ -20,19 +20,14 @@ import ( "time" ) -const ( - scanInterval = 800 * time.Millisecond - restartDebounce = 350 * time.Millisecond - stopTimeout = 8 * time.Second -) +const stopTimeout = 8 * time.Second type serviceSpec struct { - Name string - Dir string - Args []string - WatchRoot []string - Ports []int - Color string + Name string + Dir string + Args []string + Ports []int + Color string } type serviceRunner struct { @@ -66,7 +61,6 @@ func main() { defer stop() runners := make(map[string]*serviceRunner, len(specs)) - watchers := make([]*watchState, 0, len(specs)) for _, spec := range specs { runner := &serviceRunner{spec: spec} runners[spec.Name] = runner @@ -74,43 +68,13 @@ func main() { if err := runner.start(root); err != nil { logf(os.Stderr, spec, "start failed: %v", err) } - watchers = append(watchers, newWatchState(root, spec)) } - printPlain(os.Stdout, "dev-run watching %d service(s): %s", len(specs), serviceNames(specs)) + printPlain(os.Stdout, "dev-run started %d service(s): %s", len(specs), serviceNames(specs)) printPlain(os.Stdout, "press Ctrl-C to stop all services") - ticker := time.NewTicker(scanInterval) - defer ticker.Stop() - - pending := map[string]time.Time{} - for { - select { - case <-ctx.Done(): - stopAll(runners) - return - case now := <-ticker.C: - for _, watcher := range watchers { - changed, err := watcher.changed() - if err != nil { - logf(os.Stderr, watcher.spec, "watch failed: %v", err) - continue - } - if changed { - pending[watcher.spec.Name] = now - } - } - for name, changedAt := range pending { - if now.Sub(changedAt) < restartDebounce { - continue - } - delete(pending, name) - runner := runners[name] - logf(os.Stdout, runner.spec, "change detected, restarting") - runner.restart(root) - } - } - } + <-ctx.Done() + stopAll(runners) } func allServices() []serviceSpec { @@ -126,13 +90,9 @@ func allServices() []serviceSpec { appService("gateway-service", []int{13000}, "92"), appService("statistics-service", []int{13010, 13110}, "94"), { - Name: "admin", - Dir: "server/admin", - Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"}, - WatchRoot: []string{ - "server/admin", - "api", - }, + Name: "admin", + Dir: "server/admin", + Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"}, Ports: []int{13100}, Color: "91", }, @@ -141,14 +101,9 @@ func allServices() []serviceSpec { func appService(name string, ports []int, color string) serviceSpec { return serviceSpec{ - Name: name, - Dir: ".", - Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"}, - WatchRoot: []string{ - "services/" + name, - "pkg", - "api", - }, + Name: name, + Dir: ".", + Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"}, Ports: ports, Color: color, } @@ -238,14 +193,6 @@ func (r *serviceRunner) start(repoRoot string) error { return nil } -func (r *serviceRunner) restart(repoRoot string) { - r.stop() - killPortOwners(r.spec) - if err := r.start(repoRoot); err != nil { - logf(os.Stderr, r.spec, "restart failed: %v", err) - } -} - func (r *serviceRunner) stop() { r.mu.Lock() cmd := r.cmd @@ -306,114 +253,6 @@ func stopAll(runners map[string]*serviceRunner) { wg.Wait() } -type watchState struct { - root string - spec serviceSpec - snapshot map[string]fileStamp -} - -type fileStamp struct { - modTime time.Time - size int64 -} - -func newWatchState(root string, spec serviceSpec) *watchState { - w := &watchState{root: root, spec: spec} - w.snapshot = w.scan() - return w -} - -func (w *watchState) changed() (bool, error) { - next := w.scan() - changed := !sameSnapshot(w.snapshot, next) - w.snapshot = next - return changed, nil -} - -func (w *watchState) scan() map[string]fileStamp { - out := make(map[string]fileStamp) - for _, rel := range w.spec.WatchRoot { - w.scanPath(out, rel) - } - // 根 module 变更会影响所有 app 服务;admin 还有自己的独立 module 文件。 - w.scanPath(out, "go.mod") - w.scanPath(out, "go.sum") - if w.spec.Name == "admin" { - w.scanPath(out, filepath.Join("server", "admin", "go.mod")) - w.scanPath(out, filepath.Join("server", "admin", "go.sum")) - } - return out -} - -func (w *watchState) scanPath(out map[string]fileStamp, rel string) { - path := filepath.Join(w.root, rel) - info, err := os.Stat(path) - if err != nil { - return - } - if !info.IsDir() { - if watchableFile(path) { - out[rel] = fileStamp{modTime: info.ModTime(), size: info.Size()} - } - return - } - _ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - if ignoredDir(d.Name()) { - return filepath.SkipDir - } - return nil - } - if !watchableFile(path) { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - relPath, err := filepath.Rel(w.root, path) - if err != nil { - return nil - } - out[relPath] = fileStamp{modTime: info.ModTime(), size: info.Size()} - return nil - }) -} - -func ignoredDir(name string) bool { - switch name { - case ".git", ".idea", ".vscode", "bin", "dist", "node_modules", "storage", "tmp", "vendor": - return true - default: - return false - } -} - -func watchableFile(path string) bool { - switch filepath.Ext(path) { - case ".go", ".yaml", ".yml", ".proto", ".mod", ".sum": - return true - default: - return false - } -} - -func sameSnapshot(a, b map[string]fileStamp) bool { - if len(a) != len(b) { - return false - } - for path, stampA := range a { - stampB, ok := b[path] - if !ok || !stampA.modTime.Equal(stampB.modTime) || stampA.size != stampB.size { - return false - } - } - return true -} - func killPortOwners(spec serviceSpec) { seen := map[int]bool{} for _, port := range spec.Ports { diff --git a/docs/Outbox统一治理说明.md b/docs/Outbox统一治理说明.md new file mode 100644 index 00000000..8fdd85cb --- /dev/null +++ b/docs/Outbox统一治理说明.md @@ -0,0 +1,308 @@ +# Outbox 统一治理说明 + +更新时间:2026-06-01 + +## 背景 + +生产 MySQL CPU 打满的直接触发点是多个 worker 高频轮询 outbox 表,尤其是 `wallet_outbox` 数据量已经到百万级后,部分查询没有完整匹配索引、带 `OR` 分支、缺少 `app_code` 过滤或由下游服务直扫 owner 表,导致 MySQL 需要持续扫描大量历史 outbox 行。 + +这次治理目标是: + +- owner service 只允许自己扫描自己的 outbox,并发布 MQ。 +- 下游 projection 不再直接扫描 owner outbox。 +- 所有 outbox claim 查询必须带 `app_code + status/event_type + retry/lock + created_at` 匹配索引。 +- 所有 `OR` claim 分支拆成独立查询。 +- `delivered/done` 历史 outbox 建立归档机制。 + +## 修改前 + +### 运行方式 + +- `wallet-service` 扫 `wallet_outbox` 发布 `hyapp_wallet_outbox` MQ。 +- `room-service` 扫 `room_outbox` 发布 `hyapp_room_outbox` MQ。 +- `activity-service`、`game-service`、`user-service` 也各自有 outbox worker。 +- `notice-service` 仍保留直扫 `wallet_outbox`、`room_outbox` 的历史 worker 能力。 +- `wallet-service` 内部的礼物墙和 badge projection worker 也在轮询扫描 `wallet_outbox`。 +- 部分 claim SQL 把 `pending/retryable/locked expired` 写在同一个 `OR` 条件里。 +- 部分 outbox 索引缺少 `app_code`、`next_retry_at_ms`、`lock_until_ms/locked_until_ms`、`event_id` 这样的匹配列。 +- `game_outbox`、`user_outbox` 缺少完整的 worker lock/retry 字段。 +- 历史 `delivered/done` 数据没有统一归档脚本。 + +### 主要风险 + +- outbox 表越大,高频轮询越容易退化成大范围扫描。 +- 下游服务直接读 owner outbox,会把 owner 表变成跨服务共享查询热点。 +- `OR` 查询很容易让优化器放弃最优索引路径。 +- delivered 历史数据留在热表中,会持续抬高索引体积和扫描成本。 +- projection、通知、MQ 发布混用 outbox 状态,后续排障边界不清晰。 + +## 修改后 + +### 运行方式 + +- owner service 继续负责自己的 outbox claim 和 MQ 发布: + - `wallet-service` 发布 `hyapp_wallet_outbox` + - `room-service` 发布 `hyapp_room_outbox` + - `activity-service` 发布 activity / broadcast 相关 MQ + - `game-service` 发布 game 相关 MQ + - `user-service` 发布 `hyapp_user_outbox` +- `notice-service` 不再启动直扫 `wallet_outbox` / `room_outbox` 的 worker,改为消费 MQ。 +- `wallet-service` 内部礼物墙和 badge projection 不再轮询扫描 `wallet_outbox`,改为消费 `hyapp_wallet_outbox` MQ。 +- claim 查询拆成多段: + - `pending` + - `retryable` + - `delivering/running expired lock` +- claim 查询全部显式带 `app_code`,并匹配对应索引。 +- delivered/done 历史 outbox 通过归档脚本搬到 archive 表。 + +## 修复掉的问题 + +### 1. 下游直扫 owner outbox + +之前: + +- `notice-service` 可以直接扫描 `wallet_outbox`、`room_outbox`。 +- `wallet-service` 内部 projection worker 轮询扫描 `wallet_outbox` 做礼物墙和 badge 投影。 + +现在: + +- `notice-service` runtime 配置禁止直扫 wallet/room outbox,只消费 MQ。 +- `wallet-service` 的礼物墙和 badge projection 改为消费 `hyapp_wallet_outbox` MQ。 +- 生产验证中,`wallet_projection_events / idx_wallet_outbox_event_created / idx_wallet_outbox_asset_event_created` 相关直扫进程数为 `0`。 + +实现位置: + +- `services/notice-service/internal/config/config.go` +- `services/notice-service/internal/app/app.go` +- `services/notice-service/internal/modules/walletnotice/service.go` +- `services/notice-service/internal/modules/walletnotice/mysql_repository.go` +- `services/wallet-service/internal/app/app.go` +- `services/wallet-service/internal/service/wallet/service.go` +- `services/wallet-service/internal/storage/mysql/gift_wall_projection.go` +- `services/wallet-service/internal/storage/mysql/badge_projection_relay.go` + +### 2. claim 查询中 `OR` 导致索引不可控 + +之前: + +- 多个 outbox claim SQL 用一个查询覆盖 pending、retryable、锁过期重试。 +- SQL 里有 `OR` 分支,优化器在大表上不稳定,容易扫描过多行。 + +现在: + +- claim 查询按状态拆成独立分支。 +- 每个分支只走自己的索引条件。 +- 必要位置使用 `FORCE INDEX` 固定索引路径。 + +实现位置: + +- `services/wallet-service/internal/storage/mysql/repository.go` +- `services/room-service/internal/storage/mysql/repository.go` +- `services/activity-service/internal/storage/mysql/broadcast_repository.go` +- `services/game-service/internal/storage/mysql/repository.go` +- `services/user-service/internal/storage/mysql/user_outbox.go` + +### 3. 缺少 app_code 和匹配索引 + +之前: + +- 部分 claim 查询没有完整 `app_code + status + retry/lock + created_at` 索引。 +- 部分索引没有 `event_id` 尾列,排序和稳定分页不够完整。 + +现在: + +- wallet、room、activity、game、user、notice 相关 outbox 都补了 claim/retention 索引。 +- 生产库已验证目标索引存在,关键 `EXPLAIN` 都命中指定索引。 + +关键索引形态: + +```sql +app_code, status, next_retry_at_ms, created_at_ms, event_id +app_code, status, lock_until_ms, created_at_ms, event_id +app_code, status, updated_at_ms, event_id +``` + +部分表字段名不同: + +- `im_broadcast_outbox` 使用 `locked_until_ms` +- `notice_delivery_events` 使用 `source_name, app_code, channel, status, next_retry_at_ms/lock_until_ms, updated_at_ms` + +### 4. game/user outbox 缺少标准锁字段 + +之前: + +- `game_outbox`、`user_outbox` 没有完整的 `worker_id`、`lock_until_ms`、`retry_count`、`next_retry_at_ms`、`last_error` 字段。 +- worker 失败和过期接管能力不统一。 + +现在: + +- 两张表都补齐标准 worker claim 字段。 +- claim 逻辑统一为 pending/retryable/running expired 三段。 + +实现位置: + +- `services/game-service/internal/storage/mysql/repository.go` +- `services/user-service/internal/storage/mysql/user_outbox.go` +- `services/game-service/internal/storage/mysql/repository.go` 的 schema/migrate 逻辑 +- `services/user-service/deploy/mysql/initdb/001_user_service.sql` + +### 5. delivered/done 热表历史数据无归档 + +之前: + +- delivered/done 历史 outbox 一直留在业务热表。 +- 表越大,索引越大,后续即使查询优化了也会逐步增加维护成本。 + +现在: + +- 新增统一归档脚本: + +`scripts/mysql/archive_delivered_outbox.sql` + +- 归档目标是同库 archive 表: + - `hyapp_wallet.wallet_outbox_archive` + - `hyapp_room.room_outbox_archive` + - `hyapp_activity.activity_outbox_archive` + - `hyapp_activity.im_broadcast_outbox_archive` + - `hyapp_game.game_outbox_archive` + - `hyapp_game.game_level_event_outbox_archive` + - `hyapp_user.user_outbox_archive` + - `hyapp_notice.notice_delivery_events_archive` + +## 归档方式 + +归档脚本逻辑: + +1. 默认归档 30 天以前的数据: + +```sql +SET @outbox_archive_cutoff_ms := IFNULL( + @outbox_archive_cutoff_ms, + UNIX_TIMESTAMP(DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) * 1000 +); +``` + +2. 每张表先创建同结构 archive 表: + +```sql +CREATE TABLE IF NOT EXISTS hyapp_wallet.wallet_outbox_archive LIKE hyapp_wallet.wallet_outbox; +``` + +3. 先 `INSERT IGNORE` 到 archive 表: + +```sql +INSERT IGNORE INTO hyapp_wallet.wallet_outbox_archive +SELECT * FROM hyapp_wallet.wallet_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +``` + +4. 确认 archive 表存在对应记录后,再删除源表: + +```sql +DELETE FROM hyapp_wallet.wallet_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_wallet.wallet_outbox_archive a + WHERE a.app_code = wallet_outbox.app_code AND a.event_id = wallet_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; +``` + +每张表每次最多处理 `10000` 行,避免一次归档造成长事务和复制压力。 + +生产执行结果: + +- 脚本已执行。 +- 本次 30 天前 `delivered/done` 候选行为 `0`。 +- archive 表已通过脚本创建。 + +## 本次提交 + +- `60b7277 fix: govern outbox polling and archiving` +- `bd0ed78 fix: add game outbox retention index` +- `0aef70e fix: consume wallet projections from mq` + +生产部署: + +- `wallet-service`: `main-0aef70e-outbox-governance` +- `room-service/activity-service/game-service/user-service/notice-service`: `main-bd0ed78-outbox-governance` + +## 当前方案的优点 + +- 明确服务边界:owner outbox 只由 owner service 扫描和发布 MQ。 +- 降低 MySQL CPU 风险:下游 projection 不再对 owner 大表做高频轮询。 +- 查询路径稳定:claim SQL 拆分后,索引选择更可控。 +- 支持多实例并发:继续使用 lock 字段和 `FOR UPDATE SKIP LOCKED`。 +- 支持失败重试:通过 `retryable`、`next_retry_at_ms`、`lock_until_ms` 做指数退避和过期接管。 +- 历史数据可控:delivered/done 可以按批归档,热表不会无限增长。 +- 保留幂等语义:下游消费通过各自投影表或 delivery 表做去重,不依赖 MQ exactly-once。 + +## 当前方案的缺点 + +- MQ 成为关键依赖:MQ 故障时,下游 projection 会延迟。 +- 归档现在是脚本,不是定时任务;需要接入 cron 或运维调度。 +- 仍保留部分离线/测试用直扫方法,虽然 runtime 不启动,但后续要避免误开配置。 +- range 查询因为 `next_retry_at_ms <= now` 或 `lock_until_ms <= now`,`EXPLAIN` 里仍可能出现 `Using filesort`;目前扫描行数已被前缀索引压住,但极端积压下仍要关注。 +- MQ message 只覆盖被成功发布后的事件;如果历史 projection 已有缺口,需要单独跑一次离线修复或重放。 +- archive 表目前与源表同库同实例,能降低热表成本,但不能降低整个 MySQL 实例总存储压力。 + +## 还需要补足 + +### 1. 固化归档调度 + +建议把 `scripts/mysql/archive_delivered_outbox.sql` 接入定时任务: + +- 每天或每小时执行一次。 +- 每次每表限制 `10000` 行。 +- 归档窗口默认 30 天,可通过 `@outbox_archive_cutoff_ms` 覆盖。 +- 执行前后记录每张表归档行数。 + +### 2. 增加 outbox 指标和告警 + +建议增加以下指标: + +- 每张 outbox 各状态数量:`pending/retryable/delivering/running/delivered/dead` +- 最老 pending 时间 +- retryable 积压数量 +- dead 数量 +- MQ 发布失败数 +- MQ 消费失败数 +- projection duplicate / skipped / failed 数量 + +### 3. 增加配置保护 + +现在 notice 已经禁止 runtime 直扫 wallet/room outbox。后续建议继续收紧: + +- 对所有下游服务保留的 legacy direct scan worker 增加启动保护。 +- 生产配置中直接删除或禁用直扫配置。 +- CI 增加 SQL lint,检查 outbox claim 是否缺 `app_code` 或含 `OR`。 + +### 4. 历史 projection 缺口修复 + +对于已经 `delivered` 但下游 projection 没处理到的历史事件,建议单独做一次离线 backfill: + +- 从 owner outbox 按时间窗口导出缺口事件。 +- 写入对应 projection/delivery 幂等表。 +- 或重新投递 MQ,但要控制速率。 + +### 5. 更彻底的数据生命周期治理 + +中长期可以考虑: + +- outbox 热表只保留未完成和近期完成数据。 +- delivered/done 进入 archive 库或对象存储,而不是同库 archive 表。 +- 按 `created_at_ms` 或 `updated_at_ms` 做分区表,归档时按分区迁移或 drop partition。 +- 大流量 outbox 拆成 pending 表和 history 表,避免热冷数据混在同一张表。 + +## 最终状态 + +当前生产已验证: + +- 目标服务均健康。 +- outbox claim 索引已存在且 `EXPLAIN` 命中指定索引。 +- notice 不再直扫 wallet/room outbox。 +- wallet 内部 projection 不再轮询扫描 `wallet_outbox`。 +- MySQL processlist 中 wallet projection 相关 outbox 直扫检查结果为 `0`。 diff --git a/scripts/mysql/archive_delivered_outbox.sql b/scripts/mysql/archive_delivered_outbox.sql new file mode 100644 index 00000000..9158e51c --- /dev/null +++ b/scripts/mysql/archive_delivered_outbox.sql @@ -0,0 +1,127 @@ +SET @outbox_archive_cutoff_ms := IFNULL( + @outbox_archive_cutoff_ms, + UNIX_TIMESTAMP(DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) * 1000 +); + +CREATE TABLE IF NOT EXISTS hyapp_wallet.wallet_outbox_archive LIKE hyapp_wallet.wallet_outbox; +INSERT IGNORE INTO hyapp_wallet.wallet_outbox_archive +SELECT * FROM hyapp_wallet.wallet_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_wallet.wallet_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_wallet.wallet_outbox_archive a + WHERE a.app_code = wallet_outbox.app_code AND a.event_id = wallet_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_room.room_outbox_archive LIKE hyapp_room.room_outbox; +INSERT IGNORE INTO hyapp_room.room_outbox_archive +SELECT * FROM hyapp_room.room_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_room.room_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_room.room_outbox_archive a + WHERE a.app_code = room_outbox.app_code AND a.event_id = room_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_activity.activity_outbox_archive LIKE hyapp_activity.activity_outbox; +INSERT IGNORE INTO hyapp_activity.activity_outbox_archive +SELECT * FROM hyapp_activity.activity_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_activity.activity_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_activity.activity_outbox_archive a + WHERE a.app_code = activity_outbox.app_code AND a.outbox_id = activity_outbox.outbox_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_activity.im_broadcast_outbox_archive LIKE hyapp_activity.im_broadcast_outbox; +INSERT IGNORE INTO hyapp_activity.im_broadcast_outbox_archive +SELECT * FROM hyapp_activity.im_broadcast_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_activity.im_broadcast_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_activity.im_broadcast_outbox_archive a + WHERE a.app_code = im_broadcast_outbox.app_code AND a.event_id = im_broadcast_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_game.game_outbox_archive LIKE hyapp_game.game_outbox; +INSERT IGNORE INTO hyapp_game.game_outbox_archive +SELECT * FROM hyapp_game.game_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_game.game_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_game.game_outbox_archive a + WHERE a.app_code = game_outbox.app_code AND a.event_id = game_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_game.game_level_event_outbox_archive LIKE hyapp_game.game_level_event_outbox; +INSERT IGNORE INTO hyapp_game.game_level_event_outbox_archive +SELECT * FROM hyapp_game.game_level_event_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_game.game_level_event_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_game.game_level_event_outbox_archive a + WHERE a.app_code = game_level_event_outbox.app_code AND a.event_id = game_level_event_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_user.user_outbox_archive LIKE hyapp_user.user_outbox; +INSERT IGNORE INTO hyapp_user.user_outbox_archive +SELECT * FROM hyapp_user.user_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_user.user_outbox +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_user.user_outbox_archive a + WHERE a.app_code = user_outbox.app_code AND a.event_id = user_outbox.event_id + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; + +CREATE TABLE IF NOT EXISTS hyapp_notice.notice_delivery_events_archive LIKE hyapp_notice.notice_delivery_events; +INSERT IGNORE INTO hyapp_notice.notice_delivery_events_archive +SELECT * FROM hyapp_notice.notice_delivery_events +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms +ORDER BY updated_at_ms ASC +LIMIT 10000; +DELETE FROM hyapp_notice.notice_delivery_events +WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms + AND EXISTS ( + SELECT 1 FROM hyapp_notice.notice_delivery_events_archive a + WHERE a.source_name = notice_delivery_events.source_name + AND a.app_code = notice_delivery_events.app_code + AND a.source_event_id = notice_delivery_events.source_event_id + AND a.channel = notice_delivery_events.channel + ) +ORDER BY updated_at_ms ASC +LIMIT 10000; diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 6c6115c6..38099096 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -37,7 +37,9 @@ import ( firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement" healthmodule "hyapp-admin-server/internal/modules/health" + hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy" hostorgmodule "hyapp-admin-server/internal/modules/hostorg" + hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement" jobmodule "hyapp-admin-server/internal/modules/job" levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig" luckygiftmodule "hyapp-admin-server/internal/modules/luckygift" @@ -53,6 +55,8 @@ import ( roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure" searchmodule "hyapp-admin-server/internal/modules/search" sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin" + teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy" + teamsalarysettlementmodule "hyapp-admin-server/internal/modules/teamsalarysettlement" uploadmodule "hyapp-admin-server/internal/modules/upload" userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard" vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig" @@ -191,6 +195,12 @@ func main() { } auditHandler := auditmodule.New(store) + teamSalarySettlementHandler := teamsalarysettlementmodule.New(sqlDB, walletDB, userDB, auditHandler) + if cfg.Jobs.Enabled { + teamSalaryRunner := teamsalarysettlementmodule.NewAutoRunner(teamSalarySettlementHandler.Service(), "lalu", cfg.NodeID) + teamSalaryRunner.Start() + defer teamSalaryRunner.Close() + } var objectUploader uploadmodule.ObjectUploader if cfg.TencentCOS.Enabled { uploader, err := tencentcos.NewUploader(tencentcos.Config{ @@ -206,39 +216,43 @@ func main() { objectUploader = uploader } handlers := router.Handlers{ - Audit: auditHandler, - Auth: authmodule.New(store, auth, auditHandler, cfg), - AdminUser: adminusermodule.New(store, cfg, auditHandler), - AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), - AppConfig: appconfigmodule.New(store, auditHandler), - AppRegistry: appregistrymodule.New(userDB), - AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler), - CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler), - CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler), - DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler), - Dashboard: dashboardmodule.New(store, cfg), - FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), - Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler), - Health: healthmodule.New(store, redisClient, jobStatus), - HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), - Job: jobmodule.New(store, cfg, auditHandler), - LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), - LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler), - Menu: menumodule.New(store, auditHandler), - Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), - RBAC: rbacmodule.New(store, auditHandler), - RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler), - Report: reportmodule.New(userDB, roomClient), - RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), - RegionBlock: regionblockmodule.New(userDB, auditHandler), - Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler), - RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler), - RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler), - Search: searchmodule.New(store), - SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), - Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), - UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient), - VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler), + Audit: auditHandler, + Auth: authmodule.New(store, auth, auditHandler, cfg), + AdminUser: adminusermodule.New(store, cfg, auditHandler), + AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), + AppConfig: appconfigmodule.New(store, auditHandler), + AppRegistry: appregistrymodule.New(userDB), + AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler), + CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler), + CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler), + DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler), + Dashboard: dashboardmodule.New(store, cfg), + FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), + Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler), + Health: healthmodule.New(store, redisClient, jobStatus), + HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler), + HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), + HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB), + Job: jobmodule.New(store, cfg, auditHandler), + LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), + LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), + Menu: menumodule.New(store, auditHandler), + Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), + RBAC: rbacmodule.New(store, auditHandler), + RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler), + Report: reportmodule.New(userDB, roomClient), + RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), + RegionBlock: regionblockmodule.New(userDB, auditHandler), + Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler), + RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler), + RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler), + Search: searchmodule.New(store), + SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), + TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler), + TeamSalarySettlement: teamSalarySettlementHandler, + Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), + UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient), + VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler), } engine := router.New(cfg, auth, handlers) diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index cc6d73df..4acfddce 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -18,7 +18,7 @@ migrations: dir: "/opt/hyapp/admin-server/migrations" allow_checksum_repair: false jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS" -access_token_ttl: "30m" +access_token_ttl: "12h" refresh_token_ttl: "168h" cors_allowed_origins: - "http://43.128.56.40" diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index f342f18a..26d9be1e 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -18,7 +18,7 @@ migrations: # 本地开发阶段允许历史 migration 文件被修改后修复 checksum;staging/prod 必须关闭。 allow_checksum_repair: true jwt_secret: "dev-shared-secret" -access_token_ttl: "30m" +access_token_ttl: "12h" refresh_token_ttl: "168h" cors_allowed_origins: - "http://localhost:7001" diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index fc41004d..5cc4a248 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -129,7 +129,7 @@ func Default() Config { AllowChecksumRepair: true, }, JWTSecret: "dev-shared-secret", - AccessTokenTTL: 30 * time.Minute, + AccessTokenTTL: 12 * time.Hour, RefreshTokenTTL: 7 * 24 * time.Hour, CORSAllowedOrigins: []string{ "http://localhost:7001", diff --git a/server/admin/internal/config/config_test.go b/server/admin/internal/config/config_test.go index deb90d30..cf9b85ba 100644 --- a/server/admin/internal/config/config_test.go +++ b/server/admin/internal/config/config_test.go @@ -21,7 +21,7 @@ func TestLoadConfigYAML(t *testing.T) { if cfg.UserMySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" { t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN) } - if cfg.AccessTokenTTL != 30*time.Minute { + if cfg.AccessTokenTTL != 12*time.Hour { t.Fatalf("AccessTokenTTL = %s", cfg.AccessTokenTTL) } if cfg.RefreshTokenTTL != 7*24*time.Hour { diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index a64b3844..b654bcec 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -147,6 +147,108 @@ func (AppExploreTab) TableName() string { return "admin_app_explore_tabs" } +type HostAgencySalaryPolicy struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null;default:lalu" json:"appCode"` + Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"` + // RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。 + RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null" json:"regionId"` + Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"` + // SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。 + SettlementMode string `gorm:"size:24;not null;default:daily" json:"settlementMode"` + // SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。 + SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"` + // 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。 + GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"` + ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"` + // 时间范围采用 epoch ms 半开区间,EffectiveToMS=0 表示长期有效。 + EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"` + EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"` + Description string `gorm:"size:255;not null;default:''" json:"description"` + CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"` + UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"` + // 发布字段只记录 admin 配置同步到 wallet 运行快照的结果;结算仍只读取 wallet-service 自己的运行表。 + PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"` + PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"` + PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"` + PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` + Levels []HostAgencySalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"` +} + +func (HostAgencySalaryPolicy) TableName() string { + return "admin_host_agency_salary_policies" +} + +type HostAgencySalaryLevel struct { + ID uint `gorm:"primaryKey" json:"id"` + PolicyID uint `gorm:"uniqueIndex:uk_admin_host_agency_salary_level;index:idx_admin_host_agency_salary_level_policy,not null" json:"policyId"` + // LevelNo 是累计工资差额结算的顺序锚点,必须在同一 policy 下唯一。 + LevelNo int32 `gorm:"column:level_no;uniqueIndex:uk_admin_host_agency_salary_level;not null" json:"level"` + // RequiredDiamonds 保存主播当月钻石门槛,月底清空逻辑不在 admin-server 第一阶段执行。 + RequiredDiamonds int64 `gorm:"column:required_diamonds;index:idx_admin_host_agency_salary_level_policy,not null" json:"requiredDiamonds"` + // 三个权益字段保存“达到该等级后的累计值”,结算任务只发本次等级与已结算等级之间的差额。 + HostSalaryUSD string `gorm:"column:host_salary_usd;type:decimal(12,2);not null;default:0.00" json:"hostSalaryUsd"` + HostCoinReward int64 `gorm:"column:host_coin_reward;not null;default:0" json:"hostCoinReward"` + AgencySalaryUSD string `gorm:"column:agency_salary_usd;type:decimal(12,2);not null;default:0.00" json:"agencySalaryUsd"` + Status string `gorm:"size:24;not null;default:active" json:"status"` + SortOrder int32 `gorm:"column:sort_order;not null;default:0" json:"sortOrder"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (HostAgencySalaryLevel) TableName() string { + return "admin_host_agency_salary_policy_levels" +} + +type TeamSalaryPolicy struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null;default:lalu" json:"appCode"` + // PolicyType 当前只允许 bd/admin;拆表会让两类策略后续结算扩展变重,所以用类型字段保持同一套配置 API。 + PolicyType string `gorm:"column:policy_type;size:24;uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null" json:"policyType"` + Name string `gorm:"size:120;uniqueIndex:uk_admin_team_salary_policy_name;not null" json:"name"` + // RegionID 沿用 Host 政策的区域边界,保证同一区域组织链路可以命中同一套工资配置。 + RegionID int64 `gorm:"uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null" json:"regionId"` + Status string `gorm:"size:24;index:idx_admin_team_salary_policy_scope,not null;default:disabled" json:"status"` + // SettlementTriggerMode 决定次月 5 日窗口由自动任务处理还是进入后台人工批量结算池。 + SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"` + // BD/Admin 都按完整自然月统计,结算日期固定为次月 5 日;这里不再提供日结/半月结节奏字段。 + EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_team_salary_policy_scope,not null;default:0" json:"effectiveFromMs"` + EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"` + Description string `gorm:"size:255;not null;default:''" json:"description"` + CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"` + UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` + Levels []TeamSalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"` +} + +func (TeamSalaryPolicy) TableName() string { + return "admin_team_salary_policies" +} + +type TeamSalaryLevel struct { + ID uint `gorm:"primaryKey" json:"id"` + PolicyID uint `gorm:"uniqueIndex:uk_admin_team_salary_level;index:idx_admin_team_salary_level_policy,not null" json:"policyId"` + // LevelNo 是差额结算锚点,当前等级累计工资减去已结算累计工资就是本次应发金额。 + LevelNo int32 `gorm:"column:level_no;uniqueIndex:uk_admin_team_salary_level;not null" json:"level"` + // ThresholdUSD 对 BD 表示下属 Agency 管理主播 Host Salary 合计门槛,对 Admin 表示下属 BD Salary 合计门槛。 + ThresholdUSD string `gorm:"column:threshold_usd;type:decimal(12,2);index:idx_admin_team_salary_level_policy,not null;default:0.00" json:"thresholdUsd"` + // RatePercent 保存运营看到的百分比数字,例如 8 表示 8%,结算时再换算成比例。 + RatePercent string `gorm:"column:rate_percent;type:decimal(9,4);not null;default:0.0000" json:"ratePercent"` + // SalaryUSD 是达到该等级后的累计应得工资,实际入账仍按差额结算。 + SalaryUSD string `gorm:"column:salary_usd;type:decimal(12,2);not null;default:0.00" json:"salaryUsd"` + Status string `gorm:"size:24;not null;default:active" json:"status"` + SortOrder int32 `gorm:"column:sort_order;not null;default:0" json:"sortOrder"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (TeamSalaryLevel) TableName() string { + return "admin_team_salary_policy_levels" +} + type RefreshToken struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"index;not null" json:"userId"` diff --git a/server/admin/internal/modules/auth/handler.go b/server/admin/internal/modules/auth/handler.go index 92721afa..c6d20fd3 100644 --- a/server/admin/internal/modules/auth/handler.go +++ b/server/admin/internal/modules/auth/handler.go @@ -65,12 +65,16 @@ func (h *Handler) Refresh(c *gin.Context) { return } - result, err := h.service.Refresh(token) + result, err := h.service.Refresh(token, LoginInput{ + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + }) if err != nil { response.Unauthorized(c, "刷新会话已失效") return } + h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds())) response.OK(c, gin.H{ "accessToken": result.AccessToken, "expiresAtMs": result.ExpiresAtMS, diff --git a/server/admin/internal/modules/auth/service.go b/server/admin/internal/modules/auth/service.go index d6ed78c5..2da8cf8b 100644 --- a/server/admin/internal/modules/auth/service.go +++ b/server/admin/internal/modules/auth/service.go @@ -14,11 +14,23 @@ import ( ) type AuthFlowService struct { - store *repository.Store + store authStore auth *authcore.AuthService cfg config.Config } +type authStore interface { + FindUserByUsername(username string) (*model.User, error) + FindUserByID(id uint) (*model.User, error) + CreateRefreshToken(token model.RefreshToken) error + RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error + FindRefreshToken(hash string) (*model.RefreshToken, error) + RevokeRefreshToken(hash string) error + TouchUserLogin(id uint) error + CreateLoginLog(log model.LoginLog) error + ResetUserPassword(id uint, password string) error +} + type LoginInput struct { Username string Password string @@ -34,7 +46,7 @@ type LoginResult struct { Permissions []string } -func NewService(store *repository.Store, auth *authcore.AuthService, cfg config.Config) *AuthFlowService { +func NewService(store authStore, auth *authcore.AuthService, cfg config.Config) *AuthFlowService { return &AuthFlowService{store: store, auth: auth, cfg: cfg} } @@ -54,17 +66,11 @@ func (s *AuthFlowService) Login(input LoginInput) (LoginResult, error) { if err != nil { return LoginResult{}, err } - refreshToken, refreshHash, err := authcore.NewRefreshToken() + refreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent) if err != nil { return LoginResult{}, err } - if err := s.store.CreateRefreshToken(model.RefreshToken{ - UserID: user.ID, - TokenHash: refreshHash, - UserAgent: input.UserAgent, - IP: input.IP, - ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(), - }); err != nil { + if err := s.store.CreateRefreshToken(refreshRecord); err != nil { return LoginResult{}, err } _ = s.store.TouchUserLogin(user.ID) @@ -79,7 +85,7 @@ func (s *AuthFlowService) Logout(refreshToken string) error { return s.store.RevokeRefreshToken(authcore.HashToken(refreshToken)) } -func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) { +func (s *AuthFlowService) Refresh(refreshToken string, input LoginInput) (LoginResult, error) { if refreshToken == "" { return LoginResult{}, errors.New("刷新会话不存在") } @@ -96,7 +102,28 @@ func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) { if err != nil { return LoginResult{}, err } - return LoginResult{AccessToken: accessToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil + newRefreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent) + if err != nil { + return LoginResult{}, err + } + if err := s.store.RotateRefreshToken(stored.ID, refreshRecord); err != nil { + return LoginResult{}, err + } + return LoginResult{AccessToken: accessToken, RefreshToken: newRefreshToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil +} + +func (s *AuthFlowService) newRefreshToken(userID uint, ip string, userAgent string) (string, model.RefreshToken, error) { + refreshToken, refreshHash, err := authcore.NewRefreshToken() + if err != nil { + return "", model.RefreshToken{}, err + } + return refreshToken, model.RefreshToken{ + UserID: userID, + TokenHash: refreshHash, + UserAgent: userAgent, + IP: ip, + ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(), + }, nil } func (s *AuthFlowService) Me(actor shared.Actor) (*model.User, []string, error) { diff --git a/server/admin/internal/modules/auth/service_test.go b/server/admin/internal/modules/auth/service_test.go new file mode 100644 index 00000000..278053a2 --- /dev/null +++ b/server/admin/internal/modules/auth/service_test.go @@ -0,0 +1,162 @@ +package auth + +import ( + "errors" + "testing" + "time" + + "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/model" + authcore "hyapp-admin-server/internal/service" +) + +type fakeAuthStore struct { + user model.User + tokens map[string]model.RefreshToken + nextTokenID uint +} + +func newFakeAuthStore(t *testing.T) *fakeAuthStore { + t.Helper() + passwordHash, err := authcore.HashPassword("secret") + if err != nil { + t.Fatalf("hash password: %v", err) + } + return &fakeAuthStore{ + user: model.User{ + ID: 7, + Username: "admin", + Name: "Admin", + PasswordHash: passwordHash, + Status: model.UserStatusActive, + Roles: []model.Role{{ + Permissions: []model.Permission{{Code: "user:view"}}, + }}, + }, + tokens: make(map[string]model.RefreshToken), + } +} + +func (s *fakeAuthStore) FindUserByUsername(username string) (*model.User, error) { + if username != s.user.Username { + return nil, errors.New("not found") + } + user := s.user + return &user, nil +} + +func (s *fakeAuthStore) FindUserByID(id uint) (*model.User, error) { + if id != s.user.ID { + return nil, errors.New("not found") + } + user := s.user + return &user, nil +} + +func (s *fakeAuthStore) CreateRefreshToken(token model.RefreshToken) error { + if token.ID == 0 { + s.nextTokenID++ + token.ID = s.nextTokenID + } + s.tokens[token.TokenHash] = token + return nil +} + +func (s *fakeAuthStore) RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error { + if err := s.CreateRefreshToken(token); err != nil { + return err + } + nowMS := time.Now().UTC().UnixMilli() + for hash, existing := range s.tokens { + if existing.ID == oldTokenID && existing.RevokedAtMS == nil { + existing.RevokedAtMS = &nowMS + s.tokens[hash] = existing + return nil + } + } + return errors.New("old token not found") +} + +func (s *fakeAuthStore) FindRefreshToken(hash string) (*model.RefreshToken, error) { + token, ok := s.tokens[hash] + if !ok || token.RevokedAtMS != nil || token.ExpiresAtMS <= time.Now().UTC().UnixMilli() { + return nil, errors.New("not found") + } + return &token, nil +} + +func (s *fakeAuthStore) RevokeRefreshToken(hash string) error { + token, ok := s.tokens[hash] + if !ok { + return nil + } + nowMS := time.Now().UTC().UnixMilli() + token.RevokedAtMS = &nowMS + s.tokens[hash] = token + return nil +} + +func (s *fakeAuthStore) TouchUserLogin(uint) error { + return nil +} + +func (s *fakeAuthStore) CreateLoginLog(model.LoginLog) error { + return nil +} + +func (s *fakeAuthStore) ResetUserPassword(id uint, password string) error { + if id != s.user.ID { + return errors.New("not found") + } + passwordHash, err := authcore.HashPassword(password) + if err != nil { + return err + } + s.user.PasswordHash = passwordHash + return nil +} + +func (s *fakeAuthStore) activeRefreshTokenCount() int { + count := 0 + nowMS := time.Now().UTC().UnixMilli() + for _, token := range s.tokens { + if token.RevokedAtMS == nil && token.ExpiresAtMS > nowMS { + count++ + } + } + return count +} + +func TestRefreshRotatesOnlyCurrentSessionAndKeepsMultiLogin(t *testing.T) { + store := newFakeAuthStore(t) + service := NewService(store, authcore.NewAuthService("test-secret", time.Hour), config.Config{RefreshTokenTTL: 7 * 24 * time.Hour}) + + first, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.1", UserAgent: "browser-a"}) + if err != nil { + t.Fatalf("first login: %v", err) + } + second, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.2", UserAgent: "browser-b"}) + if err != nil { + t.Fatalf("second login: %v", err) + } + if store.activeRefreshTokenCount() != 2 { + t.Fatalf("expected two active sessions after multi-login, got %d", store.activeRefreshTokenCount()) + } + + refreshedFirst, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"}) + if err != nil { + t.Fatalf("refresh first session: %v", err) + } + if refreshedFirst.RefreshToken == "" || refreshedFirst.RefreshToken == first.RefreshToken { + t.Fatalf("refresh must rotate current refresh token") + } + if _, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"}); err == nil { + t.Fatalf("old refresh token must be revoked after rotation") + } + if store.activeRefreshTokenCount() != 2 { + t.Fatalf("rotation should keep one session per login point, got %d", store.activeRefreshTokenCount()) + } + if _, err := service.Refresh(second.RefreshToken, LoginInput{IP: "127.0.0.2", UserAgent: "browser-b"}); err != nil { + t.Fatalf("second session should remain refreshable: %v", err) + } +} diff --git a/server/admin/internal/modules/hostagencypolicy/handler.go b/server/admin/internal/modules/hostagencypolicy/handler.go new file mode 100644 index 00000000..e5d72fbe --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/handler.go @@ -0,0 +1,138 @@ +package hostagencypolicy + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/repository" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service + audit shared.OperationLogger +} + +func New(store *repository.Store, walletDB *sql.DB, audit shared.OperationLogger) *Handler { + return &Handler{service: NewService(store, walletDB), audit: audit} +} + +func (h *Handler) ListPolicies(c *gin.Context) { + options := shared.ListOptions(c) + // query 层保留 snake_case/camelCase 两种写法,兼容生成端和手写端调用。 + items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{ + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + Status: options.Status, + SettlementMode: firstQuery(c, "settlement_mode", "settlementMode"), + // trigger mode 可直接筛出“只允许手动结算”的政策,后续工资结算页会复用同一查询口径。 + SettlementTriggerMode: firstQuery(c, "settlement_trigger_mode", "settlementTriggerMode"), + Page: options.Page, + PageSize: options.PageSize, + }) + if err != nil { + response.ServerError(c, "获取工资政策配置失败") + return + } + response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) +} + +func (h *Handler) CreatePolicy(c *gin.Context) { + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + // 操作人只从登录上下文取,不信任请求体,方便后续审计定位配置变更来源。 + item, err := h.service.Create(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "create-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID)) + response.Created(c, item) +} + +func (h *Handler) UpdatePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + // PUT 语义是整条政策配置替换,等级明细也会由 service/repository 按整包替换。 + item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "update-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID)) + response.OK(c, item) +} + +func (h *Handler) DeletePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + if err := h.service.Delete(c.Request.Context(), appctx.FromContext(c.Request.Context()), id); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "delete-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(id), 10), "success", "") + response.OK(c, gin.H{"deleted": true}) +} + +func (h *Handler) PublishPolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + item, err := h.service.Publish(c.Request.Context(), appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "publish-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d status=%s", item.RegionID, item.Status)) + response.OK(c, item) +} + +func policyID(c *gin.Context) (uint, bool) { + value := strings.TrimSpace(c.Param("policy_id")) + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 { + response.BadRequest(c, "工资政策 ID 不正确") + return 0, false + } + return uint(parsed), true +} + +func queryInt64(c *gin.Context, keys ...string) int64 { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0 + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/hostagencypolicy/request.go b/server/admin/internal/modules/hostagencypolicy/request.go new file mode 100644 index 00000000..a9afb456 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/request.go @@ -0,0 +1,25 @@ +package hostagencypolicy + +type policyRequest struct { + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementMode string `json:"settlement_mode"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"` + ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + Levels []levelRequest `json:"levels"` +} + +type levelRequest struct { + Level int32 `json:"level"` + RequiredDiamonds int64 `json:"required_diamonds"` + HostSalaryUSD string `json:"host_salary_usd"` + HostCoinReward int64 `json:"host_coin_reward"` + AgencySalaryUSD string `json:"agency_salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` +} diff --git a/server/admin/internal/modules/hostagencypolicy/response.go b/server/admin/internal/modules/hostagencypolicy/response.go new file mode 100644 index 00000000..d0ecf127 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/response.go @@ -0,0 +1,122 @@ +package hostagencypolicy + +import ( + "strconv" + + "hyapp-admin-server/internal/model" +) + +type policyDTO struct { + ID uint `json:"id"` + AppCode string `json:"app_code"` + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementMode string `json:"settlement_mode"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"` + ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + CreatedByAdminID uint `json:"created_by_admin_id"` + UpdatedByAdminID uint `json:"updated_by_admin_id"` + PublishStatus string `json:"publish_status"` + PublishError string `json:"publish_error"` + PublishedAtMS int64 `json:"published_at_ms"` + PublishedByAdminID uint `json:"published_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Levels []levelDTO `json:"levels"` +} + +type levelDTO struct { + ID uint `json:"id"` + PolicyID uint `json:"policy_id"` + Level int32 `json:"level"` + RequiredDiamonds int64 `json:"required_diamonds"` + HostSalaryUSD string `json:"host_salary_usd"` + HostCoinReward int64 `json:"host_coin_reward"` + AgencySalaryUSD string `json:"agency_salary_usd"` + TotalSalaryUSD string `json:"total_salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` +} + +func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO { + levels := make([]levelDTO, 0, len(item.Levels)) + for _, level := range item.Levels { + levels = append(levels, levelFromModel(level)) + } + // 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。 + return policyDTO{ + ID: item.ID, + AppCode: item.AppCode, + Name: item.Name, + RegionID: item.RegionID, + Status: item.Status, + SettlementMode: item.SettlementMode, + SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), + GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio), + ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate), + EffectiveFromMS: item.EffectiveFromMS, + EffectiveToMS: item.EffectiveToMS, + Description: item.Description, + CreatedByAdminID: item.CreatedByAdminID, + UpdatedByAdminID: item.UpdatedByAdminID, + PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft), + PublishError: item.PublishError, + PublishedAtMS: item.PublishedAtMS, + PublishedByAdminID: item.PublishedByAdminID, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + Levels: levels, + } +} + +func levelFromModel(item model.HostAgencySalaryLevel) levelDTO { + hostSalary := trimDecimalZeros(item.HostSalaryUSD) + agencySalary := trimDecimalZeros(item.AgencySalaryUSD) + // total_salary_usd 只是后台表格辅助展示字段,结算仍应分别读取主播工资和代理工资做各自入账。 + return levelDTO{ + ID: item.ID, + PolicyID: item.PolicyID, + Level: item.LevelNo, + RequiredDiamonds: item.RequiredDiamonds, + HostSalaryUSD: hostSalary, + HostCoinReward: item.HostCoinReward, + AgencySalaryUSD: agencySalary, + TotalSalaryUSD: formatCents(parseCentsOrZero(hostSalary) + parseCentsOrZero(agencySalary)), + Status: item.Status, + SortOrder: item.SortOrder, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + } +} + +func parseCentsOrZero(value string) int64 { + // 复用服务层 decimal parser,确保展示合计和入库金额使用同一套小数规则。 + _, cents, err := parseFixedDecimal(value, 2, true, "salary") + if err != nil { + return 0 + } + return cents +} + +func formatCents(value int64) string { + whole := value / 100 + fraction := value % 100 + if fraction == 0 { + return strconv.FormatInt(whole, 10) + } + return strconv.FormatInt(whole, 10) + "." + twoDigits(fraction) +} + +func twoDigits(value int64) string { + if value < 10 { + return "0" + strconv.FormatInt(value, 10) + } + return strconv.FormatInt(value, 10) +} diff --git a/server/admin/internal/modules/hostagencypolicy/routes.go b/server/admin/internal/modules/hostagencypolicy/routes.go new file mode 100644 index 00000000..a39e2f00 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/routes.go @@ -0,0 +1,19 @@ +package hostagencypolicy + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + protected.GET("/admin/host-agency-policies", middleware.RequirePermission("host-agency-policy:view"), h.ListPolicies) + protected.POST("/admin/host-agency-policies", middleware.RequirePermission("host-agency-policy:create"), h.CreatePolicy) + protected.PUT("/admin/host-agency-policies/:policy_id", middleware.RequirePermission("host-agency-policy:update"), h.UpdatePolicy) + protected.POST("/admin/host-agency-policies/:policy_id/publish", middleware.RequirePermission("host-agency-policy:publish"), h.PublishPolicy) + protected.DELETE("/admin/host-agency-policies/:policy_id", middleware.RequirePermission("host-agency-policy:delete"), h.DeletePolicy) +} diff --git a/server/admin/internal/modules/hostagencypolicy/service.go b/server/admin/internal/modules/hostagencypolicy/service.go new file mode 100644 index 00000000..13cde9bc --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/service.go @@ -0,0 +1,614 @@ +package hostagencypolicy + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/repository" +) + +const ( + // active/disabled 只表达后台政策是否可用于结算匹配;历史结算记录不应因为停用政策被回写改口径。 + policyStatusActive = "active" + policyStatusDisabled = "disabled" + + // 第一阶段只配置可选结算节奏,真正的日结/半月结算调度会在后续结算任务里消费该字段。 + settlementModeDaily = "daily" + settlementModeHalfMonth = "half_month" + + // 自动触发由 cron-service 扫描结算;手动触发只进入后台待结算池,避免运营要求人工复核时被定时任务提前发放。 + settlementTriggerAutomatic = "automatic" + settlementTriggerManual = "manual" + + // 礼物金币转主播钻石默认 1:1;月底剩余钻石转美元默认不开启,由后台显式配置。 + defaultGiftCoinToDiamondRatio = "1" + defaultResidualDiamondRate = "0" + + publishStatusDraft = "draft" + publishStatusPublished = "published" + publishStatusFailed = "failed" +) + +type Service struct { + store *repository.Store + walletDB *sql.DB +} + +func NewService(store *repository.Store, walletDB *sql.DB) *Service { + return &Service{store: store, walletDB: walletDB} +} + +func (s *Service) List(appCode string, options repository.HostAgencySalaryPolicyListOptions) ([]policyDTO, int64, error) { + // 列表入口统一收敛 app_code、状态和结算方式,避免前端传别名或非法值时把无效条件下推到 DB。 + options.AppCode = appctx.Normalize(appCode) + options.Status = normalizeStatusFilter(options.Status) + options.SettlementMode = normalizeSettlementModeFilter(options.SettlementMode) + options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode) + items, total, err := s.store.ListHostAgencySalaryPolicies(options) + if err != nil { + return nil, 0, err + } + out := make([]policyDTO, 0, len(items)) + for _, item := range items { + out = append(out, policyFromModel(item)) + } + return out, total, nil +} + +func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) { + // 创建时先把请求规整为模型,所有金额和等级递增规则都在这个边界完成,仓储层只负责持久化。 + item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req) + if err != nil { + return policyDTO{}, err + } + // 启用政策必须先做时间段冲突校验,否则同一区域结算时会匹配到两套工资规则。 + if err := s.ensureNoActiveOverlap(item, 0); err != nil { + return policyDTO{}, err + } + if err := s.store.CreateHostAgencySalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) { + // 更新先读原记录,保证 app_code 和主键归属来自数据库,避免请求体越权改租户。 + item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id) + if err != nil { + return policyDTO{}, err + } + // 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。 + updated, err := policyModelFromRequest(item.AppCode, actorID, req) + if err != nil { + return policyDTO{}, err + } + item.Name = updated.Name + item.RegionID = updated.RegionID + item.Status = updated.Status + item.SettlementMode = updated.SettlementMode + item.SettlementTriggerMode = updated.SettlementTriggerMode + item.GiftCoinToDiamondRatio = updated.GiftCoinToDiamondRatio + item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate + item.EffectiveFromMS = updated.EffectiveFromMS + item.EffectiveToMS = updated.EffectiveToMS + item.Description = updated.Description + item.UpdatedByAdminID = actorID + // 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft。 + item.PublishStatus = publishStatusDraft + item.PublishError = "" + // 等级明细按整包替换处理:前端提交的是一套完整政策梯度,不做局部 patch,减少排序和递增口径漂移。 + item.Levels = updated.Levels + if err := s.ensureNoActiveOverlap(item, id); err != nil { + return policyDTO{}, err + } + if err := s.store.UpdateHostAgencySalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Delete(ctx context.Context, appCode string, id uint) error { + // 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。 + if _, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id); err != nil { + return err + } + // 删除后台配置前先清掉 wallet 运行快照,避免 admin 记录消失后结算仍命中过期政策。 + if err := s.deleteRuntimePolicy(ctx, appctx.Normalize(appCode), id); err != nil { + return err + } + return s.store.DeleteHostAgencySalaryPolicy(appctx.Normalize(appCode), id) +} + +func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id uint) (policyDTO, error) { + appCode = appctx.Normalize(appCode) + item, err := s.store.GetHostAgencySalaryPolicy(appCode, id) + if err != nil { + return policyDTO{}, err + } + if err := s.ensureNoActiveOverlap(item, id); err != nil { + return policyDTO{}, err + } + publishedAtMS := time.Now().UTC().UnixMilli() + if err := s.publishRuntimePolicy(ctx, item, publishedAtMS); err != nil { + // 发布失败也写回状态,方便后台列表明确展示“配置未进入 wallet 运行态”的事实。 + _ = s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusFailed, truncateRunes(err.Error(), 255), item.PublishedAtMS, actorID) + return policyDTO{}, err + } + if err := s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusPublished, "", publishedAtMS, actorID); err != nil { + return policyDTO{}, err + } + item.PublishStatus = publishStatusPublished + item.PublishError = "" + item.PublishedAtMS = publishedAtMS + item.PublishedByAdminID = actorID + return policyFromModel(item), nil +} + +func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, excludeID uint) error { + // 停用政策允许提前配置多套草稿;只有 active 才会参与结算匹配,所以冲突检查只约束启用态。 + if item.Status != policyStatusActive { + return nil + } + // 时间段判断采用半开区间:[effective_from_ms, effective_to_ms),effective_to_ms=0 表示长期有效。 + overlap, err := s.store.HasOverlappingActiveHostAgencySalaryPolicy(item.AppCode, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID) + if err != nil { + return err + } + if overlap { + return errors.New("同一区域同一时间只能存在一个启用政策") + } + return nil +} + +func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgencySalaryPolicy, publishedAtMS int64) error { + if s == nil || s.walletDB == nil { + return errors.New("wallet mysql is not configured") + } + if len(item.Levels) == 0 { + return errors.New("至少需要配置一个等级") + } + tx, err := s.walletDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // wallet-service 运行表以 admin policy_id 为主键;每次发布整包覆盖主表和等级,保证结算读取到一套完整梯度。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO host_agency_salary_policies ( + app_code, policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, + gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate, + effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + region_id = VALUES(region_id), + status = VALUES(status), + settlement_mode = VALUES(settlement_mode), + settlement_trigger_mode = VALUES(settlement_trigger_mode), + gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio), + residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms)`, + item.AppCode, + item.ID, + item.Name, + item.RegionID, + item.Status, + item.SettlementMode, + firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), + item.GiftCoinToDiamondRatio, + item.ResidualDiamondToUSDRate, + item.EffectiveFromMS, + item.EffectiveToMS, + nonZeroMS(item.CreatedAtMS, publishedAtMS), + publishedAtMS, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, item.AppCode, item.ID); err != nil { + return err + } + for _, level := range item.Levels { + _, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资") + if err != nil { + return err + } + _, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资") + if err != nil { + return err + } + // 运行表保存美分整数,避免 wallet-service 结算时再解析 decimal 字符串造成重复口径。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO host_agency_salary_policy_levels ( + app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor, + host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + item.AppCode, + item.ID, + level.LevelNo, + level.RequiredDiamonds, + hostSalaryMinor, + level.HostCoinReward, + agencySalaryMinor, + level.Status, + level.SortOrder, + nonZeroMS(level.CreatedAtMS, publishedAtMS), + publishedAtMS, + ); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Service) deleteRuntimePolicy(ctx context.Context, appCode string, policyID uint) error { + if s == nil || s.walletDB == nil { + return errors.New("wallet mysql is not configured") + } + tx, err := s.walletDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policies WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil { + return err + } + return tx.Commit() +} + +func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.HostAgencySalaryPolicy, error) { + name := strings.TrimSpace(req.Name) + if name == "" || len([]rune(name)) > 120 { + return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确") + } + // 当前阶段保持单区域政策:一个区域同一时段只能命中一条 active 规则,后续结算查询也以 region_id 做索引。 + if req.RegionID <= 0 { + return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域") + } + status := normalizeStatus(req.Status) + if status == "" { + // 新建默认停用,避免运营保存草稿时立即影响结算口径。 + status = policyStatusDisabled + } + if !validStatus(status) { + return model.HostAgencySalaryPolicy{}, errors.New("政策状态不正确") + } + settlementMode := normalizeSettlementMode(req.SettlementMode) + if settlementMode == "" { + // 产品当前默认日结;半月结算只是调度节奏差异,等级和差额发放规则相同。 + settlementMode = settlementModeDaily + } + if !validSettlementMode(settlementMode) { + return model.HostAgencySalaryPolicy{}, errors.New("结算方式不正确") + } + settlementTriggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode) + if settlementTriggerMode == "" { + // 默认自动触发,兼容现有政策和现有 cron 日结/半月结任务;需要人工复核时由后台显式切到 manual。 + settlementTriggerMode = settlementTriggerAutomatic + } + if !validSettlementTriggerMode(settlementTriggerMode) { + return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确") + } + if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) { + return model.HostAgencySalaryPolicy{}, errors.New("政策生效时间不正确") + } + // 比例字段用 decimal 字符串入库,避免 float 在金币/钻石/美元换算中产生精度噪声。 + giftRatio := firstNonBlank(req.GiftCoinToDiamondRatio, defaultGiftCoinToDiamondRatio) + giftRatio, _, err := parseFixedDecimal(giftRatio, 6, false, "金币转钻石比例") + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } + residualRate := firstNonBlank(req.ResidualDiamondToUSDRate, defaultResidualDiamondRate) + residualRate, _, err = parseFixedDecimal(residualRate, 12, true, "剩余钻石转美元比例") + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } + description := strings.TrimSpace(req.Description) + if len([]rune(description)) > 255 { + return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符") + } + // 等级表保存的是累计门槛和累计权益;实际发薪时应减去上一已结算等级,只发差额。 + levels, err := levelModelsFromRequest(req.Levels) + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } + return model.HostAgencySalaryPolicy{ + AppCode: appCode, + Name: name, + RegionID: req.RegionID, + Status: status, + SettlementMode: settlementMode, + SettlementTriggerMode: settlementTriggerMode, + GiftCoinToDiamondRatio: giftRatio, + ResidualDiamondToUSDRate: residualRate, + EffectiveFromMS: req.EffectiveFromMS, + EffectiveToMS: req.EffectiveToMS, + Description: description, + CreatedByAdminID: actorID, + UpdatedByAdminID: actorID, + Levels: levels, + }, nil +} + +func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) { + if len(requests) == 0 { + return nil, errors.New("至少需要配置一个等级") + } + // 先按等级号排序再校验,允许前端在编辑过程中临时乱序提交,但最终持久化必须是稳定梯度。 + levels := append([]levelRequest(nil), requests...) + sortLevelRequests(levels) + seen := map[int32]struct{}{} + out := make([]model.HostAgencySalaryLevel, 0, len(levels)) + var previousRequiredDiamonds int64 + var previousHostSalaryCents int64 + var previousHostCoinReward int64 + var previousAgencySalaryCents int64 + for index, req := range levels { + // 等级号是结算差额的顺序锚点,不能为 0,也不能重复。 + if req.Level <= 0 { + return nil, errors.New("等级必须大于 0") + } + if _, ok := seen[req.Level]; ok { + return nil, fmt.Errorf("等级 %d 重复", req.Level) + } + seen[req.Level] = struct{}{} + if req.RequiredDiamonds <= 0 { + return nil, fmt.Errorf("等级 %d 所需钻石必须大于 0", req.Level) + } + // 钻石门槛必须严格递增,否则用户达到高等级时无法确定唯一命中等级。 + if index > 0 && req.RequiredDiamonds <= previousRequiredDiamonds { + return nil, fmt.Errorf("等级 %d 所需钻石必须大于上一等级", req.Level) + } + // 主播美元工资是“累计应得”,后续结算以当前等级累计值减去已结算累计值发差额。 + hostSalary, hostSalaryCents, err := parseFixedDecimal(req.HostSalaryUSD, 2, true, "主播美元工资") + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && hostSalaryCents < previousHostSalaryCents { + return nil, fmt.Errorf("等级 %d 主播美元工资不能小于上一等级", req.Level) + } + if req.HostCoinReward < 0 { + return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于 0", req.Level) + } + // 金币奖励同样是累计配置,不能比上一等级低,否则差额发放会变成负数。 + if index > 0 && req.HostCoinReward < previousHostCoinReward { + return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于上一等级", req.Level) + } + // 代理美元工资跟随主播结算同时发放,也按累计值减已结算累计值计算差额。 + agencySalary, agencySalaryCents, err := parseFixedDecimal(req.AgencySalaryUSD, 2, true, "代理美元工资") + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && agencySalaryCents < previousAgencySalaryCents { + return nil, fmt.Errorf("等级 %d 代理美元工资不能小于上一等级", req.Level) + } + status := normalizeStatus(req.Status) + if status == "" { + // 单个等级默认启用;需要临时屏蔽某个等级时由运营显式停用。 + status = policyStatusActive + } + if !validStatus(status) { + return nil, fmt.Errorf("等级 %d 状态不正确", req.Level) + } + sortOrder := req.SortOrder + if sortOrder == 0 { + // 默认排序跟等级号一致,前端展示和后续结算扫描都能保持自然顺序。 + sortOrder = req.Level + } + out = append(out, model.HostAgencySalaryLevel{ + LevelNo: req.Level, + RequiredDiamonds: req.RequiredDiamonds, + HostSalaryUSD: hostSalary, + HostCoinReward: req.HostCoinReward, + AgencySalaryUSD: agencySalary, + Status: status, + SortOrder: sortOrder, + }) + previousRequiredDiamonds = req.RequiredDiamonds + previousHostSalaryCents = hostSalaryCents + previousHostCoinReward = req.HostCoinReward + previousAgencySalaryCents = agencySalaryCents + } + return out, nil +} + +func sortLevelRequests(items []levelRequest) { + for i := 1; i < len(items); i++ { + item := items[i] + j := i - 1 + for j >= 0 && items[j].Level > item.Level { + items[j+1] = items[j] + j-- + } + items[j+1] = item + } +} + +func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) { + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, "+") { + value = strings.TrimPrefix(value, "+") + } + if value == "" || strings.HasPrefix(value, "-") { + return "", 0, fmt.Errorf("%s不正确", field) + } + parts := strings.Split(value, ".") + if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) { + return "", 0, fmt.Errorf("%s不正确", field) + } + if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) { + return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale) + } + whole, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s过大", field) + } + multiplier := pow10(scale) + if whole > (1<<62)/multiplier { + return "", 0, fmt.Errorf("%s过大", field) + } + var fraction int64 + if len(parts) == 2 { + fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1])) + fraction, err = strconv.ParseInt(fractionText, 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s不正确", field) + } + } + scaled := whole*multiplier + fraction + if !allowZero && scaled <= 0 { + return "", 0, fmt.Errorf("%s必须大于 0", field) + } + return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil +} + +func fixedDigits(value int64, scale int) string { + text := strconv.FormatInt(value, 10) + if len(text) >= scale { + return text + } + return strings.Repeat("0", scale-len(text)) + text +} + +func trimDecimalZeros(value string) string { + value = strings.TrimSpace(value) + if !strings.Contains(value, ".") { + return value + } + value = strings.TrimRight(value, "0") + return strings.TrimRight(value, ".") +} + +func pow10(scale int) int64 { + var out int64 = 1 + for i := 0; i < scale; i++ { + out *= 10 + } + return out +} + +func allDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func nonZeroMS(value int64, fallback int64) int64 { + if value > 0 { + return value + } + return fallback +} + +func truncateRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", policyStatusActive, "enabled": + if strings.TrimSpace(value) == "" { + return "" + } + return policyStatusActive + case policyStatusDisabled, "inactive": + return policyStatusDisabled + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeStatusFilter(value string) string { + status := normalizeStatus(value) + if !validStatus(status) { + return "" + } + return status +} + +func validStatus(value string) bool { + return value == policyStatusActive || value == policyStatusDisabled +} + +func normalizeSettlementMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", settlementModeDaily, "day": + if strings.TrimSpace(value) == "" { + return "" + } + return settlementModeDaily + case settlementModeHalfMonth, "half-month", "semi_month": + return settlementModeHalfMonth + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeSettlementModeFilter(value string) string { + mode := normalizeSettlementMode(value) + if !validSettlementMode(mode) { + return "" + } + return mode +} + +func validSettlementMode(value string) bool { + return value == settlementModeDaily || value == settlementModeHalfMonth +} + +func normalizeSettlementTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", settlementTriggerAutomatic, "auto": + if strings.TrimSpace(value) == "" { + return "" + } + return settlementTriggerAutomatic + case settlementTriggerManual, "manual_settlement": + return settlementTriggerManual + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeSettlementTriggerModeFilter(value string) string { + mode := normalizeSettlementTriggerMode(value) + if !validSettlementTriggerMode(mode) { + return "" + } + return mode +} + +func validSettlementTriggerMode(value string) bool { + return value == settlementTriggerAutomatic || value == settlementTriggerManual +} diff --git a/server/admin/internal/modules/hostagencypolicy/service_test.go b/server/admin/internal/modules/hostagencypolicy/service_test.go new file mode 100644 index 00000000..67c2ab74 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/service_test.go @@ -0,0 +1,95 @@ +package hostagencypolicy + +import "testing" + +func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) { + item, err := policyModelFromRequest("lalu", 7, policyRequest{ + Name: "Lalu 默认政策", + RegionID: 101, + Status: "active", + SettlementMode: "half-month", + SettlementTriggerMode: "manual", + GiftCoinToDiamondRatio: "1", + ResidualDiamondToUSDRate: "0.000001", + Levels: []levelRequest{ + {Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"}, + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err != nil { + t.Fatalf("policy should be valid: %v", err) + } + if item.AppCode != "lalu" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual { + t.Fatalf("policy fields mismatch: %+v", item) + } + if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" { + t.Fatalf("ratio fields mismatch: %+v", item) + } + if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 { + t.Fatalf("levels should be sorted: %+v", item.Levels) + } + if item.Levels[0].HostSalaryUSD != "1.50" || item.Levels[1].AgencySalaryUSD != "0.80" { + t.Fatalf("salary fields should be fixed decimals: %+v", item.Levels) + } +} + +func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid", + RegionID: 101, + SettlementMode: settlementModeDaily, + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"}, + {Level: 2, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err == nil { + t.Fatal("expected non-increasing levels to fail") + } +} + +func TestPolicyModelFromRequestDefaultsSettlementTriggerMode(t *testing.T) { + item, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Default trigger", + RegionID: 101, + SettlementMode: settlementModeDaily, + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err != nil { + t.Fatalf("policy should be valid: %v", err) + } + if item.SettlementTriggerMode != settlementTriggerAutomatic { + t.Fatalf("default trigger mismatch: %+v", item) + } +} + +func TestPolicyModelFromRequestRejectsInvalidSettlementMode(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid", + RegionID: 101, + SettlementMode: "monthly", + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err == nil { + t.Fatal("expected invalid settlement mode to fail") + } +} + +func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid", + RegionID: 101, + SettlementMode: settlementModeDaily, + SettlementTriggerMode: "cron", + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err == nil { + t.Fatal("expected invalid settlement trigger mode to fail") + } +} diff --git a/server/admin/internal/modules/hostsalarysettlement/dto.go b/server/admin/internal/modules/hostsalarysettlement/dto.go new file mode 100644 index 00000000..698dc167 --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/dto.go @@ -0,0 +1,46 @@ +package hostsalarysettlement + +type userDTO struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +type settlementDTO struct { + SettlementID string `json:"settlement_id"` + CommandID string `json:"command_id"` + TransactionID string `json:"transaction_id"` + SettlementType string `json:"settlement_type"` + UserID string `json:"user_id"` + User userDTO `json:"user"` + AgencyOwnerUserID string `json:"agency_owner_user_id"` + AgencyOwner userDTO `json:"agency_owner"` + CycleKey string `json:"cycle_key"` + PolicyID uint64 `json:"policy_id"` + LevelNo int32 `json:"level_no"` + TotalDiamonds int64 `json:"total_diamonds"` + HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"` + HostSalaryUSDDelta string `json:"host_salary_usd_delta"` + HostCoinRewardDelta int64 `json:"host_coin_reward_delta"` + AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"` + AgencySalaryUSDDelta string `json:"agency_salary_usd_delta"` + ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"` + ResidualUSDDelta string `json:"residual_usd_delta"` + Status string `json:"status"` + Reason string `json:"reason"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type query struct { + Page int + PageSize int + CycleKey string + SettlementType string + Status string + UserID int64 + AgencyOwnerUserID int64 + PolicyID uint64 + StartAtMS int64 + EndAtMS int64 +} diff --git a/server/admin/internal/modules/hostsalarysettlement/handler.go b/server/admin/internal/modules/hostsalarysettlement/handler.go new file mode 100644 index 00000000..ddb4e3f7 --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/handler.go @@ -0,0 +1,115 @@ +package hostsalarysettlement + +import ( + "database/sql" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func New(walletDB *sql.DB, userDB *sql.DB) *Handler { + return &Handler{service: NewService(walletDB, userDB)} +} + +func (h *Handler) List(c *gin.Context) { + req, ok := parseQuery(c) + if !ok { + return + } + items, total, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) + if err != nil { + response.ServerError(c, "获取工资结算记录失败") + return + } + response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) +} + +func parseQuery(c *gin.Context) (query, bool) { + options := shared.ListOptions(c) + userID, ok := optionalInt64(c, "user_id", "userId") + if !ok { + response.BadRequest(c, "主播用户 ID 不正确") + return query{}, false + } + agencyOwnerUserID, ok := optionalInt64(c, "agency_owner_user_id", "agencyOwnerUserId") + if !ok { + response.BadRequest(c, "代理用户 ID 不正确") + return query{}, false + } + policyID, ok := optionalUint64(c, "policy_id", "policyId") + if !ok { + response.BadRequest(c, "政策 ID 不正确") + return query{}, false + } + startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs") + if !ok { + response.BadRequest(c, "开始时间不正确") + return query{}, false + } + endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs") + if !ok { + response.BadRequest(c, "结束时间不正确") + return query{}, false + } + if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS { + response.BadRequest(c, "时间区间不正确") + return query{}, false + } + req := normalizeQuery(query{ + Page: options.Page, + PageSize: options.PageSize, + CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), + SettlementType: normalizeSettlementType(firstQuery(c, "settlement_type", "settlementType")), + Status: normalizeStatus(firstQuery(c, "status")), + UserID: userID, + AgencyOwnerUserID: agencyOwnerUserID, + PolicyID: policyID, + StartAtMS: startAtMS, + EndAtMS: endAtMS, + }) + if rawType := strings.TrimSpace(firstQuery(c, "settlement_type", "settlementType")); rawType != "" && strings.ToLower(rawType) != "all" && req.SettlementType == "" { + response.BadRequest(c, "结算类型不正确") + return query{}, false + } + if rawStatus := strings.TrimSpace(firstQuery(c, "status")); rawStatus != "" && strings.ToLower(rawStatus) != "all" && req.Status == "" { + response.BadRequest(c, "结算状态不正确") + return query{}, false + } + return req, true +} + +func optionalInt64(c *gin.Context, keys ...string) (int64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil && parsed >= 0 +} + +func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseUint(value, 10, 64) + return parsed, err == nil +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/hostsalarysettlement/routes.go b/server/admin/internal/modules/hostsalarysettlement/routes.go new file mode 100644 index 00000000..912f784b --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/routes.go @@ -0,0 +1,15 @@ +package hostsalarysettlement + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + protected.GET("/admin/host-salary-settlements", middleware.RequirePermission("host-salary-settlement:view"), h.List) +} diff --git a/server/admin/internal/modules/hostsalarysettlement/service.go b/server/admin/internal/modules/hostsalarysettlement/service.go new file mode 100644 index 00000000..eecd1483 --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/service.go @@ -0,0 +1,290 @@ +package hostsalarysettlement + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" +) + +const maxPageSize = 100 + +type Service struct { + walletDB *sql.DB + userDB *sql.DB +} + +type userProfile struct { + UserID int64 + DisplayUserID string + Username string + Avatar string +} + +func NewService(walletDB *sql.DB, userDB *sql.DB) *Service { + return &Service{walletDB: walletDB, userDB: userDB} +} + +func (s *Service) List(ctx context.Context, appCode string, req query) ([]settlementDTO, int64, error) { + if s == nil || s.walletDB == nil { + return nil, 0, fmt.Errorf("wallet mysql is not configured") + } + req = normalizeQuery(req) + whereSQL, args := settlementWhere(appCode, req) + + var total int64 + if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM host_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, + cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, + host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta, + status, reason, created_at_ms + FROM host_salary_settlement_records + `+whereSQL+` + ORDER BY created_at_ms DESC, settlement_id DESC + LIMIT ? OFFSET ?`, + append(args, req.PageSize, offset(req.Page, req.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]settlementDTO, 0, req.PageSize) + userIDs := make([]int64, 0, req.PageSize*2) + for rows.Next() { + var item settlementDTO + var userID int64 + var agencyOwnerUserID int64 + if err := rows.Scan( + &item.SettlementID, + &item.CommandID, + &item.TransactionID, + &item.SettlementType, + &userID, + &agencyOwnerUserID, + &item.CycleKey, + &item.PolicyID, + &item.LevelNo, + &item.TotalDiamonds, + &item.HostSalaryUSDMinorDelta, + &item.HostCoinRewardDelta, + &item.AgencySalaryUSDMinorDelta, + &item.ResidualUSDMinorDelta, + &item.Status, + &item.Reason, + &item.CreatedAtMS, + ); err != nil { + return nil, 0, err + } + item.UserID = strconv.FormatInt(userID, 10) + item.AgencyOwnerUserID = formatOptionalUserID(agencyOwnerUserID) + item.User = userDTO{UserID: item.UserID} + item.AgencyOwner = userDTO{UserID: item.AgencyOwnerUserID} + item.HostSalaryUSDDelta = formatUSDMinor(item.HostSalaryUSDMinorDelta) + item.AgencySalaryUSDDelta = formatUSDMinor(item.AgencySalaryUSDMinorDelta) + item.ResidualUSDDelta = formatUSDMinor(item.ResidualUSDMinorDelta) + items = append(items, item) + if userID > 0 { + userIDs = append(userIDs, userID) + } + if agencyOwnerUserID > 0 { + userIDs = append(userIDs, agencyOwnerUserID) + } + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + profiles, err := s.userProfiles(ctx, appCode, userIDs) + if err != nil { + return nil, 0, err + } + for i := range items { + if userID, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil { + items[i].User = userDTOFromProfile(items[i].UserID, profiles[userID]) + } + if agencyID, err := strconv.ParseInt(items[i].AgencyOwnerUserID, 10, 64); err == nil && agencyID > 0 { + items[i].AgencyOwner = userDTOFromProfile(items[i].AgencyOwnerUserID, profiles[agencyID]) + } + } + return items, total, nil +} + +func settlementWhere(appCode string, req query) (string, []any) { + conditions := []string{"app_code = ?"} + args := []any{strings.TrimSpace(appCode)} + if req.CycleKey != "" { + conditions = append(conditions, "cycle_key = ?") + args = append(args, req.CycleKey) + } + if req.SettlementType != "" { + conditions = append(conditions, "settlement_type = ?") + args = append(args, req.SettlementType) + } + if req.Status != "" { + conditions = append(conditions, "status = ?") + args = append(args, req.Status) + } + if req.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, req.UserID) + } + if req.AgencyOwnerUserID > 0 { + conditions = append(conditions, "agency_owner_user_id = ?") + args = append(args, req.AgencyOwnerUserID) + } + if req.PolicyID > 0 { + conditions = append(conditions, "policy_id = ?") + args = append(args, req.PolicyID) + } + if req.StartAtMS > 0 { + conditions = append(conditions, "created_at_ms >= ?") + args = append(args, req.StartAtMS) + } + if req.EndAtMS > 0 { + conditions = append(conditions, "created_at_ms < ?") + args = append(args, req.EndAtMS) + } + // 查询只读 wallet-service 结算事实表,不回推钱包分录;账务对账需要单独走 entries/transactions 明细。 + return "WHERE " + strings.Join(conditions, " AND "), args +} + +func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { + if s == nil || s.userDB == nil || len(userIDs) == 0 { + return map[int64]userProfile{}, nil + } + uniqueIDs := uniquePositiveUserIDs(userIDs) + if len(uniqueIDs) == 0 { + return map[int64]userProfile{}, nil + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",") + args := make([]any, 0, len(uniqueIDs)+1) + args = append(args, strings.TrimSpace(appCode)) + for _, id := range uniqueIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') + FROM users + WHERE app_code = ? AND user_id IN (`+placeholders+`)`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + profiles := make(map[int64]userProfile, len(uniqueIDs)) + for rows.Next() { + var item userProfile + if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil { + return nil, err + } + profiles[item.UserID] = item + } + return profiles, rows.Err() +} + +func normalizeQuery(req query) query { + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.SettlementType = normalizeSettlementType(req.SettlementType) + req.Status = normalizeStatus(req.Status) + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 50 + } + if req.PageSize > maxPageSize { + req.PageSize = maxPageSize + } + return req +} + +func normalizeSettlementType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "daily", "day": + return "daily" + case "half_month", "half-month", "halfmonth": + return "half_month" + case "month_end", "month-end", "monthend": + return "month_end" + default: + return "" + } +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "succeeded", "skipped", "failed": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +func uniquePositiveUserIDs(ids []int64) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func userDTOFromProfile(fallbackID string, profile userProfile) userDTO { + if profile.UserID <= 0 { + return userDTO{UserID: fallbackID} + } + return userDTO{ + UserID: strconv.FormatInt(profile.UserID, 10), + DisplayUserID: profile.DisplayUserID, + Username: profile.Username, + Avatar: profile.Avatar, + } +} + +func formatOptionalUserID(value int64) string { + if value <= 0 { + return "" + } + return strconv.FormatInt(value, 10) +} + +func formatUSDMinor(value int64) string { + sign := "" + if value < 0 { + sign = "-" + value = -value + } + whole := value / 100 + fraction := value % 100 + if fraction == 0 { + return sign + strconv.FormatInt(whole, 10) + } + if fraction < 10 { + return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10) + } + return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10) +} + +func offset(page int, pageSize int) int { + if page <= 1 { + return 0 + } + return (page - 1) * pageSize +} diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index 2b7e43cb..c23ba0ea 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -1,6 +1,7 @@ package luckygift import ( + "context" "math" "strings" "time" @@ -16,12 +17,16 @@ import ( ) type Handler struct { - activity activityclient.Client - audit shared.OperationLogger + activity activityclient.Client + requestTimeout time.Duration + audit shared.OperationLogger } -func New(activity activityclient.Client, audit shared.OperationLogger) *Handler { - return &Handler{activity: activity, audit: audit} +func New(activity activityclient.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler { + if requestTimeout <= 0 { + requestTimeout = 3 * time.Second + } + return &Handler{activity: activity, requestTimeout: requestTimeout, audit: audit} } type configRequest struct { @@ -108,7 +113,9 @@ type drawSummaryDTO struct { } func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { - resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{ + ctx, cancel := h.activityContext(c) + defer cancel() + resp, err := h.activity.GetLuckyGiftConfig(ctx, &activityv1.GetLuckyGiftConfigRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), }) @@ -128,7 +135,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { if req.PoolID == "" { req.PoolID = strings.TrimSpace(c.Query("pool_id")) } - resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{ + ctx, cancel := h.activityContext(c) + defer cancel() + resp, err := h.activity.UpsertLuckyGiftConfig(ctx, &activityv1.UpsertLuckyGiftConfigRequest{ Meta: h.meta(c), Config: configToProto(req), OperatorAdminId: int64(middleware.CurrentUserID(c)), @@ -143,7 +152,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { } func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) { - resp, err := h.activity.ListLuckyGiftConfigs(c.Request.Context(), &activityv1.ListLuckyGiftConfigsRequest{ + ctx, cancel := h.activityContext(c) + defer cancel() + resp, err := h.activity.ListLuckyGiftConfigs(ctx, &activityv1.ListLuckyGiftConfigsRequest{ Meta: h.meta(c), }) if err != nil { @@ -159,7 +170,9 @@ func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) { func (h *Handler) ListLuckyGiftDraws(c *gin.Context) { options := shared.ListOptions(c) - resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{ + ctx, cancel := h.activityContext(c) + defer cancel() + resp, err := h.activity.ListLuckyGiftDraws(ctx, &activityv1.ListLuckyGiftDrawsRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), GiftId: strings.TrimSpace(c.Query("gift_id")), @@ -181,7 +194,9 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) { } func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) { - resp, err := h.activity.GetLuckyGiftDrawSummary(c.Request.Context(), &activityv1.GetLuckyGiftDrawSummaryRequest{ + ctx, cancel := h.activityContext(c) + defer cancel() + resp, err := h.activity.GetLuckyGiftDrawSummary(ctx, &activityv1.GetLuckyGiftDrawSummaryRequest{ Meta: h.meta(c), PoolId: strings.TrimSpace(c.Query("pool_id")), GiftId: strings.TrimSpace(c.Query("gift_id")), @@ -205,6 +220,10 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } +func (h *Handler) activityContext(c *gin.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(c.Request.Context(), h.requestTimeout) +} + func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig { stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages)) for _, stage := range req.Stages { diff --git a/server/admin/internal/modules/teamsalarypolicy/handler.go b/server/admin/internal/modules/teamsalarypolicy/handler.go new file mode 100644 index 00000000..0240d8db --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/handler.go @@ -0,0 +1,121 @@ +package teamsalarypolicy + +import ( + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/repository" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service + audit shared.OperationLogger +} + +func New(store *repository.Store, audit shared.OperationLogger) *Handler { + return &Handler{service: NewService(store), audit: audit} +} + +func (h *Handler) ListPolicies(c *gin.Context) { + options := shared.ListOptions(c) + policyType := firstQuery(c, "policy_type", "policyType") + items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.TeamSalaryPolicyListOptions{ + PolicyType: policyType, + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + Status: options.Status, + SettlementTriggerMode: firstQuery(c, "settlement_trigger_mode", "settlementTriggerMode"), + Page: options.Page, + PageSize: options.PageSize, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) +} + +func (h *Handler) CreatePolicy(c *gin.Context) { + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + item, err := h.service.Create(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "create-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("policy_type=%s region_id=%d", item.PolicyType, item.RegionID)) + response.Created(c, item) +} + +func (h *Handler) UpdatePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "update-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("policy_type=%s region_id=%d", item.PolicyType, item.RegionID)) + response.OK(c, item) +} + +func (h *Handler) DeletePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + policyType := firstQuery(c, "policy_type", "policyType") + if err := h.service.Delete(appctx.FromContext(c.Request.Context()), policyType, id); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "delete-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(id), 10), "success", fmt.Sprintf("policy_type=%s", normalizePolicyType(policyType))) + response.OK(c, gin.H{"deleted": true}) +} + +func policyID(c *gin.Context) (uint, bool) { + value := strings.TrimSpace(c.Param("policy_id")) + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 { + response.BadRequest(c, "工资政策 ID 不正确") + return 0, false + } + return uint(parsed), true +} + +func queryInt64(c *gin.Context, keys ...string) int64 { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0 + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/teamsalarypolicy/request.go b/server/admin/internal/modules/teamsalarypolicy/request.go new file mode 100644 index 00000000..a3c05a26 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/request.go @@ -0,0 +1,22 @@ +package teamsalarypolicy + +type policyRequest struct { + PolicyType string `json:"policy_type"` + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + Levels []levelRequest `json:"levels"` +} + +type levelRequest struct { + Level int32 `json:"level"` + ThresholdUSD string `json:"threshold_usd"` + RatePercent string `json:"rate_percent"` + SalaryUSD string `json:"salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` +} diff --git a/server/admin/internal/modules/teamsalarypolicy/response.go b/server/admin/internal/modules/teamsalarypolicy/response.go new file mode 100644 index 00000000..b0c888cd --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/response.go @@ -0,0 +1,74 @@ +package teamsalarypolicy + +import "hyapp-admin-server/internal/model" + +type policyDTO struct { + ID uint `json:"id"` + AppCode string `json:"app_code"` + PolicyType string `json:"policy_type"` + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + CreatedByAdminID uint `json:"created_by_admin_id"` + UpdatedByAdminID uint `json:"updated_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Levels []levelDTO `json:"levels"` +} + +type levelDTO struct { + ID uint `json:"id"` + PolicyID uint `json:"policy_id"` + Level int32 `json:"level"` + ThresholdUSD string `json:"threshold_usd"` + RatePercent string `json:"rate_percent"` + SalaryUSD string `json:"salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` +} + +func policyFromModel(item model.TeamSalaryPolicy) policyDTO { + levels := make([]levelDTO, 0, len(item.Levels)) + for _, level := range item.Levels { + levels = append(levels, levelFromModel(level)) + } + // 响应层去掉 decimal 尾零,前端输入框仍以字符串处理金额和百分比,避免 JS 浮点误差。 + return policyDTO{ + ID: item.ID, + AppCode: item.AppCode, + PolicyType: item.PolicyType, + Name: item.Name, + RegionID: item.RegionID, + Status: item.Status, + SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), + EffectiveFromMS: item.EffectiveFromMS, + EffectiveToMS: item.EffectiveToMS, + Description: item.Description, + CreatedByAdminID: item.CreatedByAdminID, + UpdatedByAdminID: item.UpdatedByAdminID, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + Levels: levels, + } +} + +func levelFromModel(item model.TeamSalaryLevel) levelDTO { + return levelDTO{ + ID: item.ID, + PolicyID: item.PolicyID, + Level: item.LevelNo, + ThresholdUSD: trimDecimalZeros(item.ThresholdUSD), + RatePercent: trimDecimalZeros(item.RatePercent), + SalaryUSD: trimDecimalZeros(item.SalaryUSD), + Status: item.Status, + SortOrder: item.SortOrder, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + } +} diff --git a/server/admin/internal/modules/teamsalarypolicy/routes.go b/server/admin/internal/modules/teamsalarypolicy/routes.go new file mode 100644 index 00000000..b5401d02 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/routes.go @@ -0,0 +1,17 @@ +package teamsalarypolicy + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + protected.GET("/admin/team-salary-policies", middleware.RequirePermission("team-salary-policy:view"), h.ListPolicies) + protected.POST("/admin/team-salary-policies", middleware.RequirePermission("team-salary-policy:create"), h.CreatePolicy) + protected.PUT("/admin/team-salary-policies/:policy_id", middleware.RequirePermission("team-salary-policy:update"), h.UpdatePolicy) + protected.DELETE("/admin/team-salary-policies/:policy_id", middleware.RequirePermission("team-salary-policy:delete"), h.DeletePolicy) +} diff --git a/server/admin/internal/modules/teamsalarypolicy/service.go b/server/admin/internal/modules/teamsalarypolicy/service.go new file mode 100644 index 00000000..d69bd1c7 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/service.go @@ -0,0 +1,406 @@ +package teamsalarypolicy + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/repository" +) + +const ( + policyTypeBD = "bd" + policyTypeAdmin = "admin" + + policyStatusActive = "active" + policyStatusDisabled = "disabled" + + settlementTriggerAutomatic = "automatic" + settlementTriggerManual = "manual" +) + +type Service struct { + store *repository.Store +} + +func NewService(store *repository.Store) *Service { + return &Service{store: store} +} + +func (s *Service) List(appCode string, options repository.TeamSalaryPolicyListOptions) ([]policyDTO, int64, error) { + options.AppCode = appctx.Normalize(appCode) + options.PolicyType = normalizePolicyType(options.PolicyType) + if options.PolicyType == "" { + return nil, 0, errors.New("政策类型不正确") + } + options.Status = normalizeStatusFilter(options.Status) + options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode) + items, total, err := s.store.ListTeamSalaryPolicies(options) + if err != nil { + return nil, 0, err + } + out := make([]policyDTO, 0, len(items)) + for _, item := range items { + out = append(out, policyFromModel(item)) + } + return out, total, nil +} + +func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) { + item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req) + if err != nil { + return policyDTO{}, err + } + if err := s.ensureNoActiveOverlap(item, 0); err != nil { + return policyDTO{}, err + } + if err := s.store.CreateTeamSalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) { + policyType := normalizePolicyType(req.PolicyType) + if policyType == "" { + return policyDTO{}, errors.New("政策类型不正确") + } + item, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id) + if err != nil { + return policyDTO{}, err + } + updated, err := policyModelFromRequest(item.AppCode, actorID, req) + if err != nil { + return policyDTO{}, err + } + item.Name = updated.Name + item.RegionID = updated.RegionID + item.Status = updated.Status + item.SettlementTriggerMode = updated.SettlementTriggerMode + item.EffectiveFromMS = updated.EffectiveFromMS + item.EffectiveToMS = updated.EffectiveToMS + item.Description = updated.Description + item.UpdatedByAdminID = actorID + item.Levels = updated.Levels + if err := s.ensureNoActiveOverlap(item, id); err != nil { + return policyDTO{}, err + } + if err := s.store.UpdateTeamSalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Delete(appCode string, policyType string, id uint) error { + policyType = normalizePolicyType(policyType) + if policyType == "" { + return errors.New("政策类型不正确") + } + if _, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id); err != nil { + return err + } + return s.store.DeleteTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id) +} + +func (s *Service) ensureNoActiveOverlap(item model.TeamSalaryPolicy, excludeID uint) error { + if item.Status != policyStatusActive { + return nil + } + overlap, err := s.store.HasOverlappingActiveTeamSalaryPolicy(item.AppCode, item.PolicyType, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID) + if err != nil { + return err + } + if overlap { + return errors.New("同一区域同一时间只能存在一个启用政策") + } + return nil +} + +func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.TeamSalaryPolicy, error) { + policyType := normalizePolicyType(req.PolicyType) + if policyType == "" { + return model.TeamSalaryPolicy{}, errors.New("政策类型不正确") + } + name := strings.TrimSpace(req.Name) + if name == "" || len([]rune(name)) > 120 { + return model.TeamSalaryPolicy{}, errors.New("政策名称不正确") + } + if req.RegionID <= 0 { + return model.TeamSalaryPolicy{}, errors.New("请选择适用区域") + } + status := normalizeStatus(req.Status) + if status == "" { + // BD/Admin 政策默认先保存为停用,运营确认等级表无误后再启用,避免自动结算读到半成品。 + status = policyStatusDisabled + } + if !validStatus(status) { + return model.TeamSalaryPolicy{}, errors.New("政策状态不正确") + } + triggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode) + if triggerMode == "" { + triggerMode = settlementTriggerAutomatic + } + if !validSettlementTriggerMode(triggerMode) { + return model.TeamSalaryPolicy{}, errors.New("结算触发方式不正确") + } + if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) { + return model.TeamSalaryPolicy{}, errors.New("政策生效时间不正确") + } + description := strings.TrimSpace(req.Description) + if len([]rune(description)) > 255 { + return model.TeamSalaryPolicy{}, errors.New("备注不能超过 255 个字符") + } + levels, err := levelModelsFromRequest(policyType, req.Levels) + if err != nil { + return model.TeamSalaryPolicy{}, err + } + return model.TeamSalaryPolicy{ + AppCode: appCode, + PolicyType: policyType, + Name: name, + RegionID: req.RegionID, + Status: status, + SettlementTriggerMode: triggerMode, + EffectiveFromMS: req.EffectiveFromMS, + EffectiveToMS: req.EffectiveToMS, + Description: description, + CreatedByAdminID: actorID, + UpdatedByAdminID: actorID, + Levels: levels, + }, nil +} + +func levelModelsFromRequest(policyType string, requests []levelRequest) ([]model.TeamSalaryLevel, error) { + if len(requests) == 0 { + return nil, errors.New("至少需要配置一个等级") + } + levels := append([]levelRequest(nil), requests...) + sortLevelRequests(levels) + seen := map[int32]struct{}{} + out := make([]model.TeamSalaryLevel, 0, len(levels)) + var previousThresholdCents int64 + var previousSalaryCents int64 + for index, req := range levels { + if req.Level <= 0 { + return nil, errors.New("等级必须大于 0") + } + if _, ok := seen[req.Level]; ok { + return nil, fmt.Errorf("等级 %d 重复", req.Level) + } + seen[req.Level] = struct{}{} + threshold, thresholdCents, err := parseFixedDecimal(req.ThresholdUSD, 2, false, thresholdFieldName(policyType)) + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && thresholdCents <= previousThresholdCents { + return nil, fmt.Errorf("等级 %d 收入门槛必须大于上一等级", req.Level) + } + // rate_percent 保存百分比文本,例如 8 表示 8%;允许 4 位小数应对后续更细比例。 + rate, _, err := parseFixedDecimal(req.RatePercent, 4, false, "提成比例") + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + salary, salaryCents, err := parseFixedDecimal(req.SalaryUSD, 2, true, salaryFieldName(policyType)) + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && salaryCents < previousSalaryCents { + return nil, fmt.Errorf("等级 %d 累计工资不能小于上一等级", req.Level) + } + status := normalizeStatus(req.Status) + if status == "" { + status = policyStatusActive + } + if !validStatus(status) { + return nil, fmt.Errorf("等级 %d 状态不正确", req.Level) + } + sortOrder := req.SortOrder + if sortOrder == 0 { + sortOrder = req.Level + } + out = append(out, model.TeamSalaryLevel{ + LevelNo: req.Level, + ThresholdUSD: threshold, + RatePercent: rate, + SalaryUSD: salary, + Status: status, + SortOrder: sortOrder, + }) + previousThresholdCents = thresholdCents + previousSalaryCents = salaryCents + } + return out, nil +} + +func thresholdFieldName(policyType string) string { + if policyType == policyTypeAdmin { + return "BD工资门槛" + } + return "下属主播工资门槛" +} + +func salaryFieldName(policyType string) string { + if policyType == policyTypeAdmin { + return "Admin工资" + } + return "BD工资" +} + +func sortLevelRequests(items []levelRequest) { + for i := 1; i < len(items); i++ { + item := items[i] + j := i - 1 + for j >= 0 && items[j].Level > item.Level { + items[j+1] = items[j] + j-- + } + items[j+1] = item + } +} + +func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) { + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, "+") { + value = strings.TrimPrefix(value, "+") + } + if value == "" || strings.HasPrefix(value, "-") { + return "", 0, fmt.Errorf("%s不正确", field) + } + parts := strings.Split(value, ".") + if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) { + return "", 0, fmt.Errorf("%s不正确", field) + } + if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) { + return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale) + } + whole, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s过大", field) + } + multiplier := pow10(scale) + if whole > (1<<62)/multiplier { + return "", 0, fmt.Errorf("%s过大", field) + } + var fraction int64 + if len(parts) == 2 { + fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1])) + fraction, err = strconv.ParseInt(fractionText, 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s不正确", field) + } + } + scaled := whole*multiplier + fraction + if !allowZero && scaled <= 0 { + return "", 0, fmt.Errorf("%s必须大于 0", field) + } + return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil +} + +func fixedDigits(value int64, scale int) string { + text := strconv.FormatInt(value, 10) + if len(text) >= scale { + return text + } + return strings.Repeat("0", scale-len(text)) + text +} + +func trimDecimalZeros(value string) string { + value = strings.TrimSpace(value) + if !strings.Contains(value, ".") { + return value + } + value = strings.TrimRight(value, "0") + return strings.TrimRight(value, ".") +} + +func pow10(scale int) int64 { + var out int64 = 1 + for i := 0; i < scale; i++ { + out *= 10 + } + return out +} + +func allDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func normalizePolicyType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case policyTypeBD: + return policyTypeBD + case policyTypeAdmin, "super_admin", "super-admin": + return policyTypeAdmin + default: + return "" + } +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", policyStatusActive, "enabled": + if strings.TrimSpace(value) == "" { + return "" + } + return policyStatusActive + case policyStatusDisabled, "inactive": + return policyStatusDisabled + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeStatusFilter(value string) string { + status := normalizeStatus(value) + if !validStatus(status) { + return "" + } + return status +} + +func validStatus(value string) bool { + return value == policyStatusActive || value == policyStatusDisabled +} + +func normalizeSettlementTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", settlementTriggerAutomatic, "auto": + if strings.TrimSpace(value) == "" { + return "" + } + return settlementTriggerAutomatic + case settlementTriggerManual, "manual_settlement": + return settlementTriggerManual + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeSettlementTriggerModeFilter(value string) string { + mode := normalizeSettlementTriggerMode(value) + if !validSettlementTriggerMode(mode) { + return "" + } + return mode +} + +func validSettlementTriggerMode(value string) bool { + return value == settlementTriggerAutomatic || value == settlementTriggerManual +} diff --git a/server/admin/internal/modules/teamsalarypolicy/service_test.go b/server/admin/internal/modules/teamsalarypolicy/service_test.go new file mode 100644 index 00000000..6b6ef457 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/service_test.go @@ -0,0 +1,58 @@ +package teamsalarypolicy + +import "testing" + +func TestPolicyModelFromRequestBuildsBDLevels(t *testing.T) { + item, err := policyModelFromRequest("lalu", 7, policyRequest{ + PolicyType: "bd", + Name: "BD 默认政策", + RegionID: 101, + Status: "active", + SettlementTriggerMode: "manual", + Levels: []levelRequest{ + {Level: 2, ThresholdUSD: "200", RatePercent: "8", SalaryUSD: "16"}, + {Level: 1, ThresholdUSD: "100", RatePercent: "8", SalaryUSD: "8"}, + }, + }) + if err != nil { + t.Fatalf("policy should be valid: %v", err) + } + if item.PolicyType != policyTypeBD || item.CreatedByAdminID != 7 || item.SettlementTriggerMode != settlementTriggerManual { + t.Fatalf("policy fields mismatch: %+v", item) + } + if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].ThresholdUSD != "200.00" { + t.Fatalf("levels should be sorted and normalized: %+v", item.Levels) + } + if item.Levels[0].RatePercent != "8.0000" || item.Levels[1].SalaryUSD != "16.00" { + t.Fatalf("level decimals mismatch: %+v", item.Levels) + } +} + +func TestPolicyModelFromRequestRejectsNonIncreasingThreshold(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + PolicyType: "admin", + Name: "Invalid", + RegionID: 101, + Levels: []levelRequest{ + {Level: 1, ThresholdUSD: "500", RatePercent: "20", SalaryUSD: "100"}, + {Level: 2, ThresholdUSD: "200", RatePercent: "20", SalaryUSD: "200"}, + }, + }) + if err == nil { + t.Fatal("expected non-increasing threshold to fail") + } +} + +func TestPolicyModelFromRequestRejectsInvalidPolicyType(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + PolicyType: "host", + Name: "Invalid", + RegionID: 101, + Levels: []levelRequest{ + {Level: 1, ThresholdUSD: "100", RatePercent: "8", SalaryUSD: "8"}, + }, + }) + if err == nil { + t.Fatal("expected invalid policy type to fail") + } +} diff --git a/server/admin/internal/modules/teamsalarysettlement/auto_runner.go b/server/admin/internal/modules/teamsalarysettlement/auto_runner.go new file mode 100644 index 00000000..0bc5240e --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/auto_runner.go @@ -0,0 +1,98 @@ +package teamsalarysettlement + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" +) + +const autoRunnerInterval = time.Hour + +// AutoRunner 是 admin-server 内的轻量自动结算循环;真正的幂等不靠 runner,而靠 wallet 结算进度表。 +type AutoRunner struct { + service *Service + appCode string + worker string + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewAutoRunner 只保存运行参数,不立刻启动 goroutine,便于 main 按 jobs.enabled 控制生命周期。 +func NewAutoRunner(service *Service, appCode string, worker string) *AutoRunner { + return &AutoRunner{ + service: service, + appCode: normalizeAppCode(appCode), + worker: strings.TrimSpace(worker), + } +} + +// Start 启动后台循环;重复调用不创建第二个 worker,避免同进程内重复扫描。 +func (r *AutoRunner) Start() { + if r == nil || r.service == nil || r.cancel != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + r.wg.Add(1) + go r.loop(ctx) +} + +// Close 停止后台循环并等待退出,保证 admin-server 优雅关闭时不会截断正在执行的事务。 +func (r *AutoRunner) Close() { + if r == nil || r.cancel == nil { + return + } + r.cancel() + r.wg.Wait() + r.cancel = nil +} + +// loop 启动后先立即检查一次,再按小时检查;是否真正发薪由 RunAutomaticDue 按日期判断。 +func (r *AutoRunner) loop(ctx context.Context) { + defer r.wg.Done() + r.runOnce(ctx, time.Now()) + ticker := time.NewTicker(autoRunnerInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + r.runOnce(ctx, now) + } + } +} + +// runOnce 给单次自动结算设置超时,避免数据库异常时 goroutine 永久卡住。 +func (r *AutoRunner) runOnce(parent context.Context, now time.Time) { + ctx, cancel := context.WithTimeout(parent, 10*time.Minute) + defer cancel() + // 多节点同时触发时不单独抢锁;结算进度行和 command_id 唯一索引会保证同一周期同一用户只发一次。 + result, err := r.service.RunAutomaticDue(ctx, r.appCode, now) + if err != nil { + slog.Error("team_salary_auto_settlement_failed", "worker", r.worker, "error", err) + return + } + if !result.Due { + return + } + slog.Info("team_salary_auto_settlement_completed", + "worker", r.worker, + "cycle_key", result.CycleKey, + "bd_success", result.Results[policyTypeBD].SuccessCount, + "bd_skipped", result.Results[policyTypeBD].SkippedCount, + "admin_success", result.Results[policyTypeAdmin].SuccessCount, + "admin_skipped", result.Results[policyTypeAdmin].SkippedCount, + ) +} + +// normalizeAppCode 兜底默认应用编码,保证自动任务和后台手动入口都使用同一 app 维度。 +func normalizeAppCode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "lalu" + } + return value +} diff --git a/server/admin/internal/modules/teamsalarysettlement/dto.go b/server/admin/internal/modules/teamsalarysettlement/dto.go new file mode 100644 index 00000000..62319abc --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/dto.go @@ -0,0 +1,102 @@ +package teamsalarysettlement + +type userDTO struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +type pendingDTO struct { + PolicyType string `json:"policy_type"` + UserID string `json:"user_id"` + User userDTO `json:"user"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + RegionName string `json:"region_name"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + LevelNo int32 `json:"level_no"` + IncomeUSDMinor int64 `json:"income_usd_minor"` + IncomeUSD string `json:"income_usd"` + SalaryUSDMinorTarget int64 `json:"salary_usd_minor_target"` + SalaryUSDTarget string `json:"salary_usd_target"` + SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"` + SalaryUSDDelta string `json:"salary_usd_delta"` + SettledLevelNo int32 `json:"settled_level_no"` + SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"` + SettledSalaryUSD string `json:"settled_salary_usd"` + LastSettledIncomeUSDMinor int64 `json:"last_settled_income_usd_minor"` + LastSettledIncomeUSD string `json:"last_settled_income_usd"` + MonthClosedAtMS int64 `json:"month_closed_at_ms"` +} + +type recordDTO struct { + SettlementID string `json:"settlement_id"` + CommandID string `json:"command_id"` + TransactionID string `json:"transaction_id"` + PolicyType string `json:"policy_type"` + TriggerMode string `json:"trigger_mode"` + UserID string `json:"user_id"` + User userDTO `json:"user"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + PolicyID uint64 `json:"policy_id"` + LevelNo int32 `json:"level_no"` + IncomeUSDMinor int64 `json:"income_usd_minor"` + IncomeUSD string `json:"income_usd"` + SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"` + SalaryUSDDelta string `json:"salary_usd_delta"` + Status string `json:"status"` + Reason string `json:"reason"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type pendingQuery struct { + Page int + PageSize int + PolicyType string + TriggerMode string + CycleKey string + RegionID int64 + CountryID int64 + CountryCode string + UserID int64 +} + +type recordQuery struct { + Page int + PageSize int + PolicyType string + TriggerMode string + CycleKey string + Status string + RegionID int64 + CountryID int64 + CountryCode string + UserID int64 + PolicyID uint64 + StartAtMS int64 + EndAtMS int64 +} + +type settleRequest struct { + PolicyType string `json:"policy_type"` + TriggerMode string `json:"trigger_mode"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + CountryID int64 `json:"country_id"` + CountryCode string `json:"country_code"` + UserIDs []int64 `json:"user_ids"` +} + +type settleResultDTO struct { + PolicyType string `json:"policy_type"` + CycleKey string `json:"cycle_key"` + RequestedCount int `json:"requested_count"` + ProcessedCount int `json:"processed_count"` + SuccessCount int `json:"success_count"` + SkippedCount int `json:"skipped_count"` + FailureCount int `json:"failure_count"` + Records []recordDTO `json:"records"` +} diff --git a/server/admin/internal/modules/teamsalarysettlement/handler.go b/server/admin/internal/modules/teamsalarysettlement/handler.go new file mode 100644 index 00000000..1f1adaad --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/handler.go @@ -0,0 +1,209 @@ +package teamsalarysettlement + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + audit shared.OperationLogger + service *Service +} + +// New 只做 HTTP 层组装,具体工资口径全部下沉到 Service,避免 handler 里复制账务逻辑。 +func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler { + return &Handler{audit: audit, service: NewService(adminDB, walletDB, userDB)} +} + +// Service 暴露给自动任务 runner 复用同一个结算引擎,保证手动和自动结算走同一套规则。 +func (h *Handler) Service() *Service { + if h == nil { + return nil + } + return h.service +} + +// ListPending 返回当前筛选条件下可产生正向工资差值的 BD/Admin 用户。 +func (h *Handler) ListPending(c *gin.Context) { + req, ok := parsePendingQuery(c) + if !ok { + return + } + items, total, err := h.service.ListPending(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) + if err != nil { + response.ServerError(c, "获取待结算工资失败") + return + } + response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) +} + +// ListRecords 返回已经生成 settlement_id 的 BD/Admin 结算记录,用于后台查账。 +func (h *Handler) ListRecords(c *gin.Context) { + req, ok := parseRecordQuery(c) + if !ok { + return + } + items, total, err := h.service.ListRecords(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) + if err != nil { + response.ServerError(c, "获取 BD/Admin 结算记录失败") + return + } + response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) +} + +// Settle 执行后台批量手动结算;前端传入 user_ids 后,Service 会再次按候选白名单过滤。 +func (h *Handler) Settle(c *gin.Context) { + var req settleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "结算参数不正确") + return + } + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) + if req.PolicyType == "" { + response.BadRequest(c, "工资类型不正确") + return + } + if req.TriggerMode == "" { + response.BadRequest(c, "触发方式不正确") + return + } + result, err := h.service.Settle(c.Request.Context(), appctx.FromContext(c.Request.Context()), int64(shared.ActorFromContext(c).UserID), req) + if err != nil { + response.ServerError(c, "工资结算失败") + return + } + shared.OperationLog(c, h.audit, "settle-team-salary", "team_salary_settlement_records", "success", fmt.Sprintf("policy_type=%s cycle_key=%s success=%d skipped=%d failed=%d", result.PolicyType, result.CycleKey, result.SuccessCount, result.SkippedCount, result.FailureCount)) + response.OK(c, result) +} + +// parsePendingQuery 兼容 snake_case/camelCase 查询参数,便于前端直接传 API DTO 或内部模型字段。 +func parsePendingQuery(c *gin.Context) (pendingQuery, bool) { + options := shared.ListOptions(c) + req := pendingQuery{ + Page: options.Page, + PageSize: options.PageSize, + PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")), + TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), + CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), + CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), + } + var ok bool + if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { + response.BadRequest(c, "区域 ID 不正确") + return pendingQuery{}, false + } + if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok { + response.BadRequest(c, "国家 ID 不正确") + return pendingQuery{}, false + } + if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok { + response.BadRequest(c, "用户 ID 不正确") + return pendingQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" { + response.BadRequest(c, "工资类型不正确") + return pendingQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" { + response.BadRequest(c, "触发方式不正确") + return pendingQuery{}, false + } + return normalizePendingQuery(req), true +} + +// parseRecordQuery 统一记录列表的分页、枚举和时间区间校验,避免把非法筛选传到 SQL 组装层。 +func parseRecordQuery(c *gin.Context) (recordQuery, bool) { + options := shared.ListOptions(c) + req := recordQuery{ + Page: options.Page, + PageSize: options.PageSize, + PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")), + TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), + CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), + Status: normalizeStatus(firstQuery(c, "status")), + CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), + } + var ok bool + if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { + response.BadRequest(c, "区域 ID 不正确") + return recordQuery{}, false + } + if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok { + response.BadRequest(c, "国家 ID 不正确") + return recordQuery{}, false + } + if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok { + response.BadRequest(c, "用户 ID 不正确") + return recordQuery{}, false + } + if req.PolicyID, ok = optionalUint64(c, "policy_id", "policyId"); !ok { + response.BadRequest(c, "政策 ID 不正确") + return recordQuery{}, false + } + if req.StartAtMS, ok = optionalInt64(c, "start_at_ms", "startAtMs"); !ok { + response.BadRequest(c, "开始时间不正确") + return recordQuery{}, false + } + if req.EndAtMS, ok = optionalInt64(c, "end_at_ms", "endAtMs"); !ok { + response.BadRequest(c, "结束时间不正确") + return recordQuery{}, false + } + if req.StartAtMS > 0 && req.EndAtMS > 0 && req.StartAtMS >= req.EndAtMS { + response.BadRequest(c, "时间区间不正确") + return recordQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" { + response.BadRequest(c, "工资类型不正确") + return recordQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" { + response.BadRequest(c, "触发方式不正确") + return recordQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "status")); raw != "" && strings.ToLower(raw) != "all" && req.Status == "" { + response.BadRequest(c, "状态不正确") + return recordQuery{}, false + } + return normalizeRecordQuery(req), true +} + +// optionalInt64 解析可选正整数查询参数;空值代表不筛选。 +func optionalInt64(c *gin.Context, keys ...string) (int64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil && parsed >= 0 +} + +// optionalUint64 解析可选无符号整数,当前用于 policy_id。 +func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseUint(value, 10, 64) + return parsed, err == nil +} + +// firstQuery 按优先级读取同义查询参数,避免每个字段重复写 snake/camel fallback。 +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/teamsalarysettlement/routes.go b/server/admin/internal/modules/teamsalarysettlement/routes.go new file mode 100644 index 00000000..233f4844 --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/routes.go @@ -0,0 +1,18 @@ +package teamsalarysettlement + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + // 三个接口共用“工资结算”菜单权限:view 看待结算/记录,settle 才能真正写钱包和结算记录。 + protected.GET("/admin/team-salary-settlements/pending", middleware.RequirePermission("host-salary-settlement:view"), h.ListPending) + protected.POST("/admin/team-salary-settlements/settle", middleware.RequirePermission("host-salary-settlement:settle"), h.Settle) + protected.GET("/admin/team-salary-settlements/records", middleware.RequirePermission("host-salary-settlement:view"), h.ListRecords) +} diff --git a/server/admin/internal/modules/teamsalarysettlement/service.go b/server/admin/internal/modules/teamsalarysettlement/service.go new file mode 100644 index 00000000..005a3805 --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/service.go @@ -0,0 +1,1457 @@ +package teamsalarysettlement + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "sort" + "strconv" + "strings" + "time" +) + +const ( + // maxPageSize 限制后台列表单次拉取规模,避免待结算计算在页面误传大 page_size 时拖垮三个库的聚合查询。 + maxPageSize = 100 + policyTypeBD = "bd" + policyTypeAdmin = "admin" + triggerAutomatic = "automatic" + triggerManual = "manual" + // assetUSDBalance 必须与 wallet-service 钱包资产类型保持一致,BD/Admin 工资直接入美元余额账户。 + assetUSDBalance = "USD_BALANCE" + bizTypeTeamSettlement = "team_salary_settlement" + statusSucceeded = "succeeded" +) + +// autoSettlementResult 是自动任务内部的聚合结果;它不直接暴露给前端,只用于日志和测试断言。 +type autoSettlementResult struct { + Due bool `json:"due"` + CycleKey string `json:"cycle_key"` + Results map[string]settleResultDTO `json:"results"` +} + +// Service 聚合 admin/user/wallet 三个库:政策在 admin,组织关系在 user,结算进度与钱包流水在 wallet。 +type Service struct { + adminDB *sql.DB + walletDB *sql.DB + userDB *sql.DB +} + +// teamPolicy 是结算时使用的政策快照;工资按等级累计值发差值,因此必须携带完整等级表。 +type teamPolicy struct { + PolicyID uint64 + PolicyType string + Name string + RegionID int64 + TriggerMode string + Levels []teamPolicyLevel +} + +// teamPolicyLevel 中金额统一转为美分整数,避免 decimal 字符串在计算差值时出现浮点误差。 +type teamPolicyLevel struct { + LevelNo int + ThresholdMinor int64 + SalaryMinor int64 +} + +// teamProgress 是幂等核心:同一用户同一周期只记录“已发到哪个累计工资”,重复结算自然得到 0 差值。 +type teamProgress struct { + SettledLevelNo int + SettledSalaryUSDMinor int64 + LastSettledIncomeUSDMinor int64 + LastPolicyID uint64 + MonthClosedAtMS int64 + Version int64 +} + +// teamCandidate 是待结算候选人,已经完成收入聚合、区域归属、政策匹配和差值计算。 +type teamCandidate struct { + PolicyType string + UserID int64 + CycleKey string + RegionID int64 + RegionName string + Policy teamPolicy + Level teamPolicyLevel + IncomeUSDMinor int64 + Progress teamProgress + SalaryUSDMinorDelta int64 +} + +// settlementMetadata 被同时写入钱包交易和 outbox,后续查账可以从任一钱包流水反查政策、周期、档位和操作者。 +type settlementMetadata struct { + AppCode string `json:"app_code"` + PolicyType string `json:"policy_type"` + TriggerMode string `json:"trigger_mode"` + UserID int64 `json:"user_id"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + LevelNo int `json:"level_no"` + IncomeUSDMinor int64 `json:"income_usd_minor"` + SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"` + SettledLevelNo int `json:"settled_level_no"` + SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"` + ProcessedAtMS int64 `json:"processed_at_ms"` + OperatorAdminID int64 `json:"operator_admin_id"` +} + +// userProfile 只用于后台展示,结算主链路永远以 user_id 为准,避免昵称或短 ID 变更影响账务。 +type userProfile struct { + UserID int64 + DisplayUserID string + Username string + Avatar string +} + +// NewService 注入三个数据库连接;调用方负责连接生命周期,Service 只做业务编排。 +func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service { + return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB} +} + +// ListPending 计算当前筛选条件下“现在结算会产生正向工资差值”的 BD/Admin 列表。 +func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQuery) ([]pendingDTO, int64, error) { + if err := s.ensureReady(ctx); err != nil { + return nil, 0, err + } + req = normalizePendingQuery(req) + // 待结算列表复用结算候选计算,保证运营看到的金额与真正点击结算时的金额口径一致。 + candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, req.UserID, time.Now().UTC().UnixMilli()) + if err != nil { + return nil, 0, err + } + // 待结算列表只展示有正向差额的用户;未达档或已结清用户留在记录/进度表里,避免运营批量操作时出现 0 金额行。 + pending := make([]teamCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.SalaryUSDMinorDelta > 0 { + pending = append(pending, candidate) + } + } + sortTeamCandidates(pending) + total := int64(len(pending)) + pageItems := paginateCandidates(pending, req.Page, req.PageSize) + // 用户信息只按分页后的结果加载,避免大区域待结算列表一次性扫全量用户资料。 + profiles, err := s.userProfiles(ctx, appCode, candidateUserIDs(pageItems)) + if err != nil { + return nil, 0, err + } + items := make([]pendingDTO, 0, len(pageItems)) + for _, item := range pageItems { + items = append(items, pendingDTOFromCandidate(item, profiles[item.UserID])) + } + return items, total, nil +} + +// ListRecords 查询 BD/Admin 结算记录;国家筛选会先转成区域集合,再过滤 wallet 记录里的 region_id。 +func (s *Service) ListRecords(ctx context.Context, appCode string, req recordQuery) ([]recordDTO, int64, error) { + if err := s.ensureReady(ctx); err != nil { + return nil, 0, err + } + req = normalizeRecordQuery(req) + whereSQL, args, err := s.recordWhere(ctx, strings.TrimSpace(appCode), req) + if err != nil { + return nil, 0, err + } + var total int64 + if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM team_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT settlement_id, command_id, transaction_id, policy_type, trigger_mode, user_id, + cycle_key, region_id, policy_id, level_no, income_usd_minor, salary_usd_minor_delta, + status, reason, created_at_ms + FROM team_salary_settlement_records + `+whereSQL+` + ORDER BY created_at_ms DESC, settlement_id DESC + LIMIT ? OFFSET ?`, + append(args, req.PageSize, offset(req.Page, req.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]recordDTO, 0, req.PageSize) + userIDs := make([]int64, 0, req.PageSize) + for rows.Next() { + var item recordDTO + var userID int64 + if err := rows.Scan(&item.SettlementID, &item.CommandID, &item.TransactionID, &item.PolicyType, &item.TriggerMode, &userID, &item.CycleKey, &item.RegionID, &item.PolicyID, &item.LevelNo, &item.IncomeUSDMinor, &item.SalaryUSDMinorDelta, &item.Status, &item.Reason, &item.CreatedAtMS); err != nil { + return nil, 0, err + } + item.UserID = strconv.FormatInt(userID, 10) + item.User = userDTO{UserID: item.UserID} + item.IncomeUSD = formatUSDMinor(item.IncomeUSDMinor) + item.SalaryUSDDelta = formatUSDMinor(item.SalaryUSDMinorDelta) + items = append(items, item) + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + profiles, err := s.userProfiles(ctx, appCode, userIDs) + if err != nil { + return nil, 0, err + } + for i := range items { + if id, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil { + items[i].User = userDTOFromProfile(items[i].UserID, profiles[id]) + } + } + return items, total, nil +} + +// Settle 执行手动或自动结算;它只关心“触发方式”和“用户集合”,差值与幂等统一落在 settleCandidate。 +func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID int64, req settleRequest) (settleResultDTO, error) { + if err := s.ensureReady(ctx); err != nil { + return settleResultDTO{}, err + } + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + if req.TriggerMode == "" { + req.TriggerMode = triggerManual + } + if req.CycleKey == "" { + req.CycleKey = previousMonthCycleKey(time.Now().UTC()) + } + userFilter := int64(0) + if len(req.UserIDs) == 1 { + userFilter = req.UserIDs[0] + } + // 单用户结算在候选聚合阶段下推 userFilter;批量结算先算区域候选再做白名单过滤。 + candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, userFilter, time.Now().UTC().UnixMilli()) + if err != nil { + return settleResultDTO{}, err + } + if len(req.UserIDs) > 0 { + allowed := map[int64]struct{}{} + for _, id := range req.UserIDs { + if id > 0 { + allowed[id] = struct{}{} + } + } + // 前端多选只允许结算勾选用户;这里二次过滤,避免客户端篡改国家/区域后误结不在选择范围内的人。 + filtered := candidates[:0] + for _, candidate := range candidates { + if _, ok := allowed[candidate.UserID]; ok { + filtered = append(filtered, candidate) + } + } + candidates = filtered + } + sortTeamCandidates(candidates) + + result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(req.UserIDs), Records: []recordDTO{}} + profiles, _ := s.userProfiles(ctx, appCode, candidateUserIDs(candidates)) + for _, candidate := range candidates { + result.ProcessedCount++ + // 单个候选失败不阻塞同批其他用户,后台返回 failure_count 供运营重试定位。 + record, settled, err := s.settleCandidate(ctx, strings.TrimSpace(appCode), operatorAdminID, req.TriggerMode, candidate) + if err != nil { + result.FailureCount++ + continue + } + if !settled { + result.SkippedCount++ + continue + } + if id, err := strconv.ParseInt(record.UserID, 10, 64); err == nil { + record.User = userDTOFromProfile(record.UserID, profiles[id]) + } + result.SuccessCount++ + result.Records = append(result.Records, record) + } + return result, nil +} + +// RunAutomaticDue 是自动任务入口:只在每月 5 日结上一个完整自然月,并固定先结 BD 再结 Admin。 +func (s *Service) RunAutomaticDue(ctx context.Context, appCode string, now time.Time) (autoSettlementResult, error) { + utcNow := now.UTC() + result := autoSettlementResult{ + Due: utcNow.Day() == 5, + CycleKey: previousMonthCycleKey(utcNow), + Results: map[string]settleResultDTO{}, + } + if !result.Due { + return result, nil + } + // BD 必须先跑:Admin 的收入基数来自同一周期内 BD 已成功入账的工资记录。 + bdResult, err := s.Settle(ctx, appCode, 0, settleRequest{ + PolicyType: policyTypeBD, + TriggerMode: triggerAutomatic, + CycleKey: result.CycleKey, + }) + if err != nil { + return result, err + } + result.Results[policyTypeBD] = bdResult + + adminResult, err := s.Settle(ctx, appCode, 0, settleRequest{ + PolicyType: policyTypeAdmin, + TriggerMode: triggerAutomatic, + CycleKey: result.CycleKey, + }) + if err != nil { + return result, err + } + result.Results[policyTypeAdmin] = adminResult + return result, nil +} + +// collectCandidates 汇总 BD 和 Admin 两类候选;policyType 为空时用于记录页或内部全量扫描。 +func (s *Service) collectCandidates(ctx context.Context, appCode string, policyType string, triggerMode string, cycleKey string, regionID int64, countryID int64, countryCode string, userID int64, nowMS int64) ([]teamCandidate, error) { + policyType = normalizePolicyType(policyType) + triggerMode = normalizeTriggerMode(triggerMode) + if cycleKey == "" { + cycleKey = previousMonthCycleKey(time.Now().UTC()) + } + // 国家筛选在 user 库维护,结算记录只存 region_id,因此先把国家解析为一组可用区域。 + regionFilter, err := s.regionFilter(ctx, appCode, regionID, countryID, countryCode) + if err != nil { + return nil, err + } + var candidates []teamCandidate + if policyType == "" || policyType == policyTypeBD { + items, err := s.collectBDCandidates(ctx, appCode, triggerMode, cycleKey, regionFilter, userID, nowMS) + if err != nil { + return nil, err + } + candidates = append(candidates, items...) + } + if policyType == "" || policyType == policyTypeAdmin { + items, err := s.collectAdminCandidates(ctx, appCode, triggerMode, cycleKey, regionFilter, userID, nowMS) + if err != nil { + return nil, err + } + candidates = append(candidates, items...) + } + return candidates, nil +} + +// collectBDCandidates 以 Agency 为桥接,把 Host/Agency 已成功结算的主播收入汇总到其上级 BD。 +func (s *Service) collectBDCandidates(ctx context.Context, appCode string, triggerMode string, cycleKey string, regions map[int64]struct{}, userID int64, nowMS int64) ([]teamCandidate, error) { + agencyIncome, err := s.hostIncomeByAgencyOwner(ctx, appCode, cycleKey) + if err != nil || len(agencyIncome) == 0 { + return nil, err + } + // host_salary_settlement_records 只知道 agency owner;BD 归属必须回 user 库查 active agency 当前关系。 + agencies, err := s.activeAgenciesByOwner(ctx, appCode, keysInt64(agencyIncome)) + if err != nil { + return nil, err + } + incomeByBDRegion := map[string]int64{} + for ownerID, income := range agencyIncome { + agency, ok := agencies[ownerID] + if !ok || agency.ParentBDUserID <= 0 || income <= 0 { + continue + } + // 区域来自 Agency 配置,不从主播记录推导,保证 BD 政策和组织管理后台保持同一口径。 + if len(regions) > 0 { + if _, ok := regions[agency.RegionID]; !ok { + continue + } + } + if userID > 0 && agency.ParentBDUserID != userID { + continue + } + incomeByBDRegion[teamCandidateKey(agency.ParentBDUserID, agency.RegionID)] += income + } + return s.candidatesFromIncome(ctx, appCode, policyTypeBD, triggerMode, cycleKey, incomeByBDRegion, nowMS) +} + +// collectAdminCandidates 以已发 BD 工资作为 Admin 收入基数,再通过 BD profile 找到上级 Admin。 +func (s *Service) collectAdminCandidates(ctx context.Context, appCode string, triggerMode string, cycleKey string, regions map[int64]struct{}, userID int64, nowMS int64) ([]teamCandidate, error) { + bdIncome, err := s.bdSalaryIncomeByUser(ctx, appCode, cycleKey) + if err != nil || len(bdIncome) == 0 { + return nil, err + } + // Admin 政策定义为“下属 BD 的 BD Salary 合计”,因此只有已成功入账的 BD 记录才进入这里。 + bdProfiles, err := s.bdProfilesByUser(ctx, appCode, keysInt64(bdIncome), "bd") + if err != nil { + return nil, err + } + leaderIDs := make([]int64, 0, len(bdProfiles)) + for _, profile := range bdProfiles { + if profile.ParentLeaderUserID > 0 { + leaderIDs = append(leaderIDs, profile.ParentLeaderUserID) + } + } + leaders, err := s.bdProfilesByUser(ctx, appCode, uniquePositiveUserIDs(leaderIDs), "bd_leader") + if err != nil { + return nil, err + } + incomeByAdminRegion := map[string]int64{} + for bdUserID, income := range bdIncome { + profile, ok := bdProfiles[bdUserID] + if !ok || profile.ParentLeaderUserID <= 0 || income <= 0 { + continue + } + leader, ok := leaders[profile.ParentLeaderUserID] + if !ok { + continue + } + // Admin 的适用区域以 bd_leader profile 为准,避免一个 BD 跨区域时把收入错配到错误政策。 + if len(regions) > 0 { + if _, ok := regions[leader.RegionID]; !ok { + continue + } + } + if userID > 0 && leader.UserID != userID { + continue + } + incomeByAdminRegion[teamCandidateKey(leader.UserID, leader.RegionID)] += income + } + return s.candidatesFromIncome(ctx, appCode, policyTypeAdmin, triggerMode, cycleKey, incomeByAdminRegion, nowMS) +} + +// candidatesFromIncome 将“用户+区域+收入基数”转换为可结算候选,集中处理政策匹配、等级选择和差值计算。 +func (s *Service) candidatesFromIncome(ctx context.Context, appCode string, policyType string, triggerMode string, cycleKey string, incomeByUserRegion map[string]int64, nowMS int64) ([]teamCandidate, error) { + if len(incomeByUserRegion) == 0 { + return nil, nil + } + // 区域名称只是展示字段;读取失败不影响结算主流程,主键 region_id 仍然完整。 + regionNames, _ := s.regionNames(ctx, appCode) + out := make([]teamCandidate, 0, len(incomeByUserRegion)) + for key, income := range incomeByUserRegion { + userID, regionID := parseTeamCandidateKey(key) + // 自动/手动政策同一区域可共存,triggerMode 必须参与政策解析,否则会把人工复核政策提前发放。 + policy, ok, err := s.resolvePolicy(ctx, appCode, policyType, regionID, triggerMode, nowMS) + if err != nil || !ok { + if err != nil { + return nil, err + } + continue + } + progress, err := s.loadProgress(ctx, appCode, policyType, userID, cycleKey) + if err != nil { + return nil, err + } + level := highestTeamLevel(policy.Levels, income) + // 等级薪资是“累计应得”,这里先算一次待结差值供页面展示,真正发放时还会在事务锁内重算。 + delta := positiveDelta(level.SalaryMinor, progress.SettledSalaryUSDMinor) + out = append(out, teamCandidate{ + PolicyType: policyType, + UserID: userID, + CycleKey: cycleKey, + RegionID: regionID, + RegionName: regionNames[regionID], + Policy: policy, + Level: level, + IncomeUSDMinor: income, + Progress: progress, + SalaryUSDMinorDelta: delta, + }) + } + return out, nil +} + +// settleCandidate 在一个 wallet 事务内完成进度锁定、差值重算、钱包入账、流水和结算记录写入。 +func (s *Service) settleCandidate(ctx context.Context, appCode string, operatorAdminID int64, triggerMode string, candidate teamCandidate) (recordDTO, bool, error) { + nowMS := time.Now().UTC().UnixMilli() + tx, err := s.walletDB.BeginTx(ctx, nil) + if err != nil { + return recordDTO{}, false, err + } + defer func() { _ = tx.Rollback() }() + + progress, err := s.lockProgress(ctx, tx, appCode, candidate, nowMS) + if err != nil { + return recordDTO{}, false, err + } + // 结算表里的等级工资是累计值;每次只发“当前应得累计 - 已发累计”,因此重复提交同一批用户不会二次入账。 + salaryDelta := positiveDelta(candidate.Level.SalaryMinor, progress.SettledSalaryUSDMinor) + if salaryDelta <= 0 { + // 未升档但收入基数增加时只刷新 last_settled_income,减少后续待结算列表反复出现 0 金额候选。 + if candidate.IncomeUSDMinor > progress.LastSettledIncomeUSDMinor { + progress.LastSettledIncomeUSDMinor = candidate.IncomeUSDMinor + if err := s.updateProgress(ctx, tx, appCode, candidate, progress, nowMS); err != nil { + return recordDTO{}, false, err + } + } + if err := tx.Commit(); err != nil { + return recordDTO{}, false, err + } + return recordDTO{}, false, nil + } + + // nextProgress 使用 max,确保历史已发累计不会因政策调整或候选回退被写小。 + nextProgress := teamProgress{ + SettledLevelNo: maxInt(progress.SettledLevelNo, candidate.Level.LevelNo), + SettledSalaryUSDMinor: maxInt64(progress.SettledSalaryUSDMinor, candidate.Level.SalaryMinor), + LastSettledIncomeUSDMinor: maxInt64(progress.LastSettledIncomeUSDMinor, candidate.IncomeUSDMinor), + LastPolicyID: candidate.Policy.PolicyID, + MonthClosedAtMS: nowMS, + Version: progress.Version, + } + metadata := settlementMetadata{ + AppCode: appCode, + PolicyType: candidate.PolicyType, + TriggerMode: triggerMode, + UserID: candidate.UserID, + CycleKey: candidate.CycleKey, + RegionID: candidate.RegionID, + PolicyID: candidate.Policy.PolicyID, + PolicyName: candidate.Policy.Name, + LevelNo: candidate.Level.LevelNo, + IncomeUSDMinor: candidate.IncomeUSDMinor, + SalaryUSDMinorDelta: salaryDelta, + SettledLevelNo: nextProgress.SettledLevelNo, + SettledSalaryUSDMinor: nextProgress.SettledSalaryUSDMinor, + ProcessedAtMS: nowMS, + OperatorAdminID: operatorAdminID, + } + // commandID 带上目标等级和收入基数;同一进度重复提交会命中 progress 差值为 0,异常重试也会被唯一键兜住。 + commandID := teamSalaryCommandID(candidate, triggerMode, nextProgress) + transactionID := teamSalaryTransactionID(appCode, commandID) + if err := s.insertWalletTransaction(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil { + return recordDTO{}, false, err + } + balanceAfter, version, err := s.creditWallet(ctx, tx, appCode, transactionID, candidate.UserID, salaryDelta, nowMS) + if err != nil { + return recordDTO{}, false, err + } + if err := s.insertWalletOutbox(ctx, tx, appCode, transactionID, commandID, candidate.UserID, salaryDelta, balanceAfter, version, metadata, nowMS); err != nil { + return recordDTO{}, false, err + } + if err := s.insertRecord(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil { + return recordDTO{}, false, err + } + if err := s.updateProgress(ctx, tx, appCode, candidate, nextProgress, nowMS); err != nil { + return recordDTO{}, false, err + } + if err := tx.Commit(); err != nil { + return recordDTO{}, false, err + } + return recordDTO{ + SettlementID: teamSalarySettlementID(appCode, commandID), + CommandID: commandID, + TransactionID: transactionID, + PolicyType: candidate.PolicyType, + TriggerMode: triggerMode, + UserID: strconv.FormatInt(candidate.UserID, 10), + User: userDTO{UserID: strconv.FormatInt(candidate.UserID, 10)}, + CycleKey: candidate.CycleKey, + RegionID: candidate.RegionID, + PolicyID: candidate.Policy.PolicyID, + LevelNo: int32(candidate.Level.LevelNo), + IncomeUSDMinor: candidate.IncomeUSDMinor, + IncomeUSD: formatUSDMinor(candidate.IncomeUSDMinor), + SalaryUSDMinorDelta: salaryDelta, + SalaryUSDDelta: formatUSDMinor(salaryDelta), + Status: statusSucceeded, + CreatedAtMS: nowMS, + }, true, nil +} + +// hostIncomeByAgencyOwner 读取 Host/Agency 已成功结算记录,BD 只基于主播工资和月底剩余折美元,不计 Agency 自己工资。 +func (s *Service) hostIncomeByAgencyOwner(ctx context.Context, appCode string, cycleKey string) (map[int64]int64, error) { + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT agency_owner_user_id, SUM(host_salary_usd_minor_delta + residual_usd_minor_delta) + FROM host_salary_settlement_records + WHERE app_code = ? AND cycle_key = ? AND status = 'succeeded' AND agency_owner_user_id > 0 + GROUP BY agency_owner_user_id`, appCode, cycleKey) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int64{} + for rows.Next() { + var ownerID int64 + var income sql.NullInt64 + if err := rows.Scan(&ownerID, &income); err != nil { + return nil, err + } + if ownerID > 0 && income.Int64 > 0 { + out[ownerID] = income.Int64 + } + } + return out, rows.Err() +} + +// bdSalaryIncomeByUser 读取同周期已成功发放的 BD 工资,Admin 只按真实入账 BD Salary 计算。 +func (s *Service) bdSalaryIncomeByUser(ctx context.Context, appCode string, cycleKey string) (map[int64]int64, error) { + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT user_id, SUM(salary_usd_minor_delta) + FROM team_salary_settlement_records + WHERE app_code = ? AND policy_type = 'bd' AND cycle_key = ? AND status = 'succeeded' + GROUP BY user_id`, appCode, cycleKey) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int64{} + for rows.Next() { + var userID int64 + var income sql.NullInt64 + if err := rows.Scan(&userID, &income); err != nil { + return nil, err + } + if userID > 0 && income.Int64 > 0 { + out[userID] = income.Int64 + } + } + return out, rows.Err() +} + +// agencyInfo 是 BD 汇总所需的最小组织关系视图。 +type agencyInfo struct { + OwnerUserID int64 + ParentBDUserID int64 + RegionID int64 +} + +// activeAgenciesByOwner 只使用 active agency,避免历史或停用 Agency 继续给 BD 产生工资。 +func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, ownerIDs []int64) (map[int64]agencyInfo, error) { + ownerIDs = uniquePositiveUserIDs(ownerIDs) + if len(ownerIDs) == 0 { + return map[int64]agencyInfo{}, nil + } + placeholders := placeholders(len(ownerIDs)) + args := make([]any, 0, len(ownerIDs)+1) + args = append(args, appCode) + for _, id := range ownerIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT owner_user_id, parent_bd_user_id, region_id + FROM agencies + WHERE app_code = ? AND status = 'active' AND owner_user_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]agencyInfo{} + for rows.Next() { + var item agencyInfo + if err := rows.Scan(&item.OwnerUserID, &item.ParentBDUserID, &item.RegionID); err != nil { + return nil, err + } + out[item.OwnerUserID] = item + } + return out, rows.Err() +} + +// bdProfile 是 Admin 汇总所需的最小 BD/BD Leader 组织关系视图。 +type bdProfile struct { + UserID int64 + Role string + RegionID int64 + ParentLeaderUserID int64 +} + +// bdProfilesByUser 按角色取 active profile,BD 和 BD Leader 共用一张表但语义不同,调用方必须显式传 role。 +func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) { + userIDs = uniquePositiveUserIDs(userIDs) + if len(userIDs) == 0 { + return map[int64]bdProfile{}, nil + } + placeholders := placeholders(len(userIDs)) + args := make([]any, 0, len(userIDs)+2) + args = append(args, appCode, role) + for _, id := range userIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT user_id, role, region_id, COALESCE(parent_leader_user_id, 0) + FROM bd_profiles + WHERE app_code = ? AND role = ? AND status = 'active' AND user_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]bdProfile{} + for rows.Next() { + var item bdProfile + if err := rows.Scan(&item.UserID, &item.Role, &item.RegionID, &item.ParentLeaderUserID); err != nil { + return nil, err + } + out[item.UserID] = item + } + return out, rows.Err() +} + +// resolvePolicy 按 app、类型、区域、触发方式和生效时间解析当前唯一有效政策。 +func (s *Service) resolvePolicy(ctx context.Context, appCode string, policyType string, regionID int64, triggerMode string, nowMS int64) (teamPolicy, bool, error) { + conditions := []string{"app_code = ?", "policy_type = ?", "region_id = ?", "status = 'active'", "effective_from_ms <= ?", "(effective_to_ms = 0 OR effective_to_ms > ?)"} + args := []any{appCode, policyType, regionID, nowMS, nowMS} + if triggerMode != "" { + conditions = append(conditions, "settlement_trigger_mode = ?") + args = append(args, triggerMode) + } + row := s.adminDB.QueryRowContext(ctx, ` + SELECT id, policy_type, name, region_id, settlement_trigger_mode + FROM admin_team_salary_policies + WHERE `+strings.Join(conditions, " AND ")+` + ORDER BY effective_from_ms DESC, id DESC + LIMIT 1`, args...) + var policy teamPolicy + if err := row.Scan(&policy.PolicyID, &policy.PolicyType, &policy.Name, &policy.RegionID, &policy.TriggerMode); err != nil { + if errors.Is(err, sql.ErrNoRows) { + // 没有政策不是系统错误;该区域用户本轮没有可结算候选。 + return teamPolicy{}, false, nil + } + return teamPolicy{}, false, err + } + levels, err := s.policyLevels(ctx, policy.PolicyID) + if err != nil { + return teamPolicy{}, false, err + } + policy.Levels = levels + return policy, true, nil +} + +// policyLevels 读取并转换政策等级;排序按门槛递增,highestTeamLevel 可以线性选择最高达标档。 +func (s *Service) policyLevels(ctx context.Context, policyID uint64) ([]teamPolicyLevel, error) { + rows, err := s.adminDB.QueryContext(ctx, ` + SELECT level_no, CAST(threshold_usd AS CHAR), CAST(salary_usd AS CHAR) + FROM admin_team_salary_policy_levels + WHERE policy_id = ? AND status = 'active' + ORDER BY threshold_usd ASC, level_no ASC`, policyID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []teamPolicyLevel + for rows.Next() { + var level teamPolicyLevel + var threshold string + var salary string + if err := rows.Scan(&level.LevelNo, &threshold, &salary); err != nil { + return nil, err + } + thresholdMinor, err := usdStringToMinor(threshold) + if err != nil { + return nil, err + } + salaryMinor, err := usdStringToMinor(salary) + if err != nil { + return nil, err + } + level.ThresholdMinor = thresholdMinor + level.SalaryMinor = salaryMinor + out = append(out, level) + } + return out, rows.Err() +} + +// loadProgress 为列表展示读取当前进度;没有进度代表本周期尚未发过工资。 +func (s *Service) loadProgress(ctx context.Context, appCode string, policyType string, userID int64, cycleKey string) (teamProgress, error) { + var progress teamProgress + err := s.walletDB.QueryRowContext(ctx, ` + SELECT settled_level_no, settled_salary_usd_minor, last_settled_income_usd_minor, + last_policy_id, month_closed_at_ms, version + FROM team_salary_settlement_progress + WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ?`, + appCode, policyType, userID, cycleKey, + ).Scan(&progress.SettledLevelNo, &progress.SettledSalaryUSDMinor, &progress.LastSettledIncomeUSDMinor, &progress.LastPolicyID, &progress.MonthClosedAtMS, &progress.Version) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return teamProgress{Version: 1}, nil + } + return teamProgress{}, err + } + return progress, nil +} + +// lockProgress 先 INSERT IGNORE 再 SELECT FOR UPDATE,保证并发结算同一用户周期时只有一个事务能重算差值。 +func (s *Service) lockProgress(ctx context.Context, tx *sql.Tx, appCode string, candidate teamCandidate, nowMS int64) (teamProgress, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO team_salary_settlement_progress ( + app_code, policy_type, user_id, cycle_key, region_id, settled_level_no, + settled_salary_usd_minor, last_settled_income_usd_minor, last_policy_id, + month_closed_at_ms, version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 1, ?, ?)`, + appCode, candidate.PolicyType, candidate.UserID, candidate.CycleKey, candidate.RegionID, nowMS, nowMS, + ); err != nil { + return teamProgress{}, err + } + var progress teamProgress + if err := tx.QueryRowContext(ctx, ` + SELECT settled_level_no, settled_salary_usd_minor, last_settled_income_usd_minor, + last_policy_id, month_closed_at_ms, version + FROM team_salary_settlement_progress + WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, appCode, candidate.PolicyType, candidate.UserID, candidate.CycleKey, + ).Scan(&progress.SettledLevelNo, &progress.SettledSalaryUSDMinor, &progress.LastSettledIncomeUSDMinor, &progress.LastPolicyID, &progress.MonthClosedAtMS, &progress.Version); err != nil { + return teamProgress{}, err + } + return progress, nil +} + +// updateProgress 使用 version 乐观校验,防止事务外或异常并发修改覆盖已提交进度。 +func (s *Service) updateProgress(ctx context.Context, tx *sql.Tx, appCode string, candidate teamCandidate, progress teamProgress, nowMS int64) error { + result, err := tx.ExecContext(ctx, ` + UPDATE team_salary_settlement_progress + SET region_id = ?, + settled_level_no = ?, + settled_salary_usd_minor = ?, + last_settled_income_usd_minor = ?, + last_policy_id = ?, + month_closed_at_ms = ?, + version = version + 1, + updated_at_ms = ? + WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ? AND version = ?`, + candidate.RegionID, + progress.SettledLevelNo, + progress.SettledSalaryUSDMinor, + progress.LastSettledIncomeUSDMinor, + progress.LastPolicyID, + progress.MonthClosedAtMS, + nowMS, + appCode, + candidate.PolicyType, + candidate.UserID, + candidate.CycleKey, + progress.Version, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("team salary settlement progress version conflict") + } + return nil +} + +// insertWalletTransaction 写钱包交易主表,command_id 是业务幂等键。 +func (s *Service) insertWalletTransaction(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, metadata settlementMetadata, nowMS int64) error { + payload, err := json.Marshal(metadata) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO wallet_transactions ( + app_code, transaction_id, command_id, biz_type, status, request_hash, + external_ref, metadata_json, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)`, + appCode, transactionID, commandID, bizTypeTeamSettlement, stableHash(string(payload)), metadata.CycleKey, string(payload), nowMS, nowMS, + ) + return err +} + +// creditWallet 锁定美元账户并追加 wallet_entries,使余额、流水和版本在同一事务内一致。 +func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, userID int64, amount int64, nowMS int64) (int64, int64, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO wallet_accounts ( + app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, appCode, userID, assetUSDBalance, nowMS, nowMS); err != nil { + return 0, 0, err + } + var available int64 + var frozen int64 + var version int64 + if err := tx.QueryRowContext(ctx, ` + SELECT available_amount, frozen_amount, version + FROM wallet_accounts + WHERE app_code = ? AND user_id = ? AND asset_type = ? + FOR UPDATE`, appCode, userID, assetUSDBalance, + ).Scan(&available, &frozen, &version); err != nil { + return 0, 0, err + } + after := available + amount + if after < 0 { + return 0, 0, fmt.Errorf("team salary credit overflow") + } + // 工资只会正向入账;version 条件仍保留,和其它钱包写入路径保持并发语义一致。 + if _, err := tx.ExecContext(ctx, ` + UPDATE wallet_accounts + SET available_amount = ?, version = version + 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`, + after, nowMS, appCode, userID, assetUSDBalance, version, + ); err != nil { + return 0, 0, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO wallet_entries ( + app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta, + available_after, frozen_after, counterparty_user_id, room_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, 0, '', ?)`, + appCode, transactionID, userID, assetUSDBalance, amount, after, frozen, nowMS, + ); err != nil { + return 0, 0, err + } + return after, version + 1, nil +} + +// insertWalletOutbox 投递余额变更事件,供后续 MQ/通知链路异步消费。 +func (s *Service) insertWalletOutbox(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, userID int64, amount int64, balanceAfter int64, version int64, metadata settlementMetadata, nowMS int64) error { + payload, err := json.Marshal(map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": userID, + "asset_type": assetUSDBalance, + "available_delta": amount, + "available_after": balanceAfter, + "version": version, + "metadata": metadata, + "created_at_ms": nowMS, + }) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO wallet_outbox ( + app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, payload, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?)`, + appCode, transactionID+":"+assetUSDBalance, transactionID, commandID, userID, assetUSDBalance, amount, string(payload), nowMS, nowMS, + ) + return err +} + +// insertRecord 写业务结算记录,是后台查询 settlement_id 的权威来源。 +func (s *Service) insertRecord(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, metadata settlementMetadata, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO team_salary_settlement_records ( + app_code, settlement_id, command_id, transaction_id, policy_type, trigger_mode, + user_id, cycle_key, region_id, policy_id, level_no, income_usd_minor, + salary_usd_minor_delta, status, reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', '', ?)`, + appCode, + teamSalarySettlementID(appCode, commandID), + commandID, + transactionID, + metadata.PolicyType, + metadata.TriggerMode, + metadata.UserID, + metadata.CycleKey, + metadata.RegionID, + metadata.PolicyID, + metadata.LevelNo, + metadata.IncomeUSDMinor, + metadata.SalaryUSDMinorDelta, + nowMS, + ) + return err +} + +// recordWhere 统一构造记录查询条件,避免列表 API 和后续导出 API 出现筛选口径差异。 +func (s *Service) recordWhere(ctx context.Context, appCode string, req recordQuery) (string, []any, error) { + conditions := []string{"app_code = ?"} + args := []any{appCode} + if req.PolicyType != "" { + conditions = append(conditions, "policy_type = ?") + args = append(args, req.PolicyType) + } + if req.TriggerMode != "" { + conditions = append(conditions, "trigger_mode = ?") + args = append(args, req.TriggerMode) + } + if req.CycleKey != "" { + conditions = append(conditions, "cycle_key = ?") + args = append(args, req.CycleKey) + } + if req.Status != "" { + conditions = append(conditions, "status = ?") + args = append(args, req.Status) + } + if req.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, req.UserID) + } + if req.PolicyID > 0 { + conditions = append(conditions, "policy_id = ?") + args = append(args, req.PolicyID) + } + if req.RegionID > 0 { + conditions = append(conditions, "region_id = ?") + args = append(args, req.RegionID) + } else if req.CountryID > 0 || req.CountryCode != "" { + regions, err := s.regionFilter(ctx, appCode, 0, req.CountryID, req.CountryCode) + if err != nil { + return "", nil, err + } + if len(regions) == 0 { + conditions = append(conditions, "region_id = -1") + } else { + ids := keysInt64FromSet(regions) + conditions = append(conditions, "region_id IN ("+placeholders(len(ids))+")") + for _, id := range ids { + args = append(args, id) + } + } + } + if req.StartAtMS > 0 { + conditions = append(conditions, "created_at_ms >= ?") + args = append(args, req.StartAtMS) + } + if req.EndAtMS > 0 { + conditions = append(conditions, "created_at_ms < ?") + args = append(args, req.EndAtMS) + } + return "WHERE " + strings.Join(conditions, " AND "), args, nil +} + +// regionFilter 把区域、国家 ID、国家代码归一为 region_id 集合;region_id 直传时优先级最高。 +func (s *Service) regionFilter(ctx context.Context, appCode string, regionID int64, countryID int64, countryCode string) (map[int64]struct{}, error) { + if regionID > 0 { + return map[int64]struct{}{regionID: {}}, nil + } + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if countryID > 0 && countryCode == "" { + if err := s.userDB.QueryRowContext(ctx, `SELECT country_code FROM countries WHERE app_code = ? AND country_id = ?`, appCode, countryID).Scan(&countryCode); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return map[int64]struct{}{}, nil + } + return nil, err + } + } + if countryCode == "" { + return nil, nil + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT region_id + FROM region_countries + WHERE app_code = ? AND country_code = ? AND status = 'active'`, appCode, countryCode) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]struct{}{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = struct{}{} + } + return out, rows.Err() +} + +// regionNames 批量读取区域名称,只服务后台展示,不参与任何金额计算。 +func (s *Service) regionNames(ctx context.Context, appCode string) (map[int64]string, error) { + rows, err := s.userDB.QueryContext(ctx, `SELECT region_id, name FROM regions WHERE app_code = ?`, appCode) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]string{} + for rows.Next() { + var id int64 + var name string + if err := rows.Scan(&id, &name); err != nil { + return nil, err + } + out[id] = name + } + return out, rows.Err() +} + +// userProfiles 批量读取用户展示信息;缺失用户资料时上层会保留 user_id。 +func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { + userIDs = uniquePositiveUserIDs(userIDs) + if len(userIDs) == 0 { + return map[int64]userProfile{}, nil + } + args := make([]any, 0, len(userIDs)+1) + args = append(args, appCode) + for _, id := range userIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') + FROM users + WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]userProfile{} + for rows.Next() { + var item userProfile + if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil { + return nil, err + } + out[item.UserID] = item + } + return out, rows.Err() +} + +// ensureReady 做运行时自检并补齐 wallet 运行表;本地测试可以直接跑,线上仍以标准迁移为准。 +func (s *Service) ensureReady(ctx context.Context) error { + if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil { + return fmt.Errorf("team salary settlement dependencies are not configured") + } + // 运行表属于 wallet 账本库;admin-server 在本地和测试环境按需补表,线上仍应由标准 DDL 发布流程先执行 initdb/migration。 + for _, ddl := range teamSalaryRuntimeDDL() { + if _, err := s.walletDB.ExecContext(ctx, ddl); err != nil { + return err + } + } + return nil +} + +// teamSalaryRuntimeDDL 返回 wallet 侧团队工资运行表 DDL;保持与 wallet-service initdb 的结构一致。 +func teamSalaryRuntimeDDL() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS team_salary_settlement_progress ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + policy_type VARCHAR(24) NOT NULL, + user_id BIGINT NOT NULL, + cycle_key VARCHAR(16) NOT NULL, + region_id BIGINT NOT NULL, + settled_level_no INT NOT NULL DEFAULT 0, + settled_salary_usd_minor BIGINT NOT NULL DEFAULT 0, + last_settled_income_usd_minor BIGINT NOT NULL DEFAULT 0, + last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + month_closed_at_ms BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 1, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, policy_type, user_id, cycle_key), + KEY idx_team_salary_progress_cycle (app_code, policy_type, cycle_key, region_id, month_closed_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS team_salary_settlement_records ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + settlement_id VARCHAR(128) NOT NULL, + command_id VARCHAR(128) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + policy_type VARCHAR(24) NOT NULL, + trigger_mode VARCHAR(24) NOT NULL DEFAULT 'manual', + user_id BIGINT NOT NULL, + cycle_key VARCHAR(16) NOT NULL, + region_id BIGINT NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + level_no INT NOT NULL DEFAULT 0, + income_usd_minor BIGINT NOT NULL DEFAULT 0, + salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + status VARCHAR(24) NOT NULL DEFAULT 'succeeded', + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, settlement_id), + UNIQUE KEY uk_team_salary_settlement_command (app_code, command_id), + KEY idx_team_salary_records_user_cycle (app_code, policy_type, user_id, cycle_key, created_at_ms), + KEY idx_team_salary_records_cycle (app_code, policy_type, cycle_key, region_id, created_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + } +} + +// pendingDTOFromCandidate 将内部候选转成前端 DTO,同时保留已结进度方便运营判断为什么只发差值。 +func pendingDTOFromCandidate(item teamCandidate, profile userProfile) pendingDTO { + userID := strconv.FormatInt(item.UserID, 10) + return pendingDTO{ + PolicyType: item.PolicyType, + UserID: userID, + User: userDTOFromProfile(userID, profile), + CycleKey: item.CycleKey, + RegionID: item.RegionID, + RegionName: item.RegionName, + PolicyID: item.Policy.PolicyID, + PolicyName: item.Policy.Name, + LevelNo: int32(item.Level.LevelNo), + IncomeUSDMinor: item.IncomeUSDMinor, + IncomeUSD: formatUSDMinor(item.IncomeUSDMinor), + SalaryUSDMinorTarget: item.Level.SalaryMinor, + SalaryUSDTarget: formatUSDMinor(item.Level.SalaryMinor), + SalaryUSDMinorDelta: item.SalaryUSDMinorDelta, + SalaryUSDDelta: formatUSDMinor(item.SalaryUSDMinorDelta), + SettledLevelNo: int32(item.Progress.SettledLevelNo), + SettledSalaryUSDMinor: item.Progress.SettledSalaryUSDMinor, + SettledSalaryUSD: formatUSDMinor(item.Progress.SettledSalaryUSDMinor), + LastSettledIncomeUSDMinor: item.Progress.LastSettledIncomeUSDMinor, + LastSettledIncomeUSD: formatUSDMinor(item.Progress.LastSettledIncomeUSDMinor), + MonthClosedAtMS: item.Progress.MonthClosedAtMS, + } +} + +// normalizePendingQuery 收敛列表参数默认值;默认看上一个完整月,符合 BD/Admin 次月 5 日结算口径。 +func normalizePendingQuery(req pendingQuery) pendingQuery { + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) + if req.CycleKey == "" { + req.CycleKey = previousMonthCycleKey(time.Now().UTC()) + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 50 + } + if req.PageSize > maxPageSize { + req.PageSize = maxPageSize + } + return req +} + +// normalizeRecordQuery 收敛记录查询参数,非法枚举由 handler 提前报错,这里只做兜底。 +func normalizeRecordQuery(req recordQuery) recordQuery { + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + req.Status = normalizeStatus(req.Status) + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 50 + } + if req.PageSize > maxPageSize { + req.PageSize = maxPageSize + } + return req +} + +// normalizePolicyType 只允许 bd/admin/all,未知值归空让 handler 能统一判断是否非法。 +func normalizePolicyType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case policyTypeBD: + return policyTypeBD + case policyTypeAdmin: + return policyTypeAdmin + default: + return "" + } +} + +// normalizeTriggerMode 只允许 automatic/manual/all,防止手写字符串绕过政策触发方式匹配。 +func normalizeTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case triggerAutomatic: + return triggerAutomatic + case triggerManual: + return triggerManual + default: + return "" + } +} + +// normalizeStatus 只允许记录表定义的状态值,避免前端筛选拼接任意 SQL 条件。 +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "succeeded", "skipped", "failed": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +// highestTeamLevel 按收入基数找最高已达档;未达任何档时返回零值,后续差值自然为 0。 +func highestTeamLevel(levels []teamPolicyLevel, incomeMinor int64) teamPolicyLevel { + var selected teamPolicyLevel + for _, level := range levels { + if level.ThresholdMinor <= incomeMinor { + selected = level + } + } + return selected +} + +// usdStringToMinor 把 DECIMAL 字符串转成美分整数,所有工资差值都用整数计算。 +func usdStringToMinor(value string) (int64, error) { + rat, ok := new(big.Rat).SetString(strings.TrimSpace(value)) + if !ok || rat.Sign() < 0 { + return 0, fmt.Errorf("invalid usd amount: %s", value) + } + rat.Mul(rat, big.NewRat(100, 1)) + minor := new(big.Int).Quo(rat.Num(), rat.Denom()) + if !minor.IsInt64() || minor.Int64() > math.MaxInt64 { + return 0, fmt.Errorf("usd amount overflow: %s", value) + } + return minor.Int64(), nil +} + +// previousMonthCycleKey 以 UTC 自然月为结算周期,和 wallet Host 周期键保持同一口径。 +func previousMonthCycleKey(now time.Time) string { + return time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -1, 0).Format("2006-01") +} + +// teamSalaryCommandID 是团队工资业务幂等 ID,编码维度覆盖角色、触发方式、周期、用户、等级和收入基数。 +func teamSalaryCommandID(candidate teamCandidate, triggerMode string, progress teamProgress) string { + return fmt.Sprintf("team_salary:%s:%s:%s:%d:%d:%d", candidate.PolicyType, triggerMode, candidate.CycleKey, candidate.UserID, progress.SettledLevelNo, candidate.IncomeUSDMinor) +} + +// teamSalarySettlementID 从 commandID 派生稳定记录 ID,方便重复请求定位到同一业务动作。 +func teamSalarySettlementID(appCode string, commandID string) string { + return fmt.Sprintf("tss_%s_%s", strings.TrimSpace(appCode), stableHash(commandID)) +} + +// teamSalaryTransactionID 从 commandID 派生 wallet 交易 ID,保证记录和钱包交易可双向关联。 +func teamSalaryTransactionID(appCode string, commandID string) string { + return fmt.Sprintf("wtx_%s_%s", strings.TrimSpace(appCode), stableHash(commandID)) +} + +// stableHash 使用 SHA-256 压缩长幂等键,避免 MySQL varchar 索引过长。 +func stableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// teamCandidateKey 把用户和区域组合成 map key,BD/Admin 同一用户跨区域时必须分别匹配政策。 +func teamCandidateKey(userID int64, regionID int64) string { + return strconv.FormatInt(userID, 10) + ":" + strconv.FormatInt(regionID, 10) +} + +// parseTeamCandidateKey 反解 teamCandidateKey;异常 key 返回零值,后续不会命中有效政策。 +func parseTeamCandidateKey(key string) (int64, int64) { + parts := strings.Split(key, ":") + if len(parts) != 2 { + return 0, 0 + } + userID, _ := strconv.ParseInt(parts[0], 10, 64) + regionID, _ := strconv.ParseInt(parts[1], 10, 64) + return userID, regionID +} + +// sortTeamCandidates 让后台列表稳定:先角色、区域,再按收入从高到低,最后按用户 ID。 +func sortTeamCandidates(items []teamCandidate) { + sort.Slice(items, func(i, j int) bool { + if items[i].PolicyType != items[j].PolicyType { + return items[i].PolicyType < items[j].PolicyType + } + if items[i].RegionID != items[j].RegionID { + return items[i].RegionID < items[j].RegionID + } + if items[i].IncomeUSDMinor != items[j].IncomeUSDMinor { + return items[i].IncomeUSDMinor > items[j].IncomeUSDMinor + } + return items[i].UserID < items[j].UserID + }) +} + +// paginateCandidates 在内存候选上分页;候选已被区域/国家/角色过滤,避免 SQL 里重复复杂聚合分页。 +func paginateCandidates(items []teamCandidate, page int, pageSize int) []teamCandidate { + start := offset(page, pageSize) + if start >= len(items) { + return []teamCandidate{} + } + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +// candidateUserIDs 提取候选用户 ID,用于一次性加载展示资料。 +func candidateUserIDs(items []teamCandidate) []int64 { + out := make([]int64, 0, len(items)) + for _, item := range items { + out = append(out, item.UserID) + } + return out +} + +// keysInt64 将 map key 稳定排序,保证 SQL IN 参数顺序和测试输出稳定。 +func keysInt64(values map[int64]int64) []int64 { + out := make([]int64, 0, len(values)) + for key := range values { + out = append(out, key) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// keysInt64FromSet 将 set key 稳定排序,主要用于国家转区域后的记录查询条件。 +func keysInt64FromSet(values map[int64]struct{}) []int64 { + out := make([]int64, 0, len(values)) + for key := range values { + out = append(out, key) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// uniquePositiveUserIDs 去重并过滤无效用户 ID,避免动态 IN () 和重复 profile 查询。 +func uniquePositiveUserIDs(ids []int64) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// userDTOFromProfile 在资料缺失时仍返回 fallback user_id,保证结算记录永远可识别收款用户。 +func userDTOFromProfile(fallbackID string, profile userProfile) userDTO { + if profile.UserID <= 0 { + return userDTO{UserID: fallbackID} + } + return userDTO{ + UserID: strconv.FormatInt(profile.UserID, 10), + DisplayUserID: profile.DisplayUserID, + Username: profile.Username, + Avatar: profile.Avatar, + } +} + +// formatUSDMinor 只负责后端 DTO 展示格式,账务计算不使用格式化字符串。 +func formatUSDMinor(value int64) string { + sign := "" + if value < 0 { + sign = "-" + value = -value + } + whole := value / 100 + fraction := value % 100 + if fraction == 0 { + return sign + strconv.FormatInt(whole, 10) + } + if fraction < 10 { + return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10) + } + return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10) +} + +// placeholders 生成固定数量的 SQL 占位符,调用方已保证 n > 0。 +func placeholders(n int) string { + return strings.TrimRight(strings.Repeat("?,", n), ",") +} + +// offset 把页码转换为 LIMIT offset;第一页和异常页码都回到 0。 +func offset(page int, pageSize int) int { + if page <= 1 { + return 0 + } + return (page - 1) * pageSize +} + +// positiveDelta 是所有差值发薪的最终公式:目标累计 <= 已发累计时不再发钱。 +func positiveDelta(target int64, current int64) int64 { + if target <= current { + return 0 + } + return target - current +} + +// maxInt 防止进度等级被旧候选覆盖回退。 +func maxInt(left int, right int) int { + if right > left { + return right + } + return left +} + +// maxInt64 防止已发金额或收入基数被旧候选覆盖回退。 +func maxInt64(left int64, right int64) int64 { + if right > left { + return right + } + return left +} diff --git a/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go b/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go new file mode 100644 index 00000000..6dfd45ef --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go @@ -0,0 +1,381 @@ +package teamsalarysettlement + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + mysqlDriver "github.com/go-sql-driver/mysql" +) + +func TestRealMySQLTeamSalaryFullFlow(t *testing.T) { + baseDSN := strings.TrimSpace(os.Getenv("TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN")) + if baseDSN == "" { + t.Skip("set TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN to run the real MySQL BD/Admin salary flow") + } + ctx := context.Background() + adminDB := newTeamSalaryTestDB(t, baseDSN, "admin") + userDB := newTeamSalaryTestDB(t, baseDSN, "user") + walletDB := newTeamSalaryTestDB(t, baseDSN, "wallet") + execAll(t, adminDB, teamSalaryAdminDDL()...) + execAll(t, userDB, teamSalaryUserDDL()...) + execAll(t, walletDB, teamSalaryWalletDDL()...) + seedTeamSalaryFlow(t, adminDB, userDB, walletDB) + + service := NewService(adminDB, walletDB, userDB) + pendingBD, totalBD, err := service.ListPending(ctx, "lalu", pendingQuery{ + PolicyType: policyTypeBD, + TriggerMode: triggerAutomatic, + CycleKey: "2026-01", + }) + if err != nil { + t.Fatalf("list bd pending failed: %v", err) + } + if totalBD != 1 || len(pendingBD) != 1 { + t.Fatalf("bd pending count mismatch: total=%d len=%d", totalBD, len(pendingBD)) + } + if pendingBD[0].IncomeUSDMinor != 1_000_000 || pendingBD[0].SalaryUSDMinorDelta != 100_000 { + t.Fatalf("bd pending amount mismatch: income=%d salary_delta=%d", pendingBD[0].IncomeUSDMinor, pendingBD[0].SalaryUSDMinorDelta) + } + + firstRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 8, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("first automatic settlement failed: %v", err) + } + if !firstRun.Due || firstRun.CycleKey != "2026-01" { + t.Fatalf("automatic due state mismatch: due=%v cycle=%s", firstRun.Due, firstRun.CycleKey) + } + if firstRun.Results[policyTypeBD].SuccessCount != 1 || firstRun.Results[policyTypeAdmin].SuccessCount != 1 { + t.Fatalf("first settlement success mismatch: bd=%+v admin=%+v", firstRun.Results[policyTypeBD], firstRun.Results[policyTypeAdmin]) + } + + secondRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 9, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("second automatic settlement failed: %v", err) + } + if secondRun.Results[policyTypeBD].SuccessCount != 0 || secondRun.Results[policyTypeBD].SkippedCount != 1 { + t.Fatalf("bd duplicate guard mismatch: %+v", secondRun.Results[policyTypeBD]) + } + if secondRun.Results[policyTypeAdmin].SuccessCount != 0 || secondRun.Results[policyTypeAdmin].SkippedCount != 1 { + t.Fatalf("admin duplicate guard mismatch: %+v", secondRun.Results[policyTypeAdmin]) + } + + records, totalRecords, err := service.ListRecords(ctx, "lalu", recordQuery{CycleKey: "2026-01"}) + if err != nil { + t.Fatalf("list team records failed: %v", err) + } + if totalRecords != 2 || len(records) != 2 { + t.Fatalf("record count mismatch: total=%d len=%d records=%+v", totalRecords, len(records), records) + } + assertWalletBalance(t, walletDB, 4001, 100_000) + assertWalletBalance(t, walletDB, 5001, 20_000) + assertScalar(t, walletDB, `SELECT COUNT(*) FROM team_salary_settlement_records`, int64(2)) + assertScalar(t, walletDB, `SELECT COUNT(*) FROM wallet_transactions WHERE biz_type = 'team_salary_settlement'`, int64(2)) + + notDue, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 4, 8, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("non-due automatic check failed: %v", err) + } + if notDue.Due { + t.Fatalf("non-due day should not run settlement") + } +} + +func newTeamSalaryTestDB(t *testing.T, baseDSN string, suffix string) *sql.DB { + t.Helper() + cfg, err := mysqlDriver.ParseDSN(baseDSN) + if err != nil { + t.Fatalf("parse test dsn failed: %v", err) + } + dbName := fmt.Sprintf("hy_team_salary_%s_%d", suffix, time.Now().UnixNano()) + adminCfg := cfg.Clone() + adminCfg.DBName = "" + adminDB, err := sql.Open("mysql", adminCfg.FormatDSN()) + if err != nil { + t.Fatalf("open mysql admin connection failed: %v", err) + } + if _, err := adminDB.Exec("CREATE DATABASE " + quoteTeamSalaryIdentifier(dbName) + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + _ = adminDB.Close() + t.Fatalf("create test database failed: %v", err) + } + testCfg := cfg.Clone() + testCfg.DBName = dbName + db, err := sql.Open("mysql", testCfg.FormatDSN()) + if err != nil { + _ = adminDB.Close() + t.Fatalf("open test database failed: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + _, _ = adminDB.Exec("DROP DATABASE IF EXISTS " + quoteTeamSalaryIdentifier(dbName)) + _ = adminDB.Close() + }) + return db +} + +func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) { + t.Helper() + nowMS := int64(1_769_971_200_000) + execAll(t, adminDB, + `INSERT INTO admin_team_salary_policies + (id, app_code, policy_type, name, region_id, status, settlement_trigger_mode, effective_from_ms, effective_to_ms, description, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms) + VALUES + (7001, 'lalu', 'bd', 'BD automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1), + (8001, 'lalu', 'admin', 'Admin automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1)`, + `INSERT INTO admin_team_salary_policy_levels + (policy_id, level_no, threshold_usd, rate_percent, salary_usd, status, sort_order, created_at_ms, updated_at_ms) + VALUES + (7001, 1, 100.00, 8.0000, 8.00, 'active', 1, 1, 1), + (7001, 2, 200.00, 8.0000, 16.00, 'active', 2, 1, 1), + (7001, 3, 500.00, 8.0000, 40.00, 'active', 3, 1, 1), + (7001, 4, 1200.00, 8.0000, 96.00, 'active', 4, 1, 1), + (7001, 5, 2000.00, 8.0000, 160.00, 'active', 5, 1, 1), + (7001, 6, 5000.00, 9.0000, 450.00, 'active', 6, 1, 1), + (7001, 7, 10000.00, 10.0000, 1000.00, 'active', 7, 1, 1), + (8001, 1, 500.00, 20.0000, 100.00, 'active', 1, 1, 1), + (8001, 2, 1000.00, 20.0000, 200.00, 'active', 2, 1, 1), + (8001, 3, 3000.00, 20.0000, 600.00, 'active', 3, 1, 1), + (8001, 4, 5000.00, 20.0000, 1000.00, 'active', 4, 1, 1), + (8001, 5, 10000.00, 20.0000, 2000.00, 'active', 5, 1, 1), + (8001, 6, 20000.00, 20.0000, 4000.00, 'active', 6, 1, 1)`, + ) + execAll(t, userDB, + `INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`, + `INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`, + `INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`, + `INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar) VALUES + ('lalu', 3001, 'AG3001', 'Agency One', ''), + ('lalu', 3002, 'AG3002', 'Agency Two', ''), + ('lalu', 4001, 'BD4001', 'BD One', ''), + ('lalu', 5001, 'AD5001', 'Admin One', '')`, + `INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES + ('lalu', 3001, 4001, 686, 'active'), + ('lalu', 3002, 4001, 686, 'active')`, + `INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES + ('lalu', 4001, 'bd', 686, 5001, 'active'), + ('lalu', 5001, 'bd_leader', 686, 0, 'active')`, + ) + execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records + (app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, + cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, host_coin_reward_delta, + agency_salary_usd_minor_delta, residual_usd_minor_delta, status, reason, created_at_ms) + VALUES + ('lalu', 'hs_1', 'hcmd_1', 'htx_1', 'month_end', 1001, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_2', 'hcmd_2', 'htx_2', 'month_end', 1002, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_3', 'hcmd_3', 'htx_3', 'month_end', 1003, 3001, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_4', 'hcmd_4', 'htx_4', 'month_end', 1004, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_5', 'hcmd_5', 'htx_5', 'month_end', 1005, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_6', 'hcmd_6', 'htx_6', 'month_end', 1006, 3002, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d)`, nowMS, nowMS, nowMS, nowMS, nowMS, nowMS)) +} + +func teamSalaryAdminDDL() []string { + return []string{ + `CREATE TABLE admin_team_salary_policies ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + policy_type VARCHAR(24) NOT NULL, + name VARCHAR(120) NOT NULL, + region_id BIGINT NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'disabled', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic', + effective_from_ms BIGINT NOT NULL DEFAULT 0, + effective_to_ms BIGINT NOT NULL DEFAULT 0, + description VARCHAR(255) NOT NULL DEFAULT '', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + INDEX idx_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms) + )`, + `CREATE TABLE admin_team_salary_policy_levels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + policy_id BIGINT UNSIGNED NOT NULL, + level_no INT NOT NULL, + threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00, + rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000, + salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00, + status VARCHAR(24) NOT NULL DEFAULT 'active', + sort_order INT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + INDEX idx_policy_level (policy_id, threshold_usd) + )`, + } +} + +func teamSalaryUserDDL() []string { + return []string{ + `CREATE TABLE users ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + current_display_user_id VARCHAR(64) NOT NULL DEFAULT '', + username VARCHAR(128) NULL, + avatar VARCHAR(512) NULL, + PRIMARY KEY (app_code, user_id) + )`, + `CREATE TABLE regions ( + app_code VARCHAR(32) NOT NULL, + region_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + PRIMARY KEY (app_code, region_id) + )`, + `CREATE TABLE countries ( + app_code VARCHAR(32) NOT NULL, + country_id BIGINT NOT NULL, + country_code VARCHAR(16) NOT NULL, + name VARCHAR(128) NOT NULL, + PRIMARY KEY (app_code, country_id) + )`, + `CREATE TABLE region_countries ( + app_code VARCHAR(32) NOT NULL, + region_id BIGINT NOT NULL, + country_code VARCHAR(16) NOT NULL, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, region_id, country_code) + )`, + `CREATE TABLE agencies ( + app_code VARCHAR(32) NOT NULL, + owner_user_id BIGINT NOT NULL, + parent_bd_user_id BIGINT NOT NULL DEFAULT 0, + region_id BIGINT NOT NULL, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, owner_user_id) + )`, + `CREATE TABLE bd_profiles ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(24) NOT NULL, + region_id BIGINT NOT NULL, + parent_leader_user_id BIGINT NOT NULL DEFAULT 0, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, user_id, role) + )`, + } +} + +func teamSalaryWalletDDL() []string { + return []string{ + `CREATE TABLE wallet_accounts ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + user_id BIGINT NOT NULL, + asset_type VARCHAR(32) NOT NULL, + available_amount BIGINT NOT NULL, + frozen_amount BIGINT NOT NULL, + version BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id, asset_type) + )`, + `CREATE TABLE wallet_transactions ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + transaction_id VARCHAR(96) NOT NULL, + command_id VARCHAR(128) NOT NULL, + biz_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + request_hash VARCHAR(128) NOT NULL, + external_ref VARCHAR(128) NOT NULL DEFAULT '', + metadata_json JSON NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, transaction_id), + UNIQUE KEY uk_wallet_tx_command (app_code, command_id) + )`, + `CREATE TABLE wallet_entries ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + transaction_id VARCHAR(96) NOT NULL, + user_id BIGINT NOT NULL, + asset_type VARCHAR(32) NOT NULL, + available_delta BIGINT NOT NULL, + frozen_delta BIGINT NOT NULL, + available_after BIGINT NOT NULL, + frozen_after BIGINT NOT NULL, + counterparty_user_id BIGINT NOT NULL DEFAULT 0, + room_id VARCHAR(96) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL + )`, + `CREATE TABLE wallet_outbox ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + command_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + asset_type VARCHAR(32) NOT NULL, + available_delta BIGINT NOT NULL, + frozen_delta BIGINT NOT NULL, + payload JSON NOT NULL, + status VARCHAR(32) NOT NULL, + worker_id VARCHAR(128) NOT NULL DEFAULT '', + lock_until_ms BIGINT NULL, + retry_count INT NOT NULL DEFAULT 0, + next_retry_at_ms BIGINT NULL, + last_error TEXT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id) + )`, + `CREATE TABLE host_salary_settlement_records ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + settlement_id VARCHAR(128) NOT NULL, + command_id VARCHAR(128) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + settlement_type VARCHAR(24) NOT NULL, + user_id BIGINT NOT NULL, + agency_owner_user_id BIGINT NOT NULL DEFAULT 0, + cycle_key VARCHAR(16) NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + level_no INT NOT NULL DEFAULT 0, + total_diamonds BIGINT NOT NULL DEFAULT 0, + host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + host_coin_reward_delta BIGINT NOT NULL DEFAULT 0, + agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + status VARCHAR(24) NOT NULL DEFAULT 'succeeded', + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, settlement_id), + UNIQUE KEY uk_host_command (app_code, command_id) + )`, + } +} + +func execAll(t *testing.T, db *sql.DB, statements ...string) { + t.Helper() + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("exec statement failed: %v\n%s", err, statement) + } + } +} + +func assertWalletBalance(t *testing.T, db *sql.DB, userID int64, expected int64) { + t.Helper() + var amount int64 + if err := db.QueryRow(`SELECT available_amount FROM wallet_accounts WHERE app_code = 'lalu' AND user_id = ? AND asset_type = 'USD_BALANCE'`, userID).Scan(&amount); err != nil { + t.Fatalf("query wallet balance failed: %v", err) + } + if amount != expected { + t.Fatalf("wallet balance mismatch for user %d: got=%d want=%d", userID, amount, expected) + } +} + +func assertScalar(t *testing.T, db *sql.DB, query string, expected int64) { + t.Helper() + var got int64 + if err := db.QueryRow(query).Scan(&got); err != nil { + t.Fatalf("query scalar failed: %v", err) + } + if got != expected { + t.Fatalf("scalar mismatch for %s: got=%d want=%d", query, got, expected) + } +} + +func quoteTeamSalaryIdentifier(value string) string { + return "`" + strings.ReplaceAll(value, "`", "``") + "`" +} diff --git a/server/admin/internal/repository/auth_repository.go b/server/admin/internal/repository/auth_repository.go index 574220c9..c3f388b0 100644 --- a/server/admin/internal/repository/auth_repository.go +++ b/server/admin/internal/repository/auth_repository.go @@ -3,6 +3,8 @@ package repository import ( "hyapp-admin-server/internal/model" "time" + + "gorm.io/gorm" ) func (s *Store) FindUserByUsername(username string) (*model.User, error) { @@ -53,6 +55,18 @@ func (s *Store) CreateRefreshToken(token model.RefreshToken) error { return s.db.Create(&token).Error } +func (s *Store) RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error { + nowMS := time.Now().UTC().UnixMilli() + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&token).Error; err != nil { + return err + } + return tx.Model(&model.RefreshToken{}). + Where("id = ? AND revoked_at_ms IS NULL", oldTokenID). + Update("revoked_at_ms", &nowMS).Error + }) +} + func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) { var token model.RefreshToken err := s.db.Where("token_hash = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?", hash, time.Now().UTC().UnixMilli()).First(&token).Error diff --git a/server/admin/internal/repository/host_agency_salary_policy_repository.go b/server/admin/internal/repository/host_agency_salary_policy_repository.go new file mode 100644 index 00000000..3793c402 --- /dev/null +++ b/server/admin/internal/repository/host_agency_salary_policy_repository.go @@ -0,0 +1,154 @@ +package repository + +import ( + "strings" + + "hyapp-admin-server/internal/model" + + "gorm.io/gorm" +) + +type HostAgencySalaryPolicyListOptions struct { + AppCode string + Keyword string + RegionID int64 + Status string + SettlementMode string + SettlementTriggerMode string + Page int + PageSize int +} + +func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListOptions) ([]model.HostAgencySalaryPolicy, int64, error) { + // 工资政策是应用隔离配置,所有查询都必须先限定 app_code,再叠加运营筛选条件。 + db := s.db.Model(&model.HostAgencySalaryPolicy{}).Where("app_code = ?", strings.TrimSpace(options.AppCode)) + if options.RegionID > 0 { + // 第一阶段仍按单区域保存,区域筛选直接命中主表索引。 + db = db.Where("region_id = ?", options.RegionID) + } + if status := strings.TrimSpace(options.Status); status != "" { + db = db.Where("status = ?", status) + } + if mode := strings.TrimSpace(options.SettlementMode); mode != "" { + db = db.Where("settlement_mode = ?", mode) + } + if triggerMode := strings.TrimSpace(options.SettlementTriggerMode); triggerMode != "" { + // 触发方式是自动任务和人工结算入口的分界,列表需要能直接筛出需要人工处理的政策。 + db = db.Where("settlement_trigger_mode = ?", triggerMode) + } + if keyword := strings.TrimSpace(options.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + page, pageSize := normalizePage(options.Page, options.PageSize) + var items []model.HostAgencySalaryPolicy + // 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。 + err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + return tx.Order("level_no ASC") + }).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error + return items, total, err +} + +func (s *Store) GetHostAgencySalaryPolicy(appCode string, id uint) (model.HostAgencySalaryPolicy, error) { + var item model.HostAgencySalaryPolicy + // 单条读取也预加载等级,更新时会用完整等级包替换原明细。 + err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + return tx.Order("level_no ASC") + }).Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error + return item, err +} + +func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // GORM 创建主表时不让它自动级联 levels;先拿到 policy_id,再批量写明细,约束更直观。 + levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Create(item).Error; err != nil { + return err + } + for index := range levels { + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + // 恢复内存对象,service 可以把创建后的完整对象直接转成响应。 + item.Levels = levels + return nil + }) +} + +func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 等级是整包配置,不做逐条 diff;主表保存成功后删除旧明细,再插入新梯度。 + levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Save(item).Error; err != nil { + return err + } + if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil { + return err + } + for index := range levels { + levels[index].ID = 0 + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + // 重新挂回最新明细,保证返回给前端的是本次提交后的等级集。 + item.Levels = levels + return nil + }) +} + +func (s *Store) UpdateHostAgencySalaryPolicyPublishState(appCode string, id uint, status string, publishError string, publishedAtMS int64, actorID uint) error { + // 发布状态属于 admin 配置到 wallet 运行快照的同步结果,不影响政策主配置字段和等级明细。 + return s.db.Model(&model.HostAgencySalaryPolicy{}). + Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id). + Updates(map[string]any{ + "publish_status": strings.TrimSpace(status), + "publish_error": strings.TrimSpace(publishError), + "published_at_ms": publishedAtMS, + "published_by_admin_id": actorID, + "updated_by_admin_id": actorID, + }).Error +} + +func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 先删明细再删主表,兼容没有外键级联的部署环境。 + if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil { + return err + } + return tx.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.HostAgencySalaryPolicy{}).Error + }) +} + +func (s *Store) HasOverlappingActiveHostAgencySalaryPolicy(appCode string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) { + // 与当前政策开始时间相交:已有长期有效或已有结束时间晚于当前开始。 + db := s.db.Model(&model.HostAgencySalaryPolicy{}). + Where("app_code = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), regionID, "active"). + Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS) + if effectiveToMS > 0 { + // 当前政策不是长期有效时,再要求已有政策开始时间早于当前结束,形成标准半开区间相交判断。 + db = db.Where("effective_from_ms < ?", effectiveToMS) + } + if excludeID > 0 { + db = db.Where("id <> ?", excludeID) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index f4ecf0ca..8b630098 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -66,6 +66,10 @@ func (s *Store) AutoMigrate() error { &model.AppBanner{}, &model.AppVersion{}, &model.AppExploreTab{}, + &model.HostAgencySalaryPolicy{}, + &model.HostAgencySalaryLevel{}, + &model.TeamSalaryPolicy{}, + &model.TeamSalaryLevel{}, &model.RefreshToken{}, &model.LoginLog{}, &model.OperationLog{}, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index ef5470a6..8bd8e9a3 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -63,6 +63,17 @@ var defaultPermissions = []model.Permission{ {Name: "区域更新", Code: "region:update", Kind: "button"}, {Name: "区域状态", Code: "region:status", Kind: "button"}, {Name: "主播查看", Code: "host:view", Kind: "menu"}, + {Name: "主播代理工资政策查看", Code: "host-agency-policy:view", Kind: "menu"}, + {Name: "主播代理工资政策创建", Code: "host-agency-policy:create", Kind: "button"}, + {Name: "主播代理工资政策更新", Code: "host-agency-policy:update", Kind: "button"}, + {Name: "主播代理工资政策删除", Code: "host-agency-policy:delete", Kind: "button"}, + {Name: "主播代理工资政策发布", Code: "host-agency-policy:publish", Kind: "button"}, + {Name: "BD/Admin 工资政策查看", Code: "team-salary-policy:view", Kind: "button"}, + {Name: "BD/Admin 工资政策创建", Code: "team-salary-policy:create", Kind: "button"}, + {Name: "BD/Admin 工资政策更新", Code: "team-salary-policy:update", Kind: "button"}, + {Name: "BD/Admin 工资政策删除", Code: "team-salary-policy:delete", Kind: "button"}, + {Name: "工资结算查看", Code: "host-salary-settlement:view", Kind: "menu"}, + {Name: "工资手动结算", Code: "host-salary-settlement:settle", Kind: "button"}, {Name: "Agency 查看", Code: "agency:view", Kind: "menu"}, {Name: "Agency 创建", Code: "agency:create", Kind: "button"}, {Name: "Agency 状态", Code: "agency:status", Kind: "button"}, @@ -257,7 +268,9 @@ func (s *Store) seedMenus() error { {ParentID: &hostOrgID, Title: "BD Leader 列表", Code: "host-org-bd-leaders", Path: "/host/bd-leaders", Icon: "shield", PermissionCode: "bd:view", Sort: 82, Visible: true}, {ParentID: &hostOrgID, Title: "BD 列表", Code: "host-org-bds", Path: "/host/bds", Icon: "team", PermissionCode: "bd:view", Sort: 83, Visible: true}, {ParentID: &hostOrgID, Title: "Host 列表", Code: "host-org-hosts", Path: "/host/hosts", Icon: "users", PermissionCode: "host:view", Sort: 84, Visible: true}, - {ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 85, Visible: true}, + {ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true}, + {ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true}, + {ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true}, } for _, menu := range children { if _, err := s.syncDefaultMenu(s.db, menu); err != nil { @@ -480,7 +493,7 @@ func defaultRolePermissionCodes(code string) []string { "emoji-pack:view", "emoji-pack:create", "country:view", "country:create", "country:update", "country:status", "region:view", "region:create", "region:update", "region:status", - "host:view", + "host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle", "agency:view", "agency:create", "agency:status", "bd:view", "bd:create", "bd:update", "coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", @@ -498,7 +511,7 @@ func defaultRolePermissionCodes(code string) []string { "upload:create", } case "auditor": - return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"} + return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"} case "readonly": return []string{ "overview:view", @@ -520,6 +533,9 @@ func defaultRolePermissionCodes(code string) []string { "country:view", "region:view", "host:view", + "host-agency-policy:view", + "team-salary-policy:view", + "host-salary-settlement:view", "agency:view", "bd:view", "coin-seller:view", @@ -566,6 +582,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { "upload:create", "country:view", "country:create", "country:update", "country:status", "region:view", "region:create", "region:update", "region:status", + "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle", "coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", "lucky-gift:view", "lucky-gift:update", @@ -578,7 +595,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { "vip-config:view", "vip-config:update", "vip-config:grant", } case "auditor", "readonly": - return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"} + return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"} default: return nil } diff --git a/server/admin/internal/repository/team_salary_policy_repository.go b/server/admin/internal/repository/team_salary_policy_repository.go new file mode 100644 index 00000000..89492a04 --- /dev/null +++ b/server/admin/internal/repository/team_salary_policy_repository.go @@ -0,0 +1,132 @@ +package repository + +import ( + "strings" + + "hyapp-admin-server/internal/model" + + "gorm.io/gorm" +) + +type TeamSalaryPolicyListOptions struct { + AppCode string + PolicyType string + Keyword string + RegionID int64 + Status string + SettlementTriggerMode string + Page int + PageSize int +} + +func (s *Store) ListTeamSalaryPolicies(options TeamSalaryPolicyListOptions) ([]model.TeamSalaryPolicy, int64, error) { + // BD/Admin 政策共表保存,查询必须同时限定 app_code 和 policy_type,避免两个 tab 的配置互相混入。 + db := s.db.Model(&model.TeamSalaryPolicy{}). + Where("app_code = ? AND policy_type = ?", strings.TrimSpace(options.AppCode), strings.TrimSpace(options.PolicyType)) + if options.RegionID > 0 { + db = db.Where("region_id = ?", options.RegionID) + } + if status := strings.TrimSpace(options.Status); status != "" { + db = db.Where("status = ?", status) + } + if triggerMode := strings.TrimSpace(options.SettlementTriggerMode); triggerMode != "" { + db = db.Where("settlement_trigger_mode = ?", triggerMode) + } + if keyword := strings.TrimSpace(options.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + page, pageSize := normalizePage(options.Page, options.PageSize) + var items []model.TeamSalaryPolicy + err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + // 等级顺序是差额结算的业务顺序,列表和编辑都必须使用同一排序。 + return tx.Order("level_no ASC") + }).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error + return items, total, err +} + +func (s *Store) GetTeamSalaryPolicy(appCode string, policyType string, id uint) (model.TeamSalaryPolicy, error) { + var item model.TeamSalaryPolicy + err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + return tx.Order("level_no ASC") + }).Where("app_code = ? AND policy_type = ? AND id = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), id).First(&item).Error + return item, err +} + +func (s *Store) CreateTeamSalaryPolicy(item *model.TeamSalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 主表和等级整包保存,避免部分等级成功造成一套不可结算的政策梯度。 + levels := append([]model.TeamSalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Create(item).Error; err != nil { + return err + } + for index := range levels { + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + item.Levels = levels + return nil + }) +} + +func (s *Store) UpdateTeamSalaryPolicy(item *model.TeamSalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 编辑按整包替换等级,保证 UI 提交内容就是下一次结算读取的完整梯度。 + levels := append([]model.TeamSalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Save(item).Error; err != nil { + return err + } + if err := tx.Where("policy_id = ?", item.ID).Delete(&model.TeamSalaryLevel{}).Error; err != nil { + return err + } + for index := range levels { + levels[index].ID = 0 + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + item.Levels = levels + return nil + }) +} + +func (s *Store) DeleteTeamSalaryPolicy(appCode string, policyType string, id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("policy_id = ?", id).Delete(&model.TeamSalaryLevel{}).Error; err != nil { + return err + } + return tx.Where("app_code = ? AND policy_type = ? AND id = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), id).Delete(&model.TeamSalaryPolicy{}).Error + }) +} + +func (s *Store) HasOverlappingActiveTeamSalaryPolicy(appCode string, policyType string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) { + // 同一类型、同一区域、同一时间只能有一条启用政策,避免结算时 BD/Admin 可命中多套比例。 + db := s.db.Model(&model.TeamSalaryPolicy{}). + Where("app_code = ? AND policy_type = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), regionID, "active"). + Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS) + if effectiveToMS > 0 { + db = db.Where("effective_from_ms < ?", effectiveToMS) + } + if excludeID > 0 { + db = db.Where("id <> ?", excludeID) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index e5526e05..33db5cc3 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -17,7 +17,9 @@ import ( "hyapp-admin-server/internal/modules/firstrechargereward" gamemanagement "hyapp-admin-server/internal/modules/gamemanagement" "hyapp-admin-server/internal/modules/health" + "hyapp-admin-server/internal/modules/hostagencypolicy" "hyapp-admin-server/internal/modules/hostorg" + "hyapp-admin-server/internal/modules/hostsalarysettlement" "hyapp-admin-server/internal/modules/job" "hyapp-admin-server/internal/modules/levelconfig" "hyapp-admin-server/internal/modules/luckygift" @@ -33,6 +35,8 @@ import ( "hyapp-admin-server/internal/modules/roomtreasure" "hyapp-admin-server/internal/modules/search" "hyapp-admin-server/internal/modules/sevendaycheckin" + "hyapp-admin-server/internal/modules/teamsalarypolicy" + "hyapp-admin-server/internal/modules/teamsalarysettlement" "hyapp-admin-server/internal/modules/upload" "hyapp-admin-server/internal/modules/userleaderboard" "hyapp-admin-server/internal/modules/vipconfig" @@ -43,39 +47,43 @@ import ( ) type Handlers struct { - AdminUser *adminuser.Handler - AchievementConfig *achievementconfig.Handler - AppConfig *appconfig.Handler - AppRegistry *appregistry.Handler - AppUser *appuser.Handler - Audit *audit.Handler - Auth *authroutes.Handler - CoinLedger *coinledger.Handler - CountryRegion *countryregion.Handler - DailyTask *dailytask.Handler - Dashboard *dashboard.Handler - FirstRechargeReward *firstrechargereward.Handler - Game *gamemanagement.Handler - Health *health.Handler - HostOrg *hostorg.Handler - Job *job.Handler - LevelConfig *levelconfig.Handler - LuckyGift *luckygift.Handler - Menu *menu.Handler - Payment *payment.Handler - RBAC *rbac.Handler - RedPacket *redpacket.Handler - Report *reportmodule.Handler - RegistrationReward *registrationreward.Handler - RegionBlock *regionblock.Handler - Resource *resourcemodule.Handler - RoomAdmin *roomadmin.Handler - RoomTreasure *roomtreasure.Handler - Search *search.Handler - SevenDayCheckIn *sevendaycheckin.Handler - Upload *upload.Handler - UserLeaderboard *userleaderboard.Handler - VIPConfig *vipconfig.Handler + AdminUser *adminuser.Handler + AchievementConfig *achievementconfig.Handler + AppConfig *appconfig.Handler + AppRegistry *appregistry.Handler + AppUser *appuser.Handler + Audit *audit.Handler + Auth *authroutes.Handler + CoinLedger *coinledger.Handler + CountryRegion *countryregion.Handler + DailyTask *dailytask.Handler + Dashboard *dashboard.Handler + FirstRechargeReward *firstrechargereward.Handler + Game *gamemanagement.Handler + Health *health.Handler + HostAgencyPolicy *hostagencypolicy.Handler + HostOrg *hostorg.Handler + HostSalarySettlement *hostsalarysettlement.Handler + Job *job.Handler + LevelConfig *levelconfig.Handler + LuckyGift *luckygift.Handler + Menu *menu.Handler + Payment *payment.Handler + RBAC *rbac.Handler + RedPacket *redpacket.Handler + Report *reportmodule.Handler + RegistrationReward *registrationreward.Handler + RegionBlock *regionblock.Handler + Resource *resourcemodule.Handler + RoomAdmin *roomadmin.Handler + RoomTreasure *roomtreasure.Handler + Search *search.Handler + SevenDayCheckIn *sevendaycheckin.Handler + TeamSalaryPolicy *teamsalarypolicy.Handler + TeamSalarySettlement *teamsalarysettlement.Handler + Upload *upload.Handler + UserLeaderboard *userleaderboard.Handler + VIPConfig *vipconfig.Handler } func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { @@ -110,6 +118,8 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { roomadmin.RegisterRoutes(protected, h.RoomAdmin) roomtreasure.RegisterRoutes(protected, h.RoomTreasure) dashboard.RegisterRoutes(protected, h.Dashboard) + hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy) + hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement) hostorg.RegisterRoutes(protected, h.HostOrg) audit.RegisterRoutes(protected, h.Audit) payment.RegisterRoutes(protected, h.Payment) @@ -119,6 +129,8 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { upload.RegisterRoutes(protected, h.Upload) search.RegisterRoutes(protected, h.Search) sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn) + teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy) + teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement) userleaderboard.RegisterRoutes(protected, h.UserLeaderboard) vipconfig.RegisterRoutes(protected, h.VIPConfig) diff --git a/server/admin/migrations/027_host_agency_salary_policy.sql b/server/admin/migrations/027_host_agency_salary_policy.sql new file mode 100644 index 00000000..446ff2b8 --- /dev/null +++ b/server/admin/migrations/027_host_agency_salary_policy.sql @@ -0,0 +1,88 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policies ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + name VARCHAR(120) NOT NULL COMMENT '政策名称', + region_id BIGINT NOT NULL COMMENT '适用区域 ID', + status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled', + settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式:daily/half_month', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual', + gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例', + residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例', + effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms', + effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效', + description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '后台备注', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + 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', + UNIQUE KEY uk_admin_host_agency_salary_policy_name (app_code, region_id, name), + INDEX idx_admin_host_agency_salary_policy_region (app_code, region_id, status, effective_from_ms, effective_to_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策表'; + +CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policy_levels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID', + level_no INT NOT NULL COMMENT '等级', + required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石', + host_salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '主播累计美元工资', + host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计金币奖励', + agency_salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '代理累计美元工资', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_admin_host_agency_salary_level (policy_id, level_no), + INDEX idx_admin_host_agency_salary_level_policy (policy_id, required_diamonds) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级表'; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('主播代理工资政策查看', 'host-agency-policy:view', 'menu', '', @now_ms, @now_ms), + ('主播代理工资政策创建', 'host-agency-policy:create', 'button', '', @now_ms, @now_ms), + ('主播代理工资政策更新', 'host-agency-policy:update', 'button', '', @now_ms, @now_ms), + ('主播代理工资政策删除', 'host-agency-policy:delete', 'button', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '工资政策', 'host-agency-policy', '/host/salary-policies', 'wallet', 'host-agency-policy:view', 85, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'host-org' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +UPDATE admin_menus +SET sort = 86, updated_at_ms = @now_ms +WHERE code = 'host-org-coin-sellers' AND sort < 86; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ( + 'host-agency-policy:view', + 'host-agency-policy:create', + 'host-agency-policy:update', + 'host-agency-policy:delete' + ); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'host-agency-policy:view'; diff --git a/server/admin/migrations/029_host_salary_policy_publish_and_settlement.sql b/server/admin/migrations/029_host_salary_policy_publish_and_settlement.sql new file mode 100644 index 00000000..1d7fc8eb --- /dev/null +++ b/server/admin/migrations/029_host_salary_policy_publish_and_settlement.sql @@ -0,0 +1,83 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'publish_status') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN publish_status VARCHAR(24) NOT NULL DEFAULT ''draft'' COMMENT ''发布状态:draft/published/failed'' AFTER updated_by_admin_id', + '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 = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'publish_error') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN publish_error VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''最近一次发布失败原因'' AFTER publish_status', + '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 = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'published_at_ms') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''最近一次成功发布时间,UTC epoch ms'' AFTER publish_error', + '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 = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'published_by_admin_id') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN published_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最近一次成功发布管理员 ID'' AFTER published_at_ms', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('主播代理工资政策发布', 'host-agency-policy:publish', 'button', '', @now_ms, @now_ms), + ('主播工资结算记录查看', 'host-salary-settlement:view', 'menu', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '结算记录', 'host-salary-settlement', '/host/salary-settlements', 'receipt', 'host-salary-settlement:view', 86, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'host-org' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +UPDATE admin_menus +SET sort = 87, updated_at_ms = @now_ms +WHERE code = 'host-org-coin-sellers' AND sort < 87; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ( + 'host-agency-policy:publish', + 'host-salary-settlement:view' + ); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'host-salary-settlement:view'; diff --git a/server/admin/migrations/030_salary_policy_trigger_mode.sql b/server/admin/migrations/030_salary_policy_trigger_mode.sql new file mode 100644 index 00000000..ba7685eb --- /dev/null +++ b/server/admin/migrations/030_salary_policy_trigger_mode.sql @@ -0,0 +1,24 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- 触发方式是自动任务和人工结算的配置边界;老环境执行过 027 时需要幂等补列。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'settlement_trigger_mode') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT ''automatic'' COMMENT ''结算触发方式:automatic/manual'' AFTER settlement_mode', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 产品菜单统一叫“工资结算”,页面可同时承载待结算与历史记录查询。 +UPDATE admin_menus +SET title = '工资结算', + updated_at_ms = @now_ms +WHERE code = 'host-salary-settlement'; + +UPDATE admin_permissions +SET name = '工资结算查看', + updated_at_ms = @now_ms +WHERE code = 'host-salary-settlement:view'; diff --git a/server/admin/migrations/031_team_salary_policy.sql b/server/admin/migrations/031_team_salary_policy.sql new file mode 100644 index 00000000..1411d03b --- /dev/null +++ b/server/admin/migrations/031_team_salary_policy.sql @@ -0,0 +1,67 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +CREATE TABLE IF NOT EXISTS admin_team_salary_policies ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + policy_type VARCHAR(24) NOT NULL COMMENT '政策类型:bd/admin', + name VARCHAR(120) NOT NULL COMMENT '政策名称', + region_id BIGINT NOT NULL COMMENT '适用区域 ID', + status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual', + effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms', + effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效', + description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '后台备注', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + 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', + UNIQUE KEY uk_admin_team_salary_policy_name (app_code, policy_type, region_id, name), + INDEX idx_admin_team_salary_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资政策表'; + +CREATE TABLE IF NOT EXISTS admin_team_salary_policy_levels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID', + level_no INT NOT NULL COMMENT '等级', + threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '收入门槛美元;BD 为下属主播工资合计,Admin 为下属 BD 工资合计', + rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000 COMMENT '提成比例百分比,例如 8 表示 8%', + salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '达到该等级后的累计应得工资', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_admin_team_salary_level (policy_id, level_no), + INDEX idx_admin_team_salary_level_policy (policy_id, threshold_usd) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资政策等级表'; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('BD/Admin 工资政策查看', 'team-salary-policy:view', 'button', '', @now_ms, @now_ms), + ('BD/Admin 工资政策创建', 'team-salary-policy:create', 'button', '', @now_ms, @now_ms), + ('BD/Admin 工资政策更新', 'team-salary-policy:update', 'button', '', @now_ms, @now_ms), + ('BD/Admin 工资政策删除', 'team-salary-policy:delete', 'button', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ( + 'team-salary-policy:view', + 'team-salary-policy:create', + 'team-salary-policy:update', + 'team-salary-policy:delete' + ); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'team-salary-policy:view'; diff --git a/server/admin/migrations/032_team_salary_settlement.sql b/server/admin/migrations/032_team_salary_settlement.sql new file mode 100644 index 00000000..5ef5ada8 --- /dev/null +++ b/server/admin/migrations/032_team_salary_settlement.sql @@ -0,0 +1,12 @@ +SET @now_ms := UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000; + +INSERT IGNORE INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) +VALUES + ('工资手动结算', 'host-salary-settlement:settle', 'button', '允许在工资结算菜单批量手动结算 Host/Agency/BD/Admin 工资', @now_ms, @now_ms); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code = 'host-salary-settlement:settle'; diff --git a/services/activity-service/configs/config.docker.yaml b/services/activity-service/configs/config.docker.yaml index dcce12bc..d9b553a1 100644 --- a/services/activity-service/configs/config.docker.yaml +++ b/services/activity-service/configs/config.docker.yaml @@ -21,6 +21,7 @@ lucky_gift_worker: enabled: true worker_poll_interval: "1s" worker_batch_size: 100 + worker_concurrency: 4 worker_lock_ttl: "30s" worker_max_retry: 8 publish_timeout: "5s" diff --git a/services/activity-service/configs/config.tencent.example.yaml b/services/activity-service/configs/config.tencent.example.yaml index 4057b2a7..1687a4b2 100644 --- a/services/activity-service/configs/config.tencent.example.yaml +++ b/services/activity-service/configs/config.tencent.example.yaml @@ -19,8 +19,9 @@ red_packet_broadcast_worker: enabled: true lucky_gift_worker: enabled: true - worker_poll_interval: "1s" - worker_batch_size: 100 + worker_poll_interval: "60s" + worker_batch_size: 10 + worker_concurrency: 4 worker_lock_ttl: "30s" worker_max_retry: 8 publish_timeout: "5s" diff --git a/services/activity-service/configs/config.yaml b/services/activity-service/configs/config.yaml index 884ce5c9..fdf50deb 100644 --- a/services/activity-service/configs/config.yaml +++ b/services/activity-service/configs/config.yaml @@ -21,6 +21,7 @@ lucky_gift_worker: enabled: true worker_poll_interval: "1s" worker_batch_size: 100 + worker_concurrency: 4 worker_lock_ttl: "30s" worker_max_retry: 8 publish_timeout: "5s" diff --git a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql index 11692dc6..8ed8b138 100644 --- a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql +++ b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql @@ -44,7 +44,10 @@ CREATE TABLE IF NOT EXISTS activity_outbox ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, outbox_id), - KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms) + KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms), + KEY idx_activity_outbox_lucky_retry (app_code, event_type, status, next_retry_at_ms, created_at_ms, outbox_id), + KEY idx_activity_outbox_lucky_lock (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id), + KEY idx_activity_outbox_retention (app_code, status, updated_at_ms, outbox_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动事件 outbox 表'; CREATE TABLE IF NOT EXISTS lucky_gift_rules ( @@ -256,6 +259,44 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records ( KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实'; +CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总', + total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数', + unique_users BIGINT NOT NULL DEFAULT 0 COMMENT '参与用户数', + unique_rooms BIGINT NOT NULL DEFAULT 0 COMMENT '参与房间数', + total_spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币', + total_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返还金币', + base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖', + room_atmosphere_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '房间气氛支出', + activity_subsidy_coins BIGINT NOT NULL DEFAULT 0 COMMENT '活动补贴支出', + pending_draws BIGINT NOT NULL DEFAULT 0 COMMENT '待发放数量', + granted_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已发放数量', + failed_draws BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败数量', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, gift_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计'; + +CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总', + user_id BIGINT NOT NULL COMMENT '用户 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, gift_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总用户去重'; + +CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_rooms ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总', + room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, gift_id, room_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总房间去重'; + CREATE TABLE IF NOT EXISTS im_broadcast_outbox ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID', @@ -272,8 +313,9 @@ CREATE TABLE IF NOT EXISTS im_broadcast_outbox ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, event_id), - KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms), - KEY idx_im_broadcast_outbox_lock (app_code, status, locked_until_ms, created_at_ms) + KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_im_broadcast_outbox_lock (app_code, status, locked_until_ms, created_at_ms, event_id), + KEY idx_im_broadcast_outbox_retention (app_code, status, updated_at_ms, event_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM 广播发件箱表'; CREATE TABLE IF NOT EXISTS task_definitions ( diff --git a/services/activity-service/internal/app/app.go b/services/activity-service/internal/app/app.go index a75a8632..69b964d3 100644 --- a/services/activity-service/internal/app/app.go +++ b/services/activity-service/internal/app/app.go @@ -581,6 +581,7 @@ func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luc WorkerID: nodeID + "-lucky-gift", PollInterval: cfg.WorkerPollInterval, BatchSize: cfg.WorkerBatchSize, + Concurrency: cfg.WorkerConcurrency, LockTTL: cfg.WorkerLockTTL, MaxRetry: cfg.WorkerMaxRetry, PublishTimeout: cfg.PublishTimeout, diff --git a/services/activity-service/internal/config/config.go b/services/activity-service/internal/config/config.go index 59fdce61..cc2320a5 100644 --- a/services/activity-service/internal/config/config.go +++ b/services/activity-service/internal/config/config.go @@ -72,6 +72,7 @@ type LuckyGiftWorkerConfig struct { Enabled bool `yaml:"enabled"` WorkerPollInterval time.Duration `yaml:"worker_poll_interval"` WorkerBatchSize int `yaml:"worker_batch_size"` + WorkerConcurrency int `yaml:"worker_concurrency"` WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"` WorkerMaxRetry int `yaml:"worker_max_retry"` PublishTimeout time.Duration `yaml:"publish_timeout"` @@ -169,6 +170,7 @@ func Default() Config { Enabled: false, WorkerPollInterval: time.Second, WorkerBatchSize: 100, + WorkerConcurrency: 4, WorkerLockTTL: 30 * time.Second, WorkerMaxRetry: 8, PublishTimeout: 5 * time.Second, @@ -297,6 +299,12 @@ func Load(path string) (Config, error) { if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 { cfg.LuckyGiftWorker.WorkerBatchSize = 100 } + if cfg.LuckyGiftWorker.WorkerConcurrency <= 0 { + cfg.LuckyGiftWorker.WorkerConcurrency = 4 + } + if cfg.LuckyGiftWorker.WorkerConcurrency > cfg.LuckyGiftWorker.WorkerBatchSize { + cfg.LuckyGiftWorker.WorkerConcurrency = cfg.LuckyGiftWorker.WorkerBatchSize + } if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 { cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second } diff --git a/services/activity-service/internal/service/luckygift/service.go b/services/activity-service/internal/service/luckygift/service.go index 101e0a72..4f7ea5e8 100644 --- a/services/activity-service/internal/service/luckygift/service.go +++ b/services/activity-service/internal/service/luckygift/service.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "time" "hyapp/pkg/appcode" @@ -142,6 +143,7 @@ type WorkerOptions struct { WorkerID string PollInterval time.Duration BatchSize int + Concurrency int LockTTL time.Duration MaxRetry int PublishTimeout time.Duration @@ -174,15 +176,54 @@ func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOp if err != nil { return 0, err } - processed := 0 + if len(events) == 0 { + return 0, nil + } + workerCount := options.Concurrency + if workerCount > len(events) { + workerCount = len(events) + } + jobs := make(chan domain.DrawOutbox) + errs := make(chan error, len(events)) + var wg sync.WaitGroup + for index := 0; index < workerCount; index++ { + wg.Add(1) + go func() { + defer wg.Done() + for event := range jobs { + if err := s.processDrawOutbox(ctx, event, options); err != nil { + errs <- err + continue + } + errs <- nil + } + }() + } for _, event := range events { - // 单条失败立即返回,让外层记录批次错误;已处理成功的事件已经各自落 delivered,不会被回滚。 - if err := s.processDrawOutbox(ctx, event, options); err != nil { - return processed, err + select { + case <-ctx.Done(): + close(jobs) + wg.Wait() + close(errs) + return 0, ctx.Err() + case jobs <- event: + } + } + close(jobs) + wg.Wait() + close(errs) + processed := 0 + var firstErr error + for err := range errs { + if err != nil { + if firstErr == nil { + firstErr = err + } + continue } processed++ } - return processed, nil + return processed, firstErr } func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error { @@ -200,15 +241,16 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox } // 返奖 command_id 派生自聚合 draw_id,钱包侧可用它做幂等;批量抽奖不按子抽逐条加币,避免待发放和钱包流水膨胀。 resp, err := s.wallet.CreditLuckyGiftReward(eventCtx, &walletv1.CreditLuckyGiftRewardRequest{ - CommandId: "lucky_reward:" + payload.DrawID, - AppCode: event.AppCode, - TargetUserId: payload.UserID, - Amount: payload.EffectiveRewardCoins, - DrawId: payload.DrawID, - RoomId: payload.RoomID, - GiftId: payload.GiftID, - PoolId: payload.PoolID, - Reason: "lucky_gift_reward", + CommandId: "lucky_reward:" + payload.DrawID, + AppCode: event.AppCode, + TargetUserId: payload.UserID, + Amount: payload.EffectiveRewardCoins, + DrawId: payload.DrawID, + RoomId: payload.RoomID, + GiftId: payload.GiftID, + PoolId: payload.PoolID, + Reason: "lucky_gift_reward", + VisibleRegionId: payload.VisibleRegionID, }) if err != nil { return s.markOutboxFailed(eventCtx, event, options, err, false) @@ -216,6 +258,12 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox walletTransactionID = resp.GetTransactionId() credited = true } + if credited { + nowMS := s.now().UTC().UnixMilli() + if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil { + return err + } + } if s.publisher == nil { // 已返奖但缺少房间发布依赖时不能把 draw 标记 failed,否则会误导账务;只让 outbox 保持可重试。 return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited) @@ -233,16 +281,16 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox return s.markOutboxFailed(eventCtx, event, options, err, credited) } nowMS := s.now().UTC().UnixMilli() - // 所有外部副作用都成功后再把整批 draw 从 pending 收敛到 granted。 - // 这一步必须晚于钱包和 IM,否则客户端可能看到“已发放”但余额或房间表现还没落地。 - if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil { - return err + if !credited { + if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil { + return err + } } return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS) } func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error { - body, err := json.Marshal(payload) + body, err := json.Marshal(luckyGiftRoomMessagePayload(payload)) if err != nil { return err } @@ -400,6 +448,36 @@ func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool { return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= 1_000_000 } +func luckyGiftRoomMessagePayload(payload luckyGiftDrawnPayload) map[string]any { + return map[string]any{ + "event_type": "lucky_gift_drawn", + "event_id": payload.EventID, + "app_code": payload.AppCode, + "draw_id": payload.DrawID, + "command_id": payload.CommandID, + "pool_id": payload.PoolID, + "room_id": payload.RoomID, + "gift_id": payload.GiftID, + "gift_count": payload.GiftCount, + "user_id": payload.UserID, + "sender_user_id": payload.SenderUserID, + "target_user_id": payload.TargetUserID, + "visible_region_id": payload.VisibleRegionID, + "coin_spent": payload.CoinSpent, + "rule_version": payload.RuleVersion, + "experience_pool": payload.ExperiencePool, + "selected_tier_id": payload.SelectedTierID, + "multiplier_ppm": payload.MultiplierPPM, + "base_reward_coins": payload.BaseRewardCoins, + "room_atmosphere_reward_coins": payload.RoomAtmosphereRewardCoins, + "activity_subsidy_coins": payload.ActivitySubsidyCoins, + "effective_reward_coins": payload.EffectiveRewardCoins, + "stage_feedback": payload.StageFeedback, + "high_multiplier": payload.HighMultiplier, + "created_at_ms": payload.CreatedAtMS, + } +} + func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int64) map[string]any { return map[string]any{ "event_type": "lucky_gift_drawn", @@ -490,6 +568,12 @@ func normalizeWorkerOptions(options WorkerOptions) WorkerOptions { if options.BatchSize > 500 { options.BatchSize = 500 } + if options.Concurrency <= 0 { + options.Concurrency = 4 + } + if options.Concurrency > options.BatchSize { + options.Concurrency = options.BatchSize + } if options.LockTTL <= 0 { options.LockTTL = 30 * time.Second } diff --git a/services/activity-service/internal/service/luckygift/service_test.go b/services/activity-service/internal/service/luckygift/service_test.go index 89fbe7cd..ee53b26a 100644 --- a/services/activity-service/internal/service/luckygift/service_test.go +++ b/services/activity-service/internal/service/luckygift/service_test.go @@ -3,6 +3,8 @@ package luckygift import ( "context" "encoding/json" + "errors" + "fmt" "strings" "testing" "time" @@ -124,6 +126,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test "room_id": "room-1", "gift_id": "rose", "gift_count": 3, + "visible_region_id": 210, "coin_spent": 300, "effective_reward_coins": 800, "multiplier_ppm": 8000000, @@ -139,7 +142,8 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test } wallet := &fakeLuckyGiftWallet{} publisher := &fakeLuckyGiftPublisher{} - service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher)) + broadcaster := &fakeLuckyGiftRegionBroadcaster{} + service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster)) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) @@ -149,7 +153,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" { + 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 { 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" { @@ -157,6 +161,106 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test } } +func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) { + drawIDs := make([]string, 0, 999) + for index := 0; index < 999; index++ { + drawIDs = append(drawIDs, fmt.Sprintf("lucky_draw_1780217057721_%016d", index)) + } + publisher := &fakeLuckyGiftPublisher{} + service := New(&fakeLuckyGiftRepository{}, WithRoomPublisher(publisher)) + payload := luckyGiftDrawnPayload{ + EventType: "lucky_gift_drawn", + EventID: "lucky_gift_drawn:lucky_draw_1780217057721_0000000000000000", + AppCode: "lalu", + DrawID: drawIDs[0], + DrawIDs: drawIDs, + CommandID: "cmd-gift-batch", + PoolID: "lucky", + RoomID: "lalu_9aca4db6-35f0-4490-9cbc-1a452b7fa67d", + GiftID: "31", + GiftCount: 999, + UserID: 42, + SenderUserID: 42, + TargetUserID: 99, + VisibleRegionID: 210, + CoinSpent: 99900, + RuleVersion: 2, + ExperiencePool: "advanced", + SelectedTierID: "advanced_1x", + MultiplierPPM: 1000000, + BaseRewardCoins: 73983, + RoomAtmosphereRewardCoins: 0, + ActivitySubsidyCoins: 0, + EffectiveRewardCoins: 73983, + CreatedAtMS: 1780217057721, + } + + if err := service.publishLuckyGiftDrawn(context.Background(), payload, WorkerOptions{PublishTimeout: time.Second}); err != nil { + t.Fatalf("publishLuckyGiftDrawn failed: %v", err) + } + if len(publisher.last.PayloadJSON) >= 12*1024 { + t.Fatalf("room IM payload should stay below Tencent 12KB limit, got %d bytes", len(publisher.last.PayloadJSON)) + } + var body map[string]any + if err := json.Unmarshal(publisher.last.PayloadJSON, &body); err != nil { + t.Fatalf("decode room IM payload failed: %v", err) + } + if _, exists := body["draw_ids"]; exists { + t.Fatalf("room IM payload must not include internal draw_ids") + } + if body["draw_id"] != drawIDs[0] || body["event_id"] != payload.EventID || body["gift_count"].(float64) != 999 { + t.Fatalf("room IM payload lost display fields: %+v", body) + } +} + +func TestProcessPendingDrawOutboxGrantsRewardWhenRoomIMFails(t *testing.T) { + payload, _ := json.Marshal(map[string]any{ + "event_type": "lucky_gift_drawn", + "event_id": "lucky_gift_drawn:lucky_draw_im_fail", + "app_code": "lalu", + "draw_id": "lucky_draw_im_fail", + "command_id": "cmd-gift-im-fail", + "pool_id": "super_lucky", + "user_id": 42, + "room_id": "room-1", + "gift_id": "rose", + "gift_count": 1, + "coin_spent": 100, + "effective_reward_coins": 300, + "multiplier_ppm": 3000000, + "created_at_ms": 1760000000000, + }) + repository := &fakeLuckyGiftRepository{ + outbox: []domain.DrawOutbox{{ + AppCode: "lalu", + OutboxID: "lucky_lucky_draw_im_fail", + EventType: "LuckyGiftDrawn", + PayloadJSON: string(payload), + }}, + } + wallet := &fakeLuckyGiftWallet{} + publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")} + service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{})) + service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) + + processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) + if err != nil { + t.Fatalf("ProcessPendingDrawOutbox should schedule retry without failing batch: %v", err) + } + if processed != 1 { + t.Fatalf("processed count mismatch: %d", processed) + } + if repository.grantedDrawID != "lucky_draw_im_fail" { + t.Fatalf("wallet-granted draw should be settled before IM retry, got %q", repository.grantedDrawID) + } + if repository.deliveredOutboxID != "" { + t.Fatalf("outbox must not be delivered while IM failed") + } + if repository.retryableOutboxID != "lucky_lucky_draw_im_fail" { + t.Fatalf("IM failure should keep outbox retryable, got %q", repository.retryableOutboxID) + } +} + type fakeLuckyGiftRepository struct { getConfigCalls int upserted domain.RuleConfig @@ -165,6 +269,7 @@ type fakeLuckyGiftRepository struct { grantedDrawID string grantedDrawIDs []string deliveredOutboxID string + retryableOutboxID string } func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) { @@ -206,7 +311,8 @@ func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxDelivered(_ context.Context return nil } -func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(context.Context, domain.DrawOutbox, int, int64, string, int64) error { +func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error { + r.retryableOutboxID = event.OutboxID return nil } @@ -246,11 +352,12 @@ func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *wall type fakeLuckyGiftPublisher struct { last tencentim.CustomGroupMessage + err error } func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error { p.last = message - return nil + return p.err } type fakeLuckyGiftRegionBroadcaster struct { diff --git a/services/activity-service/internal/storage/mysql/broadcast_repository.go b/services/activity-service/internal/storage/mysql/broadcast_repository.go index 003216d3..6d166451 100644 --- a/services/activity-service/internal/storage/mysql/broadcast_repository.go +++ b/services/activity-service/internal/storage/mysql/broadcast_repository.go @@ -93,50 +93,55 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s if err != nil { return nil, err } - rows, err := tx.QueryContext(ctx, ` + appCode := appcode.FromContext(ctx) + records := make([]broadcastdomain.OutboxRecord, 0, limit) + claimBranches := []struct { + query string + args []any + }{ + { + query: ` SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR), status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms, created_at_ms, updated_at_ms - FROM im_broadcast_outbox + FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_pending) WHERE app_code = ? AND attempt_count < ? - AND ( - (status IN (?, ?) AND next_retry_at_ms <= ?) - OR (status = ? AND locked_until_ms <= ?) - ) + AND status IN (?, ?) + AND next_retry_at_ms <= ? ORDER BY created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`, - appcode.FromContext(ctx), - maxRetry, - broadcastdomain.StatusPending, - broadcastdomain.StatusRetryable, - nowMS, - broadcastdomain.StatusDelivering, - nowMS, - limit, - ) - if err != nil { - _ = tx.Rollback() - return nil, err + args: []any{appCode, maxRetry, broadcastdomain.StatusPending, broadcastdomain.StatusRetryable, nowMS}, + }, + { + query: ` + SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR), + status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms, + created_at_ms, updated_at_ms + FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_lock) + WHERE app_code = ? + AND attempt_count < ? + AND status = ? + AND locked_until_ms <= ? + ORDER BY created_at_ms ASC + LIMIT ? + FOR UPDATE SKIP LOCKED`, + args: []any{appCode, maxRetry, broadcastdomain.StatusDelivering, nowMS}, + }, } - records := make([]broadcastdomain.OutboxRecord, 0, limit) - for rows.Next() { - record, err := scanBroadcastOutbox(rows) + for _, branch := range claimBranches { + remaining := limit - len(records) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchRecords, err := queryBroadcastOutboxRecords(ctx, tx, branch.query, args...) if err != nil { - _ = rows.Close() _ = tx.Rollback() return nil, err } - records = append(records, record) - } - if err := rows.Close(); err != nil { - _ = tx.Rollback() - return nil, err - } - if err := rows.Err(); err != nil { - _ = tx.Rollback() - return nil, err + records = append(records, branchRecords...) } for _, record := range records { // 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。 @@ -166,6 +171,27 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s return records, nil } +func queryBroadcastOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]broadcastdomain.OutboxRecord, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + records := make([]broadcastdomain.OutboxRecord, 0) + for rows.Next() { + record, err := scanBroadcastOutbox(rows) + if err != nil { + return nil, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, err + } + return records, nil +} + // MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。 // delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。 func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error { diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go index 1a731e2a..55f971e3 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go @@ -576,6 +576,9 @@ func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appC if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil { return err } + if err := r.incrementLuckyDrawPoolStats(ctx, tx, state.Records, nowMS); err != nil { + return err + } if state.NeedsOutbox { // 只有本批存在钱包/IM/反馈副作用时才产生 outbox;纯 0 倍且无反馈的批次直接终态 granted。 return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS) @@ -653,6 +656,143 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec return nil } +type luckyDrawPoolStatKey struct { + AppCode string + PoolID string + GiftID string +} + +type luckyDrawPoolStatDelta struct { + TotalDraws int64 + TotalSpentCoins int64 + TotalRewardCoins int64 + BaseRewardCoins int64 + RoomAtmosphereRewardCoins int64 + ActivitySubsidyCoins int64 + PendingDraws int64 + GrantedDraws int64 + FailedDraws int64 + UserIDs map[int64]struct{} + RoomIDs map[string]struct{} +} + +func (r *Repository) incrementLuckyDrawPoolStats(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput, nowMS int64) error { + deltas := map[luckyDrawPoolStatKey]*luckyDrawPoolStatDelta{} + for _, record := range records { + poolID := luckyPoolID(record.Config.GiftID) + giftID := strings.TrimSpace(record.Command.GiftID) + keys := []luckyDrawPoolStatKey{ + {AppCode: record.AppCode, PoolID: poolID, GiftID: ""}, + } + if giftID != "" { + keys = append(keys, luckyDrawPoolStatKey{AppCode: record.AppCode, PoolID: poolID, GiftID: giftID}) + } + for _, key := range keys { + delta := deltas[key] + if delta == nil { + delta = &luckyDrawPoolStatDelta{UserIDs: map[int64]struct{}{}, RoomIDs: map[string]struct{}{}} + deltas[key] = delta + } + delta.TotalDraws++ + delta.TotalSpentCoins += record.Command.CoinSpent + delta.TotalRewardCoins += record.Candidate.effectiveReward() + delta.BaseRewardCoins += record.Candidate.BaseReward + delta.RoomAtmosphereRewardCoins += record.Candidate.RoomReward + delta.ActivitySubsidyCoins += record.Candidate.ActivityReward + switch record.RewardStatus { + case domain.StatusPending: + delta.PendingDraws++ + case domain.StatusGranted: + delta.GrantedDraws++ + case domain.StatusFailed: + delta.FailedDraws++ + } + if record.Command.UserID > 0 { + delta.UserIDs[record.Command.UserID] = struct{}{} + } + roomID := strings.TrimSpace(record.Command.RoomID) + if roomID != "" { + delta.RoomIDs[roomID] = struct{}{} + } + } + } + for key, delta := range deltas { + uniqueUsers, err := r.insertLuckyDrawStatUsers(ctx, tx, key, delta.UserIDs, nowMS) + if err != nil { + return err + } + uniqueRooms, err := r.insertLuckyDrawStatRooms(ctx, tx, key, delta.RoomIDs, nowMS) + if err != nil { + return err + } + if err := r.upsertLuckyDrawPoolStatDelta(ctx, tx, key, *delta, uniqueUsers, uniqueRooms, nowMS); err != nil { + return err + } + } + return nil +} + +func (r *Repository) insertLuckyDrawStatUsers(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, userIDs map[int64]struct{}, nowMS int64) (int64, error) { + var inserted int64 + for userID := range userIDs { + result, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms) + VALUES (?, ?, ?, ?, ?)`, + key.AppCode, key.PoolID, key.GiftID, userID, nowMS, + ) + if err != nil { + return 0, err + } + rows, _ := result.RowsAffected() + inserted += rows + } + return inserted, nil +} + +func (r *Repository) insertLuckyDrawStatRooms(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, roomIDs map[string]struct{}, nowMS int64) (int64, error) { + var inserted int64 + for roomID := range roomIDs { + result, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms) + VALUES (?, ?, ?, ?, ?)`, + key.AppCode, key.PoolID, key.GiftID, roomID, nowMS, + ) + if err != nil { + return 0, err + } + rows, _ := result.RowsAffected() + inserted += rows + } + return inserted, nil +} + +func (r *Repository) upsertLuckyDrawPoolStatDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, delta luckyDrawPoolStatDelta, uniqueUsers int64, uniqueRooms int64, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_draw_pool_stats ( + app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins, + total_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, + pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + total_draws = total_draws + VALUES(total_draws), + unique_users = unique_users + VALUES(unique_users), + unique_rooms = unique_rooms + VALUES(unique_rooms), + total_spent_coins = total_spent_coins + VALUES(total_spent_coins), + total_reward_coins = total_reward_coins + VALUES(total_reward_coins), + base_reward_coins = base_reward_coins + VALUES(base_reward_coins), + room_atmosphere_reward_coins = room_atmosphere_reward_coins + VALUES(room_atmosphere_reward_coins), + activity_subsidy_coins = activity_subsidy_coins + VALUES(activity_subsidy_coins), + pending_draws = pending_draws + VALUES(pending_draws), + granted_draws = granted_draws + VALUES(granted_draws), + failed_draws = failed_draws + VALUES(failed_draws), + updated_at_ms = VALUES(updated_at_ms)`, + key.AppCode, key.PoolID, key.GiftID, delta.TotalDraws, uniqueUsers, uniqueRooms, delta.TotalSpentCoins, + delta.TotalRewardCoins, delta.BaseRewardCoins, delta.RoomAtmosphereRewardCoins, delta.ActivitySubsidyCoins, + delta.PendingDraws, delta.GrantedDraws, delta.FailedDraws, nowMS, nowMS, + ) + return err +} + // luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。 func luckyDrawRecordArgs(input luckyDrawRecordInput) []any { candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input) @@ -1132,6 +1272,27 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky ); err != nil { return err } + if err := r.incrementLuckyDrawPoolStats(ctx, tx, []luckyDrawRecordInput{{ + AppCode: input.AppCode, + DrawID: input.DrawID, + Command: input.Command, + Config: input.Config, + ExperiencePool: input.ExperiencePool, + Candidate: input.Candidate, + Limited: input.Limited, + StageFeedback: input.StageFeedback, + GlobalWindow: input.GlobalWindow, + GiftWindow: input.GiftWindow, + PlatformPool: input.PlatformPool, + RoomPool: input.RoomPool, + GiftPool: input.GiftPool, + Atmosphere: input.Atmosphere, + BudgetSourcesJSON: input.BudgetSourcesJSON, + RewardStatus: input.RewardStatus, + NowMS: input.NowMS, + }}, input.NowMS); err != nil { + return err + } if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) { // outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。 payload, _ := json.Marshal(map[string]any{ @@ -1198,31 +1359,35 @@ func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID s } defer func() { _ = tx.Rollback() }() - rows, err := tx.QueryContext(ctx, ` + appCode := appcode.FromContext(ctx) + events, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, ` SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms - FROM activity_outbox - WHERE event_type = 'LuckyGiftDrawn' - AND ( - (status IN ('pending', 'retryable') AND next_retry_at_ms <= ?) - OR (status = 'delivering' AND lock_until_ms <= ?) - ) + FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_retry) + WHERE app_code = ? + AND event_type = 'LuckyGiftDrawn' + AND status IN ('pending', 'retryable') + AND next_retry_at_ms <= ? ORDER BY created_at_ms ASC, outbox_id ASC LIMIT ? - FOR UPDATE`, nowMS, nowMS, batchSize) + FOR UPDATE`, batchSize, appCode, nowMS, batchSize) if err != nil { return nil, err } - defer rows.Close() - events := make([]domain.DrawOutbox, 0, batchSize) - for rows.Next() { - var event domain.DrawOutbox - if err := rows.Scan(&event.AppCode, &event.OutboxID, &event.EventType, &event.PayloadJSON, &event.RetryCount, &event.CreatedAtMS); err != nil { + if remaining := batchSize - len(events); remaining > 0 { + retryEvents, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, ` + SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms + FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_lock) + WHERE app_code = ? + AND event_type = 'LuckyGiftDrawn' + AND status = 'delivering' + AND lock_until_ms <= ? + ORDER BY created_at_ms ASC, outbox_id ASC + LIMIT ? + FOR UPDATE`, remaining, appCode, nowMS, remaining) + if err != nil { return nil, err } - events = append(events, event) - } - if err := rows.Err(); err != nil { - return nil, err + events = append(events, retryEvents...) } lockUntilMS := nowMS + lockTTL.Milliseconds() for _, event := range events { @@ -1241,6 +1406,23 @@ func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID s return events, nil } +func (r *Repository) queryLuckyGiftOutboxCandidates(ctx context.Context, tx *sql.Tx, query string, capacity int, args ...any) ([]domain.DrawOutbox, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + events := make([]domain.DrawOutbox, 0, capacity) + for rows.Next() { + var event domain.DrawOutbox + if err := rows.Scan(&event.AppCode, &event.OutboxID, &event.EventType, &event.PayloadJSON, &event.RetryCount, &event.CreatedAtMS); err != nil { + return nil, err + } + events = append(events, event) + } + return events, rows.Err() +} + func (r *Repository) MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -1326,6 +1508,15 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra end = len(drawIDs) } chunk := drawIDs[start:end] + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + deltas, err := r.selectLuckyDrawStatusDeltas(ctx, tx, appCode, chunk, status, onlyPending) + if err != nil { + _ = tx.Rollback() + return err + } args := []any{status, walletTransactionID, failureReason, nowMS, appCode} for _, drawID := range chunk { args = append(args, drawID) @@ -1333,18 +1524,117 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra query := ` UPDATE lucky_draw_records SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ? - WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `)` + WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `) AND reward_status <> ?` + args = append(args, status) if onlyPending { query += ` AND reward_status = ?` args = append(args, domain.StatusPending) } - if _, err := r.db.ExecContext(ctx, query, args...); err != nil { + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + _ = tx.Rollback() + return err + } + if err := r.applyLuckyDrawStatusDeltas(ctx, tx, appCode, deltas, status, nowMS); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { return err } } return nil } +type luckyDrawStatusDelta struct { + PoolID string + GiftID string + OldStatus string + Count int64 +} + +func (r *Repository) selectLuckyDrawStatusDeltas(ctx context.Context, tx *sql.Tx, appCode string, drawIDs []string, targetStatus string, onlyPending bool) ([]luckyDrawStatusDelta, error) { + args := []any{appCode} + for _, drawID := range drawIDs { + args = append(args, drawID) + } + args = append(args, targetStatus) + query := ` + SELECT pool_id, gift_id, reward_status, COUNT(*) + FROM lucky_draw_records + WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(drawIDs)) + `) AND reward_status <> ?` + if onlyPending { + query += ` AND reward_status = ?` + args = append(args, domain.StatusPending) + } + query += ` GROUP BY pool_id, gift_id, reward_status` + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + deltas := []luckyDrawStatusDelta{} + for rows.Next() { + var delta luckyDrawStatusDelta + if err := rows.Scan(&delta.PoolID, &delta.GiftID, &delta.OldStatus, &delta.Count); err != nil { + return nil, err + } + deltas = append(deltas, delta) + } + return deltas, rows.Err() +} + +func (r *Repository) applyLuckyDrawStatusDeltas(ctx context.Context, tx *sql.Tx, appCode string, deltas []luckyDrawStatusDelta, targetStatus string, nowMS int64) error { + for _, delta := range deltas { + keys := []luckyDrawPoolStatKey{{AppCode: appCode, PoolID: delta.PoolID, GiftID: ""}} + if strings.TrimSpace(delta.GiftID) != "" { + keys = append(keys, luckyDrawPoolStatKey{AppCode: appCode, PoolID: delta.PoolID, GiftID: delta.GiftID}) + } + for _, key := range keys { + if err := r.applyLuckyDrawStatusDelta(ctx, tx, key, delta.OldStatus, targetStatus, delta.Count, nowMS); err != nil { + return err + } + } + } + return nil +} + +func (r *Repository) applyLuckyDrawStatusDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, oldStatus string, targetStatus string, count int64, nowMS int64) error { + pendingDelta, grantedDelta, failedDelta := luckyStatusCounterDelta(oldStatus, targetStatus, count) + if pendingDelta == 0 && grantedDelta == 0 && failedDelta == 0 { + return nil + } + _, err := tx.ExecContext(ctx, ` + UPDATE lucky_draw_pool_stats + SET pending_draws = pending_draws + ?, + granted_draws = granted_draws + ?, + failed_draws = failed_draws + ?, + updated_at_ms = ? + WHERE app_code = ? AND pool_id = ? AND gift_id = ?`, + pendingDelta, grantedDelta, failedDelta, nowMS, key.AppCode, key.PoolID, key.GiftID, + ) + return err +} + +func luckyStatusCounterDelta(oldStatus string, targetStatus string, count int64) (pending int64, granted int64, failed int64) { + switch oldStatus { + case domain.StatusPending: + pending -= count + case domain.StatusGranted: + granted -= count + case domain.StatusFailed: + failed -= count + } + switch targetStatus { + case domain.StatusPending: + pending += count + case domain.StatusGranted: + granted += count + case domain.StatusFailed: + failed += count + } + return pending, granted, failed +} + // normalizeLuckyDrawIDs 去掉空值和重复值;outbox payload 是补偿输入,必须先清洗再拼 SQL IN。 func normalizeLuckyDrawIDs(drawIDs []string) []string { seen := map[string]bool{} @@ -1421,6 +1711,13 @@ func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.D if r == nil || r.db == nil { return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } + if luckyDrawSummaryCanUseStats(query) { + return r.getLuckyGiftDrawSummaryFromStats(ctx, query) + } + return r.getLuckyGiftDrawSummaryFromRecords(ctx, query) +} + +func (r *Repository) getLuckyGiftDrawSummaryFromRecords(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query) var summary domain.DrawSummary summary.PoolID = query.PoolID @@ -1459,6 +1756,45 @@ func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.D return summary, nil } +func luckyDrawSummaryCanUseStats(query domain.DrawQuery) bool { + return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == "" +} + +func (r *Repository) getLuckyGiftDrawSummaryFromStats(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { + var summary domain.DrawSummary + summary.PoolID = query.PoolID + err := r.db.QueryRowContext(ctx, ` + SELECT total_draws, unique_users, unique_rooms, total_spent_coins, total_reward_coins, + base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, + pending_draws, granted_draws, failed_draws + FROM lucky_draw_pool_stats + WHERE app_code = ? AND pool_id = ? AND gift_id = ?`, + appcode.FromContext(ctx), query.PoolID, strings.TrimSpace(query.GiftID), + ).Scan( + &summary.TotalDraws, + &summary.UniqueUsers, + &summary.UniqueRooms, + &summary.TotalSpentCoins, + &summary.TotalRewardCoins, + &summary.BaseRewardCoins, + &summary.RoomAtmosphereRewardCoins, + &summary.ActivitySubsidyCoins, + &summary.PendingDraws, + &summary.GrantedDraws, + &summary.FailedDraws, + ) + if errors.Is(err, sql.ErrNoRows) { + return r.getLuckyGiftDrawSummaryFromRecords(ctx, query) + } + if err != nil { + return domain.DrawSummary{}, err + } + if summary.TotalSpentCoins > 0 { + summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins + } + return summary, nil +} + func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) { where := []string{"app_code = ?"} args := []any{appCode} diff --git a/services/activity-service/internal/storage/mysql/repository.go b/services/activity-service/internal/storage/mysql/repository.go index 8b140ead..781f7542 100644 --- a/services/activity-service/internal/storage/mysql/repository.go +++ b/services/activity-service/internal/storage/mysql/repository.go @@ -48,9 +48,18 @@ func (r *Repository) Migrate(ctx context.Context) error { if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil { return err } + if err := r.ensureLuckyGiftOutboxIndexes(ctx); err != nil { + return err + } + if err := r.ensureBroadcastOutboxIndexes(ctx); err != nil { + return err + } if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil { return err } + if err := r.ensureLuckyDrawPoolStatsTables(ctx); err != nil { + return err + } if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil { return err } @@ -63,6 +72,146 @@ func (r *Repository) Migrate(ctx context.Context) error { return nil } +func (r *Repository) ensureLuckyDrawPoolStatsTables(ctx context.Context) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总', + total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数', + unique_users BIGINT NOT NULL DEFAULT 0 COMMENT '参与用户数', + unique_rooms BIGINT NOT NULL DEFAULT 0 COMMENT '参与房间数', + total_spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币', + total_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返还金币', + base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖', + room_atmosphere_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '房间气氛支出', + activity_subsidy_coins BIGINT NOT NULL DEFAULT 0 COMMENT '活动补贴支出', + pending_draws BIGINT NOT NULL DEFAULT 0 COMMENT '待发放数量', + granted_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已发放数量', + failed_draws BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败数量', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, gift_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计'`, + `CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总', + user_id BIGINT NOT NULL COMMENT '用户 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, gift_id, user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总用户去重'`, + `CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_rooms ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总', + room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, gift_id, room_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总房间去重'`, + } + for _, statement := range statements { + if _, err := r.db.ExecContext(ctx, statement); err != nil { + return err + } + } + return r.backfillLuckyDrawPoolStatsIfEmpty(ctx) +} + +func (r *Repository) backfillLuckyDrawPoolStatsIfEmpty(ctx context.Context) error { + var existing int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lucky_draw_pool_stats LIMIT 1`).Scan(&existing); err != nil { + return err + } + if existing > 0 { + return nil + } + return r.backfillLuckyDrawPoolStats(ctx) +} + +func (r *Repository) backfillLuckyDrawPoolStats(ctx context.Context) error { + statements := []string{ + `INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms) + SELECT app_code, pool_id, '', user_id, MIN(created_at_ms) + FROM lucky_draw_records + GROUP BY app_code, pool_id, user_id`, + `INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms) + SELECT app_code, pool_id, gift_id, user_id, MIN(created_at_ms) + FROM lucky_draw_records + GROUP BY app_code, pool_id, gift_id, user_id`, + `INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms) + SELECT app_code, pool_id, '', room_id, MIN(created_at_ms) + FROM lucky_draw_records + GROUP BY app_code, pool_id, room_id`, + `INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms) + SELECT app_code, pool_id, gift_id, room_id, MIN(created_at_ms) + FROM lucky_draw_records + GROUP BY app_code, pool_id, gift_id, room_id`, + `INSERT INTO lucky_draw_pool_stats ( + app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins, + total_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, + pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms + ) + SELECT app_code, pool_id, '', COUNT(*), COUNT(DISTINCT user_id), COUNT(DISTINCT room_id), + COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0), + COALESCE(SUM(base_reward_coins), 0), COALESCE(SUM(room_atmosphere_reward_coins), 0), + COALESCE(SUM(activity_subsidy_coins), 0), + COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0), + MIN(created_at_ms), MAX(updated_at_ms) + FROM lucky_draw_records + GROUP BY app_code, pool_id + ON DUPLICATE KEY UPDATE + total_draws = VALUES(total_draws), + unique_users = VALUES(unique_users), + unique_rooms = VALUES(unique_rooms), + total_spent_coins = VALUES(total_spent_coins), + total_reward_coins = VALUES(total_reward_coins), + base_reward_coins = VALUES(base_reward_coins), + room_atmosphere_reward_coins = VALUES(room_atmosphere_reward_coins), + activity_subsidy_coins = VALUES(activity_subsidy_coins), + pending_draws = VALUES(pending_draws), + granted_draws = VALUES(granted_draws), + failed_draws = VALUES(failed_draws), + updated_at_ms = VALUES(updated_at_ms)`, + `INSERT INTO lucky_draw_pool_stats ( + app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins, + total_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, + pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms + ) + SELECT app_code, pool_id, gift_id, COUNT(*), COUNT(DISTINCT user_id), COUNT(DISTINCT room_id), + COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0), + COALESCE(SUM(base_reward_coins), 0), COALESCE(SUM(room_atmosphere_reward_coins), 0), + COALESCE(SUM(activity_subsidy_coins), 0), + COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0), + MIN(created_at_ms), MAX(updated_at_ms) + FROM lucky_draw_records + GROUP BY app_code, pool_id, gift_id + ON DUPLICATE KEY UPDATE + total_draws = VALUES(total_draws), + unique_users = VALUES(unique_users), + unique_rooms = VALUES(unique_rooms), + total_spent_coins = VALUES(total_spent_coins), + total_reward_coins = VALUES(total_reward_coins), + base_reward_coins = VALUES(base_reward_coins), + room_atmosphere_reward_coins = VALUES(room_atmosphere_reward_coins), + activity_subsidy_coins = VALUES(activity_subsidy_coins), + pending_draws = VALUES(pending_draws), + granted_draws = VALUES(granted_draws), + failed_draws = VALUES(failed_draws), + updated_at_ms = VALUES(updated_at_ms)`, + } + for _, statement := range statements { + if _, err := r.db.ExecContext(ctx, statement); err != nil { + return err + } + } + return nil +} + func (r *Repository) ensureLuckyUserStateFlowColumns(ctx context.Context) error { // 用户阶段改按金币流水折算等价抽数,状态表必须持久保存累计流水和连续未中奖次数,不能再只依赖 paid_draws。 additions := []struct { @@ -156,6 +305,51 @@ func (r *Repository) ensureLuckyGiftOutboxColumns(ctx context.Context) error { return nil } +func (r *Repository) ensureLuckyGiftOutboxIndexes(ctx context.Context) error { + // LuckyGiftDrawn worker 的 pending 和 lock-expired 两条路径分开查,索引列顺序要和各自 WHERE 匹配。 + additions := []struct { + name string + sql string + }{ + {"idx_activity_outbox_lucky_retry", `CREATE INDEX idx_activity_outbox_lucky_retry ON activity_outbox (app_code, event_type, status, next_retry_at_ms, created_at_ms, outbox_id)`}, + {"idx_activity_outbox_lucky_lock", `CREATE INDEX idx_activity_outbox_lucky_lock ON activity_outbox (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id)`}, + {"idx_activity_outbox_retention", `CREATE INDEX idx_activity_outbox_retention ON activity_outbox (app_code, status, updated_at_ms, outbox_id)`}, + } + for _, addition := range additions { + exists, err := r.indexExists(ctx, "activity_outbox", addition.name) + if err != nil { + return err + } + if !exists { + if _, err := r.db.ExecContext(ctx, addition.sql); err != nil { + return err + } + } + } + return nil +} + +func (r *Repository) ensureBroadcastOutboxIndexes(ctx context.Context) error { + additions := []struct { + name string + statement string + }{ + {"idx_im_broadcast_outbox_retention", `CREATE INDEX idx_im_broadcast_outbox_retention ON im_broadcast_outbox (app_code, status, updated_at_ms, event_id)`}, + } + for _, addition := range additions { + exists, err := r.indexExists(ctx, "im_broadcast_outbox", addition.name) + if err != nil { + return err + } + if !exists { + if _, err := r.db.ExecContext(ctx, addition.statement); err != nil { + return err + } + } + } + return nil +} + func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context) error { additions := []struct { name string diff --git a/services/cron-service/internal/app/app.go b/services/cron-service/internal/app/app.go index 21873897..1ffc9789 100644 --- a/services/cron-service/internal/app/app.go +++ b/services/cron-service/internal/app/app.go @@ -14,6 +14,7 @@ import ( activityv1 "hyapp.local/api/proto/activity/v1" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/grpchealth" "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" @@ -35,6 +36,7 @@ type App struct { userConn *grpc.ClientConn activityConn *grpc.ClientConn gameConn *grpc.ClientConn + walletConn *grpc.ClientConn workerCtx context.Context workerCancel context.CancelFunc workerWG sync.WaitGroup @@ -78,6 +80,15 @@ func New(cfg config.Config) (*App, error) { _ = repository.Close() return nil, err } + walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + _ = gameConn.Close() + _ = activityConn.Close() + _ = userConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service"))) health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{ @@ -87,6 +98,7 @@ func New(cfg config.Config) (*App, error) { healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) if err != nil { + _ = walletConn.Close() _ = gameConn.Close() _ = activityConn.Close() _ = userConn.Close() @@ -99,14 +111,18 @@ func New(cfg config.Config) (*App, error) { userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn)) activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn)) gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn)) + walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn)) taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{ - "login_ip_risk": userCron.ProcessLoginIPRiskBatch, - "user_region_rebuild": userCron.ProcessRegionRebuildBatch, - "mic_open_session_compensation": userCron.CompensateMicOpenSessions, - "message_fanout": activityCron.ProcessMessageFanoutBatch, - "growth_level_reward": activityCron.ProcessLevelRewardBatch, - "achievement_reward": activityCron.ProcessAchievementRewardBatch, - "game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch, + "login_ip_risk": userCron.ProcessLoginIPRiskBatch, + "user_region_rebuild": userCron.ProcessRegionRebuildBatch, + "mic_open_session_compensation": userCron.CompensateMicOpenSessions, + "message_fanout": activityCron.ProcessMessageFanoutBatch, + "growth_level_reward": activityCron.ProcessLevelRewardBatch, + "achievement_reward": activityCron.ProcessAchievementRewardBatch, + "game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch, + "host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch, + "host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch, + "host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch, }) return &App{ @@ -119,6 +135,7 @@ func New(cfg config.Config) (*App, error) { userConn: userConn, activityConn: activityConn, gameConn: gameConn, + walletConn: walletConn, workerCtx: workerCtx, workerCancel: workerCancel, }, nil @@ -170,6 +187,9 @@ func (a *App) Close() { if a.gameConn != nil { _ = a.gameConn.Close() } + if a.walletConn != nil { + _ = a.walletConn.Close() + } }) } diff --git a/services/cron-service/internal/config/config.go b/services/cron-service/internal/config/config.go index e7a706f4..edf5e743 100644 --- a/services/cron-service/internal/config/config.go +++ b/services/cron-service/internal/config/config.go @@ -172,6 +172,30 @@ func defaultTasks() map[string]TaskConfig { BatchSize: 100, AppCodes: []string{defaultAppCode}, }, + "host_salary_daily_settlement": { + Enabled: true, + Interval: "24h", + Timeout: "30s", + LockTTL: "2m", + BatchSize: 200, + AppCodes: []string{defaultAppCode}, + }, + "host_salary_half_month_settlement": { + Enabled: true, + Interval: "24h", + Timeout: "30s", + LockTTL: "2m", + BatchSize: 200, + AppCodes: []string{defaultAppCode}, + }, + "host_salary_month_end": { + Enabled: true, + Interval: "24h", + Timeout: "60s", + LockTTL: "5m", + BatchSize: 200, + AppCodes: []string{defaultAppCode}, + }, "mic_open_session_compensation": { Enabled: true, Interval: "60s", diff --git a/services/cron-service/internal/config/config_test.go b/services/cron-service/internal/config/config_test.go new file mode 100644 index 00000000..d7b8ddbf --- /dev/null +++ b/services/cron-service/internal/config/config_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +func TestDefaultHostSalaryCronTasksAreEnabled(t *testing.T) { + cfg := Default() + for _, name := range []string{ + "host_salary_daily_settlement", + "host_salary_half_month_settlement", + "host_salary_month_end", + } { + task, ok := cfg.Tasks[name] + if !ok { + t.Fatalf("host salary cron task %s missing", name) + } + if !task.Enabled { + t.Fatalf("host salary cron task %s should be enabled globally; policy settlement_mode decides who is processed", name) + } + } +} diff --git a/services/cron-service/internal/integration/wallet.go b/services/cron-service/internal/integration/wallet.go new file mode 100644 index 00000000..79c823f7 --- /dev/null +++ b/services/cron-service/internal/integration/wallet.go @@ -0,0 +1,80 @@ +package integration + +import ( + "context" + "time" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/services/cron-service/internal/scheduler" +) + +// WalletCronClient delegates wallet-owned salary settlement batches to wallet-service. +type WalletCronClient struct { + client walletv1.WalletCronServiceClient +} + +var walletCronNowUTC = func() time.Time { + return time.Now().UTC() +} + +// NewWalletCronClient creates scheduler handlers backed by wallet-service gRPC. +func NewWalletCronClient(client walletv1.WalletCronServiceClient) *WalletCronClient { + return &WalletCronClient{client: client} +} + +func (c *WalletCronClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) { + resp, err := c.client.ProcessHostSalaryDailySettlementBatch(ctx, walletCronBatchRequest(req)) + return walletCronBatchResult(resp), err +} + +func (c *WalletCronClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) { + if !isHostSalaryHalfMonthDue(walletCronNowUTC()) { + // 半月结是否适用于某个主播由后台工资政策 settlement_mode 决定;cron 这里只负责在月中打开一次全局触发窗口。 + return scheduler.BatchResult{}, nil + } + resp, err := c.client.ProcessHostSalaryHalfMonthSettlementBatch(ctx, walletCronBatchRequest(req)) + return walletCronBatchResult(resp), err +} + +func (c *WalletCronClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) { + if !isHostSalaryMonthEndDue(walletCronNowUTC()) { + // 月末批次负责最终等级差额、剩余钻石折美元和周期关闭;非月末触发只记录一次空跑,不进入 wallet 账务。 + return scheduler.BatchResult{}, nil + } + resp, err := c.client.ProcessHostSalaryMonthEndBatch(ctx, walletCronBatchRequest(req)) + return walletCronBatchResult(resp), err +} + +func isHostSalaryHalfMonthDue(now time.Time) bool { + return now.UTC().Day() == 15 +} + +func isHostSalaryMonthEndDue(now time.Time) bool { + utc := now.UTC() + return utc.AddDate(0, 0, 1).Day() == 1 +} + +func walletCronBatchRequest(req scheduler.BatchRequest) *walletv1.CronBatchRequest { + // scheduler 的 RunID 同时作为请求幂等标识传给 wallet;wallet 内部会继续按主播和周期生成账务幂等键。 + return &walletv1.CronBatchRequest{ + RequestId: req.RunID, + AppCode: req.AppCode, + RunId: req.RunID, + WorkerId: req.WorkerID, + BatchSize: int32(req.BatchSize), + LockTtlMs: req.LockTTL.Milliseconds(), + } +} + +func walletCronBatchResult(resp *walletv1.CronBatchResponse) scheduler.BatchResult { + if resp == nil { + return scheduler.BatchResult{} + } + return scheduler.BatchResult{ + ClaimedCount: int(resp.GetClaimedCount()), + ProcessedCount: int(resp.GetProcessedCount()), + SuccessCount: int(resp.GetSuccessCount()), + FailureCount: int(resp.GetFailureCount()), + HasMore: resp.GetHasMore(), + } +} diff --git a/services/cron-service/internal/integration/wallet_test.go b/services/cron-service/internal/integration/wallet_test.go new file mode 100644 index 00000000..5b443d35 --- /dev/null +++ b/services/cron-service/internal/integration/wallet_test.go @@ -0,0 +1,30 @@ +package integration + +import ( + "testing" + "time" +) + +func TestHostSalarySettlementDueDates(t *testing.T) { + tests := []struct { + name string + now time.Time + wantHalfMonth bool + wantMonthEnd bool + }{ + {name: "normal day", now: time.Date(2026, time.May, 14, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: false}, + {name: "half month day", now: time.Date(2026, time.May, 15, 10, 0, 0, 0, time.UTC), wantHalfMonth: true, wantMonthEnd: false}, + {name: "month end in 31 day month", now: time.Date(2026, time.May, 31, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: true}, + {name: "month end in 28 day month", now: time.Date(2026, time.February, 28, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isHostSalaryHalfMonthDue(tt.now); got != tt.wantHalfMonth { + t.Fatalf("half-month due mismatch: got %v want %v", got, tt.wantHalfMonth) + } + if got := isHostSalaryMonthEndDue(tt.now); got != tt.wantMonthEnd { + t.Fatalf("month-end due mismatch: got %v want %v", got, tt.wantMonthEnd) + } + }) + } +} diff --git a/services/game-service/configs/config.tencent.example.yaml b/services/game-service/configs/config.tencent.example.yaml index e8351cbc..e66fc900 100644 --- a/services/game-service/configs/config.tencent.example.yaml +++ b/services/game-service/configs/config.tencent.example.yaml @@ -23,8 +23,8 @@ rocketmq: producer_group: "hyapp-game-outbox-producer" outbox_worker: enabled: true - poll_interval: "1s" - batch_size: 100 + poll_interval: "60s" + batch_size: 10 publish_timeout: "3s" log: level: info diff --git a/services/game-service/deploy/mysql/initdb/001_game_service.sql b/services/game-service/deploy/mysql/initdb/001_game_service.sql index 39a3953c..5368cad0 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS game_launch_sessions ( user_id BIGINT NOT NULL COMMENT '用户 ID', 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', scene VARCHAR(64) NOT NULL COMMENT '场景', platform_code VARCHAR(64) NOT NULL COMMENT '平台编码', game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID', @@ -91,6 +92,7 @@ CREATE TABLE IF NOT EXISTS game_orders ( provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID', 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', op_type VARCHAR(32) NOT NULL COMMENT 'op类型', coin_amount BIGINT NOT NULL COMMENT '金币数量', status VARCHAR(32) NOT NULL COMMENT '业务状态', @@ -165,6 +167,8 @@ CREATE TABLE IF NOT EXISTS game_level_event_outbox ( PRIMARY KEY(app_code, event_id), UNIQUE KEY uk_game_level_event_order(app_code, order_id), KEY idx_game_level_event_pending(app_code, status, next_retry_at_ms, created_at_ms), + KEY idx_game_level_event_claim(app_code, status, locked_until_ms, next_retry_at_ms, created_at_ms, event_id), + KEY idx_game_level_event_retention(app_code, status, updated_at_ms, event_id), KEY idx_game_level_event_user(app_code, user_id, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏等级事件 outbox 表'; @@ -180,10 +184,17 @@ CREATE TABLE IF NOT EXISTS game_outbox ( coin_amount BIGINT NOT NULL COMMENT '金币数量', payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照', status VARCHAR(32) NOT NULL COMMENT '业务状态', + worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID', + lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期时间,UTC epoch ms', + retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次 MQ 投递重试时间,UTC epoch ms', + last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY(app_code, event_id), - KEY idx_game_outbox_status_created(app_code, status, created_at_ms), + KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id), + KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id), KEY idx_game_outbox_order(app_code, order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表'; diff --git a/services/game-service/internal/app/app.go b/services/game-service/internal/app/app.go index f4d5099d..2a22a74b 100644 --- a/services/game-service/internal/app/app.go +++ b/services/game-service/internal/app/app.go @@ -184,7 +184,8 @@ func (a *App) runOutboxWorker() { } func (a *App) processOutboxBatch() (int, error) { - records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, a.cfg.OutboxWorker.BatchSize) + workerID := "game-outbox-" + a.cfg.NodeID + records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, workerID, a.cfg.OutboxWorker.BatchSize) if err != nil { return 0, err } @@ -193,7 +194,8 @@ func (a *App) processOutboxBatch() (int, error) { err := a.publishGameOutboxRecord(publishCtx, record) cancel() if err != nil { - if markErr := a.repo.MarkGameOutboxPending(context.Background(), record.AppCode, record.EventID); markErr != nil { + nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli() + if markErr := a.repo.MarkGameOutboxRetryable(context.Background(), record.AppCode, record.EventID, err.Error(), nextRetryAtMS); markErr != nil { return 0, markErr } return len(records), err diff --git a/services/game-service/internal/domain/game/game.go b/services/game-service/internal/domain/game/game.go index f1639eb6..fc9e3957 100644 --- a/services/game-service/internal/domain/game/game.go +++ b/services/game-service/internal/domain/game/game.go @@ -112,6 +112,7 @@ type LaunchSession struct { // DisplayUserID 用于对外 uid_mode,避免直接暴露内部数字 ID。 DisplayUserID string RoomID string + RegionID int64 Scene string PlatformCode string GameID string @@ -137,6 +138,7 @@ type GameOrder struct { ProviderGameID string UserID int64 RoomID string + RegionID int64 // OpType 只允许钱包语义 debit/credit,厂商枚举在适配器层转换。 OpType string CoinAmount int64 diff --git a/services/game-service/internal/service/game/baishun_adapter.go b/services/game-service/internal/service/game/baishun_adapter.go index a3b4c6e1..5306f11c 100644 --- a/services/game-service/internal/service/game/baishun_adapter.go +++ b/services/game-service/internal/service/game/baishun_adapter.go @@ -341,6 +341,13 @@ func (s *Service) validBaishunSession(ctx context.Context, app string, token str if token == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } + // 百顺的 ss_token 直接复用 App access token;同一个登录 token 可能打开多款百顺游戏。 + // JWT 必须优先解析,否则会先命中旧启动会话的 hash,并被旧 provider_game_id 误判为跨游戏复用。 + if strings.Count(token, ".") == 2 { + if session, err := s.appSessionFromAccessToken(app, token); err == nil { + return session, nil + } + } if session, err := s.validYomiSession(ctx, app, token); err == nil { return session, nil } diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index 30284de6..047e5304 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -107,6 +107,7 @@ type LaunchCommand struct { GameID string Scene string RoomID string + RegionID int64 AccessToken string } @@ -188,6 +189,14 @@ 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 { + var err error + regionID, err = s.resolveUserRegion(ctx, command.AppCode, command.RequestID, command.UserID) + if err != nil { + return LaunchResult{}, err + } + } now := s.now() sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) @@ -216,6 +225,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch UserID: command.UserID, DisplayUserID: command.DisplayUserID, RoomID: strings.TrimSpace(command.RoomID), + RegionID: regionID, Scene: command.Scene, PlatformCode: game.PlatformCode, GameID: game.GameID, @@ -409,6 +419,25 @@ func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *game }, nil } +func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) { + if userID <= 0 || s.user == nil { + return 0, nil + } + userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{ + Meta: &userv1.RequestMeta{ + RequestId: requestID, + Caller: "game-service", + SentAtMs: s.now().UnixMilli(), + AppCode: app, + }, + UserId: userID, + }) + if err != nil { + return 0, err + } + return userResp.GetUser().GetRegionId(), nil +} + func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) { if s.wallet == nil { return nil, xerr.New(xerr.Unavailable, "wallet client is not configured") @@ -439,6 +468,10 @@ 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) + if err != nil { + return nil, err + } order := gamedomain.GameOrder{ AppCode: app, OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID), @@ -449,6 +482,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req * ProviderGameID: body.ProviderGameID, UserID: body.UserID, RoomID: strings.TrimSpace(body.RoomID), + RegionID: regionID, OpType: body.OpType, CoinAmount: body.CoinAmount, Status: gamedomain.OrderStatusWalletApplying, diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index 5e5485d1..288a5303 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -57,7 +57,7 @@ func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) { if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") { t.Fatalf("launch result mismatch: %+v", result) } - if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.LaunchTokenHash == "" { + if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.RegionID != 100 || repo.session.LaunchTokenHash == "" { t.Fatalf("launch session was not persisted: %+v", repo.session) } @@ -1001,6 +1001,54 @@ func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) { } } +func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) { + secret := "baishun-test-app-key" + userID := int64(312900923573673984) + token := leaderccTestAccessToken(t, "lalu", userID, "163004", 1700003600) + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "baishun", + AdapterType: gamedomain.AdapterBaishunV1, + CallbackSecretCiphertext: secret, + AdapterConfigJSON: `{"app_id":7482496867,"app_channel":"bobi","uid_mode":"display_user_id"}`, + }, + // 同一个 App access token 会被百顺 H5 作为 ss_token 复用;旧启动会话只说明曾经打开过别的游戏。 + // 回调验权必须以 JWT 里的当前用户身份为准,否则 user_info 会被旧 provider_game_id 拦成 token invalid。 + session: gamedomain.LaunchSession{ + AppCode: "lalu", + SessionID: "old_baishun_launch_session", + UserID: userID, + DisplayUserID: "163004", + PlatformCode: "baishun", + ProviderGameID: "1006", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + }, + } + wallet := &fakeWallet{balanceAfter: 1880} + user := &fakeUser{} + svc := New(Config{}, repo, wallet, user) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-baishun-stale-session", AppCode: "lalu"}, + PlatformCode: "baishun", + Operation: "get_user_info", + RawBody: baishunTestBody(t, secret, `{"app_id":7482496867,"user_id":"163004","ss_token":"`+token+`","provider_name":"bobi","client_ip":"104.28.244.150","game_id":1043,"currency_type":0}`, 1700000000), + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback get_user_info failed: %v", err) + } + if !strings.Contains(string(raw), `"code":0`) || strings.Contains(string(raw), "token invalid") || !strings.Contains(string(raw), `"user_id":"163004"`) { + t.Fatalf("baishun access token should ignore stale launch session game id: %s", raw) + } + if user.lastGet.GetUserId() != userID { + t.Fatalf("baishun userinfo must query JWT user, got %+v", user.lastGet) + } +} + func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}}, diff --git a/services/game-service/internal/service/game/yomi_adapter.go b/services/game-service/internal/service/game/yomi_adapter.go index 47d5fb92..37edfbb8 100644 --- a/services/game-service/internal/service/game/yomi_adapter.go +++ b/services/game-service/internal/service/game/yomi_adapter.go @@ -308,6 +308,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game ProviderGameID: session.ProviderGameID, UserID: session.UserID, RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)), + RegionID: session.RegionID, OpType: opType, CoinAmount: amount, Status: gamedomain.OrderStatusWalletApplying, diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 2c2f0bdc..05ccd9a3 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -118,6 +118,7 @@ func (r *Repository) Migrate(ctx context.Context) error { user_id BIGINT NOT NULL, display_user_id VARCHAR(32) NOT NULL, room_id VARCHAR(96) NOT NULL DEFAULT '', + region_id BIGINT NOT NULL DEFAULT 0, scene VARCHAR(64) NOT NULL, platform_code VARCHAR(64) NOT NULL, game_id VARCHAR(96) NOT NULL, @@ -141,6 +142,7 @@ func (r *Repository) Migrate(ctx context.Context) error { provider_game_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, room_id VARCHAR(96) NOT NULL DEFAULT '', + region_id BIGINT NOT NULL DEFAULT 0, op_type VARCHAR(32) NOT NULL, coin_amount BIGINT NOT NULL, status VARCHAR(32) NOT NULL, @@ -212,6 +214,8 @@ func (r *Repository) Migrate(ctx context.Context) error { PRIMARY KEY(app_code, event_id), UNIQUE KEY uk_game_level_event_order(app_code, order_id), KEY idx_game_level_event_pending(app_code, status, next_retry_at_ms, created_at_ms), + KEY idx_game_level_event_claim(app_code, status, locked_until_ms, next_retry_at_ms, created_at_ms, event_id), + KEY idx_game_level_event_retention(app_code, status, updated_at_ms, event_id), KEY idx_game_level_event_user(app_code, user_id, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS game_outbox ( @@ -226,10 +230,17 @@ func (r *Repository) Migrate(ctx context.Context) error { coin_amount BIGINT NOT NULL, payload_json JSON NOT NULL, status VARCHAR(32) NOT NULL, + worker_id VARCHAR(128) NOT NULL DEFAULT '', + lock_until_ms BIGINT NOT NULL DEFAULT 0, + retry_count INT NOT NULL DEFAULT 0, + next_retry_at_ms BIGINT NOT NULL DEFAULT 0, + last_error VARCHAR(512) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY(app_code, event_id), - KEY idx_game_outbox_status_created(app_code, status, created_at_ms), + KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id), + KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id), KEY idx_game_outbox_order(app_code, order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, } @@ -241,6 +252,39 @@ func (r *Repository) Migrate(ctx context.Context) error { if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' AFTER api_base_url"); err != nil { return err } + if err := r.ensureColumn(ctx, "game_launch_sessions", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_outbox", "lock_until_ms", "lock_until_ms BIGINT NOT NULL DEFAULT 0 AFTER worker_id"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_outbox", "retry_count", "retry_count INT NOT NULL DEFAULT 0 AFTER lock_until_ms"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_outbox", "next_retry_at_ms", "next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_outbox", "last_error", "last_error VARCHAR(512) NOT NULL DEFAULT '' AFTER next_retry_at_ms"); err != nil { + return err + } + if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_status_created", []string{"app_code", "status", "next_retry_at_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id)"); err != nil { + return err + } + if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_lock", []string{"app_code", "status", "lock_until_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id)"); err != nil { + return err + } + if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_retention(app_code, status, updated_at_ms, event_id)"); err != nil { + return err + } + if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil { + return err + } return r.seedDefaults(ctx) } @@ -261,6 +305,53 @@ func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnN return err } +func (r *Repository) ensureIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, createDDL string) error { + rows, err := r.db.QueryContext(ctx, + `SELECT COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX`, + tableName, indexName, + ) + if err != nil { + return err + } + defer rows.Close() + existing := make([]string, 0, len(columns)) + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return err + } + existing = append(existing, column) + } + if err := rows.Err(); err != nil { + return err + } + if equalStringSlices(existing, columns) { + return nil + } + if len(existing) > 0 { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName); err != nil { + return err + } + } + _, err = r.db.ExecContext(ctx, createDDL) + return err +} + +func equalStringSlices(left []string, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + func (r *Repository) seedDefaults(ctx context.Context) error { if _, err := r.db.ExecContext(ctx, `INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms) @@ -380,11 +471,11 @@ 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, scene, platform_code, game_id, + app_code, session_id, user_id, display_user_id, room_id, region_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.Scene, - session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID, + session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status, session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS, ) return err @@ -392,14 +483,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, scene, platform_code, game_id, + `SELECT app_code, session_id, user_id, display_user_id, room_id, region_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.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.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") } @@ -454,10 +545,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, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user_id, room_id, region_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.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS, + order.UserID, order.RoomID, order.RegionID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS, ); err != nil { return gamedomain.GameOrder{}, false, err } @@ -470,7 +561,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, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after, + user_id, room_id, region_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 = ? @@ -478,7 +569,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.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.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 } @@ -576,7 +667,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, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after, + user_id, room_id, region_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 = ? @@ -584,7 +675,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.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.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 } @@ -632,6 +723,8 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai 'provider_round_id', ?, 'user_id', ?, 'room_id', ?, + 'visible_region_id', ?, + 'region_id', ?, 'op_type', ?, 'coin_amount', ?, 'wallet_transaction_id', ?, @@ -639,7 +732,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.OpType, order.CoinAmount, walletTransactionID, balanceAfter, + order.UserID, order.RoomID, order.RegionID, order.RegionID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter, nowMs, nowMs, ) return err @@ -658,75 +751,138 @@ type GameOutboxRecord struct { CoinAmount int64 PayloadJSON string CreatedAtMS int64 + RetryCount int + LastError string + WorkerID string + LockUntilMS int64 } // ClaimPendingGameOutbox locks pending game facts so one worker publishes them. -func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, batchSize int) ([]GameOutboxRecord, error) { +func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string, batchSize int) ([]GameOutboxRecord, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if batchSize <= 0 { batchSize = 100 } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + workerID = "game-outbox-worker" + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() - rows, err := tx.QueryContext(ctx, ` + nowMS := time.Now().UTC().UnixMilli() + lockUntilMS := nowMS + (30 * time.Second).Milliseconds() + appCode := appcode.FromContext(ctx) + records := make([]GameOutboxRecord, 0, batchSize) + claimBranches := []struct { + query string + args []any + }{ + { + query: ` SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type, coin_amount, CAST(payload_json AS CHAR), created_at_ms - FROM game_outbox - WHERE status = 'pending' + FROM game_outbox FORCE INDEX (idx_game_outbox_status_created) + WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0 ORDER BY created_at_ms ASC, event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED - `, batchSize) - if err != nil { - return nil, err + `, + args: []any{appCode, nowMS}, + }, + { + query: ` + SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type, + coin_amount, CAST(payload_json AS CHAR), created_at_ms + FROM game_outbox FORCE INDEX (idx_game_outbox_status_created) + WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0 + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? FOR UPDATE SKIP LOCKED + `, + args: []any{appCode, nowMS}, + }, + { + query: ` + SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type, + coin_amount, CAST(payload_json AS CHAR), created_at_ms + FROM game_outbox FORCE INDEX (idx_game_outbox_lock) + WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ? + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? FOR UPDATE SKIP LOCKED + `, + args: []any{appCode, nowMS}, + }, } - records := make([]GameOutboxRecord, 0, batchSize) - for rows.Next() { - var record GameOutboxRecord - if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.OrderID, &record.UserID, &record.PlatformCode, &record.GameID, &record.OpType, &record.CoinAmount, &record.PayloadJSON, &record.CreatedAtMS); err != nil { - _ = rows.Close() + for _, branch := range claimBranches { + remaining := batchSize - len(records) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchRecords, err := queryGameOutboxRecords(ctx, tx, branch.query, args...) + if err != nil { return nil, err } - records = append(records, record) + records = append(records, branchRecords...) } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - nowMS := time.Now().UTC().UnixMilli() for _, record := range records { if _, err := tx.ExecContext(ctx, ` - UPDATE game_outbox SET status = 'running', updated_at_ms = ? - WHERE app_code = ? AND event_id = ? AND status = 'pending' - `, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil { + UPDATE game_outbox + SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ? + `, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } + for index := range records { + records[index].WorkerID = workerID + records[index].LockUntilMS = lockUntilMS + } + return records, nil +} + +func queryGameOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]GameOutboxRecord, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + records := make([]GameOutboxRecord, 0) + for rows.Next() { + var record GameOutboxRecord + if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.OrderID, &record.UserID, &record.PlatformCode, &record.GameID, &record.OpType, &record.CoinAmount, &record.PayloadJSON, &record.CreatedAtMS); err != nil { + return nil, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, err + } return records, nil } func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error { _, err := r.db.ExecContext(ctx, ` - UPDATE game_outbox SET status = 'delivered', updated_at_ms = ? + UPDATE game_outbox + SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ? WHERE app_code = ? AND event_id = ? `, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID) return err } -func (r *Repository) MarkGameOutboxPending(ctx context.Context, appCode string, eventID string) error { +func (r *Repository) MarkGameOutboxRetryable(ctx context.Context, appCode string, eventID string, lastErr string, nextRetryAtMS int64) error { _, err := r.db.ExecContext(ctx, ` - UPDATE game_outbox SET status = 'pending', updated_at_ms = ? + UPDATE game_outbox + SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1, + next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ? AND status = 'running' - `, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID) + `, nextRetryAtMS, truncate(lastErr, 512), time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID) return err } @@ -739,20 +895,28 @@ func (r *Repository) ClaimPendingLevelEvents(ctx context.Context, workerID strin return nil, err } defer func() { _ = tx.Rollback() }() - rows, err := tx.QueryContext(ctx, levelEventOutboxSelectSQL()+` + + appCode := appcode.FromContext(ctx) + events, err := r.queryPendingLevelEvents(ctx, tx, ` WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ? - AND (locked_until_ms = 0 OR locked_until_ms <= ?) + AND locked_until_ms = 0 ORDER BY created_at_ms ASC, event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, - appcode.FromContext(ctx), nowMS, nowMS, batchSize, - ) + appCode, nowMS, batchSize) if err != nil { return nil, err } - events, err := scanLevelEventOutboxRows(rows) - _ = rows.Close() - if err != nil { - return nil, err + if remaining := batchSize - len(events); remaining > 0 { + retryEvents, err := r.queryPendingLevelEvents(ctx, tx, ` + WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ? + AND locked_until_ms > 0 AND locked_until_ms <= ? + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? FOR UPDATE SKIP LOCKED`, + appCode, nowMS, nowMS, remaining) + if err != nil { + return nil, err + } + events = append(events, retryEvents...) } lockUntil := nowMS + lockTTL.Milliseconds() for _, event := range events { @@ -771,6 +935,15 @@ func (r *Repository) ClaimPendingLevelEvents(ctx context.Context, workerID strin return events, nil } +func (r *Repository) queryPendingLevelEvents(ctx context.Context, tx *sql.Tx, whereSQL string, args ...any) ([]gamedomain.LevelEventOutbox, error) { + rows, err := tx.QueryContext(ctx, levelEventOutboxSelectSQL()+whereSQL, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLevelEventOutboxRows(rows) +} + func (r *Repository) MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -1042,7 +1215,7 @@ func levelEventOutboxSelectSQL() string { provider_round_id, coin_amount, wallet_transaction_id, COALESCE(CAST(payload_json AS CHAR), '{}'), status, attempt_count, failure_reason, occurred_at_ms, created_at_ms, updated_at_ms - FROM game_level_event_outbox ` + FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim) ` } func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 2e6ba792..228b2e27 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -1753,6 +1753,49 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) { } } +func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) { + roomClient := &fakeRoomClient{} + hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}} + 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-host","target_user_id":43,"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()) + } + if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 43 { + t.Fatalf("gateway must query target host profile before sending gift: %+v", hostClient.lastHost) + } + if roomClient.lastGift == nil || !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 || roomClient.lastGift.GetTargetAgencyOwnerUserId() != 30001 { + t.Fatalf("gateway must inject active host period scope: %+v", roomClient.lastGift) + } +} + +func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) { + roomClient := &fakeRoomClient{} + hostClient := &fakeUserHostClient{} + 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-non-host","target_user_id":43,"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()) + } + if roomClient.lastGift == nil || roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 0 { + t.Fatalf("non-host target must not receive host period scope: %+v", roomClient.lastGift) + } +} + func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) { previousNow := roomapi.TimeNow roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } @@ -2860,6 +2903,8 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) { OwnerUserId: 901, RegionId: 30, Name: "Admin Seed Agency", + Avatar: "https://cdn.example/agency.png", + HostCount: 3, Status: "active", JoinEnabled: true, MaxHosts: 100, @@ -2870,7 +2915,7 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) - request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=99", nil) + request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=150", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-agency-search") recorder := httptest.NewRecorder() @@ -2880,7 +2925,7 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } - if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 20 { + if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 100 { t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies) } var response httpkit.ResponseEnvelope @@ -2890,11 +2935,31 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) { data := response.Data.(map[string]any) items := data["items"].([]any) first := items[0].(map[string]any) - if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || data["page_size"] != float64(20) { + if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || first["avatar"] != "https://cdn.example/agency.png" || first["host_count"] != float64(3) || data["page_size"] != float64(100) { t.Fatalf("host agency search response mismatch: %+v", response) } } +func TestSearchHostAgenciesAllowsEmptyKeywordForCountryList(t *testing.T) { + hostClient := &fakeUserHostClient{} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetUserHostClient(hostClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-host-agency-list") + 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 hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "" || hostClient.lastSearchAgencies.GetPageSize() != 100 { + t.Fatalf("host agency list request mismatch: %+v", hostClient.lastSearchAgencies) + } +} + func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) { hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{ { diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index 541ab66f..c604af2d 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -16,6 +16,7 @@ type Handler struct { roomQueryClient client.RoomQueryClient userProfileClient client.UserProfileClient userSocialClient client.UserSocialClient + userHostClient client.UserHostClient walletClient client.WalletClient rtcTokenConfig tencentrtc.TokenConfig } @@ -26,6 +27,7 @@ type Config struct { RoomQueryClient client.RoomQueryClient UserProfileClient client.UserProfileClient UserSocialClient client.UserSocialClient + UserHostClient client.UserHostClient WalletClient client.WalletClient RTCTokenConfig tencentrtc.TokenConfig } @@ -37,6 +39,7 @@ func New(config Config) *Handler { roomQueryClient: config.RoomQueryClient, userProfileClient: config.UserProfileClient, userSocialClient: config.UserSocialClient, + userHostClient: config.UserHostClient, walletClient: config.WalletClient, rtcTokenConfig: config.RTCTokenConfig, } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index c67ee8b7..3e83aaf6 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -1259,19 +1259,50 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } + targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs)) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{ - Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), - TargetType: targetType, - TargetUserIds: targetUserIDs, - TargetUserId: firstUserID(targetUserIDs), - GiftId: body.GiftID, - GiftCount: body.GiftCount, - PoolId: body.PoolID, + Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), + TargetType: targetType, + TargetUserIds: targetUserIDs, + TargetUserId: firstUserID(targetUserIDs), + GiftId: body.GiftID, + GiftCount: body.GiftCount, + PoolId: body.PoolID, + TargetIsHost: targetIsHost, + TargetHostRegionId: targetHostRegionID, + TargetAgencyOwnerUserId: targetAgencyOwnerUserID, }) httpkit.Write(writer, request, resp, err) } +func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) { + if targetUserID <= 0 || h.userHostClient == nil { + // 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。 + return false, 0, 0, nil + } + resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{ + Meta: httpkit.UserMeta(request, ""), + UserId: targetUserID, + }) + if err != nil { + if xerr.ReasonFromGRPC(err) == xerr.NotFound { + return false, 0, 0, nil + } + return false, 0, 0, err + } + profile := resp.GetHostProfile() + if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 { + // 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。 + return false, 0, 0, nil + } + return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil +} + // batchRoomDisplayProfiles 返回房间和公屏专用展示资料。 func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) { if h.userProfileClient == nil { diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 3938df06..8eb73abb 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -27,6 +27,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { RoomQueryClient: h.roomQueryClient, UserProfileClient: h.userProfileClient, UserSocialClient: h.userSocialClient, + UserHostClient: h.userHostClient, WalletClient: h.walletClient, RTCTokenConfig: tencentrtc.TokenConfig{ Enabled: h.tencentRTC.Enabled, diff --git a/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go b/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go index 3dde2b05..00a2f6a4 100644 --- a/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go @@ -17,6 +17,8 @@ type hostAgencyData struct { RegionID int64 `json:"region_id"` ParentBDUserID string `json:"parent_bd_user_id,omitempty"` Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + HostCount int32 `json:"host_count"` Status string `json:"status"` JoinEnabled bool `json:"join_enabled"` MaxHosts int32 `json:"max_hosts"` @@ -48,17 +50,13 @@ func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.R if keyword == "" { keyword = strings.TrimSpace(request.URL.Query().Get("keyword")) } - if keyword == "" { - httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") - return - } - pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) + pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 100) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } - if pageSize > 20 { - pageSize = 20 + if pageSize > 100 { + pageSize = 100 } resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{ Meta: httpkit.UserMeta(request, ""), @@ -123,6 +121,8 @@ func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData { RegionID: agency.GetRegionId(), ParentBDUserID: int64String(agency.GetParentBdUserId()), Name: agency.GetName(), + Avatar: agency.GetAvatar(), + HostCount: agency.GetHostCount(), Status: agency.GetStatus(), JoinEnabled: agency.GetJoinEnabled(), MaxHosts: agency.GetMaxHosts(), diff --git a/services/notice-service/configs/config.docker.yaml b/services/notice-service/configs/config.docker.yaml index 448f29de..c699b91d 100644 --- a/services/notice-service/configs/config.docker.yaml +++ b/services/notice-service/configs/config.docker.yaml @@ -28,7 +28,7 @@ wallet_notice_worker: max_backoff: 5m room_notice_worker: - enabled: true + enabled: false poll_interval: 1s batch_size: 100 lock_ttl: 30s @@ -38,13 +38,18 @@ room_notice_worker: max_backoff: 5m rocketmq: - # Docker 本地默认关闭;接入外部 RocketMQ 后可消费 room_outbox 私有通知事实。 + # Docker 本地默认关闭;接入外部 RocketMQ 后只消费 owner outbox 私有通知事实。 enabled: false name_servers: [] name_server_domain: "" access_key: "" secret_key: "" namespace: "" + wallet_outbox: + enabled: false + topic: "hyapp_wallet_outbox" + consumer_group: "hyapp-notice-wallet-outbox" + consumer_max_reconsume_times: 16 room_outbox: enabled: false topic: "hyapp_room_outbox" diff --git a/services/notice-service/configs/config.tencent.example.yaml b/services/notice-service/configs/config.tencent.example.yaml index 7f1ebf32..51c9a9f3 100644 --- a/services/notice-service/configs/config.tencent.example.yaml +++ b/services/notice-service/configs/config.tencent.example.yaml @@ -18,8 +18,8 @@ tencent_im: request_timeout: 5s wallet_notice_worker: - enabled: true - poll_interval: 500ms + enabled: false + poll_interval: 1s batch_size: 100 lock_ttl: 30s publish_timeout: 3s @@ -28,8 +28,8 @@ wallet_notice_worker: max_backoff: 5m room_notice_worker: - enabled: true - poll_interval: 500ms + enabled: false + poll_interval: 1s batch_size: 100 lock_ttl: 30s publish_timeout: 3s @@ -45,6 +45,11 @@ rocketmq: access_key: "TENCENT_ROCKETMQ_ACCESS_KEY" secret_key: "TENCENT_ROCKETMQ_SECRET_KEY" namespace: "hyapp-prod" + wallet_outbox: + enabled: true + topic: "hyapp_wallet_outbox" + consumer_group: "hyapp-notice-wallet-outbox" + consumer_max_reconsume_times: 16 room_outbox: enabled: true topic: "hyapp_room_outbox" diff --git a/services/notice-service/configs/config.yaml b/services/notice-service/configs/config.yaml index 54f9edad..5b28a0f2 100644 --- a/services/notice-service/configs/config.yaml +++ b/services/notice-service/configs/config.yaml @@ -28,7 +28,7 @@ wallet_notice_worker: max_backoff: 5m room_notice_worker: - enabled: true + enabled: false poll_interval: 1s batch_size: 100 lock_ttl: 30s @@ -38,13 +38,18 @@ room_notice_worker: max_backoff: 5m rocketmq: - # 本地默认关闭;开启后 notice 直接消费 room_outbox MQ fanout,仍写 notice_delivery_events 做幂等。 + # 本地默认关闭;开启后 notice 只消费 owner outbox MQ fanout,仍写 notice_delivery_events 做幂等。 enabled: false name_servers: [] name_server_domain: "" access_key: "" secret_key: "" namespace: "" + wallet_outbox: + enabled: false + topic: "hyapp_wallet_outbox" + consumer_group: "hyapp-notice-wallet-outbox" + consumer_max_reconsume_times: 16 room_outbox: enabled: false topic: "hyapp_room_outbox" diff --git a/services/notice-service/deploy/mysql/initdb/001_notice_service.sql b/services/notice-service/deploy/mysql/initdb/001_notice_service.sql index 890bc08a..c2802e66 100644 --- a/services/notice-service/deploy/mysql/initdb/001_notice_service.sql +++ b/services/notice-service/deploy/mysql/initdb/001_notice_service.sql @@ -26,7 +26,9 @@ CREATE TABLE IF NOT EXISTS notice_delivery_events ( created_at_ms BIGINT NOT NULL COMMENT '通知位点创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '通知位点更新时间,UTC epoch ms', PRIMARY KEY (source_name, app_code, source_event_id, channel), - KEY idx_notice_delivery_pending (status, next_retry_at_ms, lock_until_ms, updated_at_ms), + KEY idx_notice_delivery_retry (source_name, app_code, channel, status, next_retry_at_ms, updated_at_ms), + KEY idx_notice_delivery_lock (source_name, app_code, channel, status, lock_until_ms, updated_at_ms), + KEY idx_notice_delivery_retention (app_code, status, updated_at_ms, source_name, source_event_id, channel), KEY idx_notice_delivery_target (app_code, target_user_id, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知服务外部通知投递位点和死信表'; diff --git a/services/notice-service/internal/app/app.go b/services/notice-service/internal/app/app.go index b9f58254..d173de0c 100644 --- a/services/notice-service/internal/app/app.go +++ b/services/notice-service/internal/app/app.go @@ -19,6 +19,7 @@ import ( "hyapp/pkg/rocketmqx" "hyapp/pkg/roommq" "hyapp/pkg/tencentim" + "hyapp/pkg/walletmq" "hyapp/services/notice-service/internal/config" "hyapp/services/notice-service/internal/modules/roomnotice" "hyapp/services/notice-service/internal/modules/walletnotice" @@ -92,6 +93,11 @@ func New(cfg config.Config) (*App, error) { _ = store.Close() return nil, errors.New("room notice worker requires tencent_im.enabled") } + if cfg.RocketMQ.WalletOutbox.Enabled && publisher == nil { + _ = listener.Close() + _ = store.Close() + return nil, errors.New("wallet outbox mq consumer requires tencent_im.enabled") + } if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil { _ = listener.Close() _ = store.Close() @@ -114,14 +120,37 @@ func New(cfg config.Config) (*App, error) { walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher) roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher) roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker) - mqConsumers := make([]*rocketmqx.Consumer, 0, 1) - if cfg.RocketMQ.RoomOutbox.Enabled { - consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ)) + mqConsumers := make([]*rocketmqx.Consumer, 0, 2) + if cfg.RocketMQ.WalletOutbox.Enabled { + consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes)) if err != nil { _ = listener.Close() _ = store.Close() return nil, err } + if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) + if err != nil { + return err + } + _, err = walletSvc.ProcessWalletOutboxMessage(appcode.WithContext(ctx, walletMessage.AppCode), walletMessage, walletNoticeWorkerOptions(cfg.WalletNoticeWorker)) + return err + }); err != nil { + _ = consumer.Shutdown() + _ = listener.Close() + _ = store.Close() + return nil, err + } + mqConsumers = append(mqConsumers, consumer) + } + if cfg.RocketMQ.RoomOutbox.Enabled { + consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes)) + if err != nil { + shutdownConsumers(mqConsumers) + _ = listener.Close() + _ = store.Close() + return nil, err + } if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body) if err != nil { @@ -132,6 +161,7 @@ func New(cfg config.Config) (*App, error) { }); err != nil { _ = listener.Close() _ = store.Close() + shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) @@ -267,7 +297,13 @@ func (a *App) shutdownMQ() { } } -func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { +func shutdownConsumers(consumers []*rocketmqx.Consumer) { + for _, consumer := range consumers { + _ = consumer.Shutdown() + } +} + +func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig { return rocketmqx.ConsumerConfig{ EndpointConfig: rocketmqx.EndpointConfig{ NameServers: cfg.NameServers, @@ -278,8 +314,8 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig Namespace: cfg.Namespace, VIPChannel: cfg.VIPChannel, }, - GroupName: cfg.RoomOutbox.ConsumerGroup, - MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes, + GroupName: group, + MaxReconsumeTimes: maxReconsumeTimes, ConsumeRetryDelay: time.Second, ConsumePullBatch: 32, ConsumePullTimeout: 15 * time.Minute, diff --git a/services/notice-service/internal/config/config.go b/services/notice-service/internal/config/config.go index 044aedca..9a47a0e9 100644 --- a/services/notice-service/internal/config/config.go +++ b/services/notice-service/internal/config/config.go @@ -21,9 +21,9 @@ type Config struct { HealthHTTPAddr string `yaml:"health_http_addr"` // MySQLDSN 是 notice-service 自己的投递位点和死信状态库。 MySQLDSN string `yaml:"mysql_dsn"` - // WalletDatabase 是 wallet_outbox 所在库名;notice 只把它当 append-only 事实源读取。 + // WalletDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 wallet_outbox。 WalletDatabase string `yaml:"wallet_database"` - // RoomDatabase 是 room_outbox 所在库名;notice 只读取需要私有通知的房间事实。 + // RoomDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 room_outbox。 RoomDatabase string `yaml:"room_database"` // TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。 TencentIM TencentIMConfig `yaml:"tencent_im"` @@ -31,7 +31,7 @@ type Config struct { WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"` // RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。 RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"` - // RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。 + // RocketMQ 消费 owner service 发布的 outbox fanout topic。 RocketMQ RocketMQConfig `yaml:"rocketmq"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` Log logx.Config `yaml:"log"` @@ -76,7 +76,7 @@ type WalletNoticeWorkerConfig struct { MaxBackoff time.Duration `yaml:"max_backoff"` } -// RocketMQConfig 描述 notice-service 消费房间事实的 MQ 连接。 +// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。 type RocketMQConfig struct { Enabled bool `yaml:"enabled"` NameServers []string `yaml:"name_servers"` @@ -86,6 +86,7 @@ type RocketMQConfig struct { SecurityToken string `yaml:"security_token"` Namespace string `yaml:"namespace"` VIPChannel bool `yaml:"vip_channel"` + WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` } @@ -148,6 +149,12 @@ func Default() Config { func defaultRocketMQConfig() RocketMQConfig { return RocketMQConfig{ Enabled: false, + WalletOutbox: RoomOutboxMQConfig{ + Enabled: false, + Topic: "hyapp_wallet_outbox", + ConsumerGroup: "hyapp-notice-wallet-outbox", + ConsumerMaxReconsumeTimes: 16, + }, RoomOutbox: RoomOutboxMQConfig{ Enabled: false, Topic: "hyapp_room_outbox", @@ -201,6 +208,12 @@ func Load(path string) (Config, error) { return Config{}, err } cfg.RocketMQ = rocketMQ + if cfg.WalletNoticeWorker.Enabled { + return Config{}, errors.New("wallet_notice_worker direct wallet_outbox polling is disabled; enable rocketmq.wallet_outbox") + } + if cfg.RoomNoticeWorker.Enabled { + return Config{}, errors.New("room_notice_worker direct room_outbox polling is disabled; enable rocketmq.room_outbox") + } if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" { cfg.TencentIM.AdminIdentifier = "administrator" } @@ -233,7 +246,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 { cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes } - if cfg.RoomOutbox.Enabled { + if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" { + cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic + } + if cfg.WalletOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.ConsumerGroup); cfg.WalletOutbox.ConsumerGroup == "" { + cfg.WalletOutbox.ConsumerGroup = defaults.WalletOutbox.ConsumerGroup + } + if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 { + cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes + } + if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled { cfg.Enabled = true } if cfg.Enabled { diff --git a/services/notice-service/internal/config/config_test.go b/services/notice-service/internal/config/config_test.go index 69191880..7b42fdab 100644 --- a/services/notice-service/internal/config/config_test.go +++ b/services/notice-service/internal/config/config_test.go @@ -7,17 +7,23 @@ func TestLoadLocalKeepsRocketMQDisabled(t *testing.T) { if err != nil { t.Fatalf("Load local config failed: %v", err) } - if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled { + if cfg.RocketMQ.Enabled || cfg.RocketMQ.WalletOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Enabled { t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ) } } -func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) { +func TestLoadTencentExampleEnablesOutboxMQConsumers(t *testing.T) { cfg, err := Load("../../configs/config.tencent.example.yaml") if err != nil { t.Fatalf("Load tencent example failed: %v", err) } - if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" { + if cfg.WalletNoticeWorker.Enabled || cfg.RoomNoticeWorker.Enabled { + t.Fatalf("tencent example must keep legacy DB poll workers disabled: wallet=%+v room=%+v", cfg.WalletNoticeWorker, cfg.RoomNoticeWorker) + } + if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || cfg.RocketMQ.WalletOutbox.Topic == "" || cfg.RocketMQ.WalletOutbox.ConsumerGroup == "" { + t.Fatalf("tencent example must configure wallet outbox MQ consumer: %+v", cfg.RocketMQ) + } + if !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" { t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ) } } diff --git a/services/notice-service/internal/modules/roomnotice/mysql_integration_test.go b/services/notice-service/internal/modules/roomnotice/mysql_integration_test.go index fd1c0d65..c8d28fef 100644 --- a/services/notice-service/internal/modules/roomnotice/mysql_integration_test.go +++ b/services/notice-service/internal/modules/roomnotice/mysql_integration_test.go @@ -12,6 +12,7 @@ import ( "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/pkg/appcode" _ "github.com/go-sql-driver/mysql" ) @@ -96,7 +97,7 @@ func TestMySQLRepositoryProcessesRealRoomKickOutbox(t *testing.T) { publisher := &fakePublisher{} service := New(Config{NodeID: "notice-real-test"}, repository, publisher) - processed, err := service.ProcessRoomKickNotices(ctx, RoomNoticeWorkerOptions{ + processed, err := service.ProcessRoomKickNotices(appcode.WithContext(ctx, appCode), RoomNoticeWorkerOptions{ WorkerID: "notice-real-room-test-worker", BatchSize: 1, LockTTL: 30 * time.Second, @@ -166,7 +167,7 @@ func ensureRoomOutboxTable(ctx context.Context, db *sql.DB, roomDatabase string) last_error TEXT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, event_id), - KEY idx_room_outbox_notice (app_code, event_type, created_at_ms) + KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, quoteDB(roomDatabase), )) diff --git a/services/notice-service/internal/modules/roomnotice/mysql_repository.go b/services/notice-service/internal/modules/roomnotice/mysql_repository.go index 2515e63b..8181f24f 100644 --- a/services/notice-service/internal/modules/roomnotice/mysql_repository.go +++ b/services/notice-service/internal/modules/roomnotice/mysql_repository.go @@ -103,35 +103,72 @@ type roomKickCandidate struct { } func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) { - query := fmt.Sprintf(` + appCode := appcode.FromContext(ctx) + candidates := make([]roomKickCandidate, 0, limit) + branches := []struct { + query string + args []any + }{ + { + query: fmt.Sprintf(` SELECT ro.app_code, ro.event_id - FROM %s ro + FROM %s ro FORCE INDEX (idx_room_outbox_event_created) LEFT JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ? - WHERE ro.event_type = ? - AND ( - nde.source_event_id IS NULL - OR (nde.status = ? AND nde.next_retry_at_ms <= ?) - OR (nde.status = ? AND nde.lock_until_ms <= ?) - ) + WHERE ro.app_code = ? AND ro.event_type = ? + AND nde.source_event_id IS NULL ORDER BY ro.created_at_ms ASC, ro.event_id ASC - LIMIT ?`, r.roomOutboxTable) - rows, err := r.db.QueryContext(ctx, query, - sourceRoomOutbox, - channelTencentIMC2C, - eventRoomUserKicked, - deliveryStatusRetryable, - nowMs, - deliveryStatusDelivering, - nowMs, - limit, - ) + LIMIT ?`, r.roomOutboxTable), + args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked}, + }, + { + query: fmt.Sprintf(` + SELECT ro.app_code, ro.event_id + FROM %s ro FORCE INDEX (idx_room_outbox_event_created) + JOIN notice_delivery_events nde + ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ? + WHERE ro.app_code = ? AND ro.event_type = ? + AND nde.status = ? AND nde.next_retry_at_ms <= ? + ORDER BY ro.created_at_ms ASC, ro.event_id ASC + LIMIT ?`, r.roomOutboxTable), + args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusRetryable, nowMs}, + }, + { + query: fmt.Sprintf(` + SELECT ro.app_code, ro.event_id + FROM %s ro FORCE INDEX (idx_room_outbox_event_created) + JOIN notice_delivery_events nde + ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ? + WHERE ro.app_code = ? AND ro.event_type = ? + AND nde.status = ? AND nde.lock_until_ms <= ? + ORDER BY ro.created_at_ms ASC, ro.event_id ASC + LIMIT ?`, r.roomOutboxTable), + args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusDelivering, nowMs}, + }, + } + for _, branch := range branches { + remaining := limit - len(candidates) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchCandidates, err := queryRoomKickCandidates(ctx, r.db, branch.query, args...) + if err != nil { + return nil, err + } + candidates = append(candidates, branchCandidates...) + } + return candidates, nil +} + +func queryRoomKickCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]roomKickCandidate, error) { + rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() - candidates := make([]roomKickCandidate, 0, limit) + candidates := make([]roomKickCandidate, 0) for rows.Next() { var candidate roomKickCandidate if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil { @@ -313,10 +350,7 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev UPDATE notice_delivery_events SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? - AND ( - (status = ? AND next_retry_at_ms <= ?) - OR (status = ? AND lock_until_ms <= ?) - )`, + AND status = ? AND next_retry_at_ms <= ?`, deliveryStatusDelivering, workerID, lockUntilMS, @@ -328,16 +362,40 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev channelTencentIMC2C, deliveryStatusRetryable, nowMs, - deliveryStatusDelivering, - nowMs, ) if err != nil { return 0, false, err } affected, err := result.RowsAffected() - if err != nil || affected == 0 { + if err != nil { return 0, false, err } + if affected == 0 { + result, err = tx.ExecContext(ctx, ` + UPDATE notice_delivery_events + SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? + AND status = ? AND lock_until_ms <= ?`, + deliveryStatusDelivering, + workerID, + lockUntilMS, + string(payload), + nowMs, + sourceRoomOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + deliveryStatusDelivering, + nowMs, + ) + if err != nil { + return 0, false, err + } + affected, err = result.RowsAffected() + if err != nil || affected == 0 { + return 0, false, err + } + } var retryCount int err = tx.QueryRowContext(ctx, ` diff --git a/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go b/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go index 95050aac..c23918e0 100644 --- a/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go +++ b/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go @@ -11,6 +11,7 @@ import ( "time" _ "github.com/go-sql-driver/mysql" + "hyapp/pkg/appcode" ) func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) { @@ -82,7 +83,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) { publisher := &fakePublisher{} service := New(Config{NodeID: "notice-real-test"}, repository, publisher) - processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{ + processed, err := service.ProcessWalletBalanceNotices(appcode.WithContext(ctx, appCode), WalletNoticeWorkerOptions{ WorkerID: "notice-real-test-worker", BatchSize: 1, LockTTL: 30 * time.Second, @@ -186,7 +187,7 @@ func TestMySQLRepositoryProcessesRealVipActivatedOutbox(t *testing.T) { publisher := &fakePublisher{} service := New(Config{NodeID: "notice-real-test"}, repository, publisher) - processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{ + processed, err := service.ProcessWalletBalanceNotices(appcode.WithContext(ctx, appCode), WalletNoticeWorkerOptions{ WorkerID: "notice-real-test-worker", BatchSize: 1, LockTTL: 30 * time.Second, diff --git a/services/notice-service/internal/modules/walletnotice/mysql_repository.go b/services/notice-service/internal/modules/walletnotice/mysql_repository.go index fe6747f6..791d11d1 100644 --- a/services/notice-service/internal/modules/walletnotice/mysql_repository.go +++ b/services/notice-service/internal/modules/walletnotice/mysql_repository.go @@ -11,6 +11,7 @@ import ( "unicode" "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" "hyapp/pkg/xerr" ) @@ -108,40 +109,71 @@ type walletBalanceCandidate struct { func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) { eventTypes := walletPrivateNoticeEventTypes() - query := fmt.Sprintf(` + appCode := appcode.FromContext(ctx) + candidates := make([]walletBalanceCandidate, 0) + branches := []struct { + query string + args []any + }{ + { + query: fmt.Sprintf(` SELECT wo.app_code, wo.event_id, wo.event_type - FROM %s wo + FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created) LEFT JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? - WHERE wo.event_type IN (%s) - AND ( - nde.source_event_id IS NULL - OR (nde.status = ? AND nde.next_retry_at_ms <= ?) - OR (nde.status = ? AND nde.lock_until_ms <= ?) - ) + WHERE wo.app_code = ? AND wo.event_type IN (%s) + AND nde.source_event_id IS NULL ORDER BY wo.created_at_ms ASC, wo.event_id ASC - LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))) - args := []any{ - sourceWalletOutbox, - channelTencentIMC2C, + LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))), + args: append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), + }, + { + query: fmt.Sprintf(` + SELECT wo.app_code, wo.event_id, wo.event_type + FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created) + JOIN notice_delivery_events nde + ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? + WHERE wo.app_code = ? AND wo.event_type IN (%s) + AND nde.status = ? AND nde.next_retry_at_ms <= ? + ORDER BY wo.created_at_ms ASC, wo.event_id ASC + LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))), + args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusRetryable, nowMs), + }, + { + query: fmt.Sprintf(` + SELECT wo.app_code, wo.event_id, wo.event_type + FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created) + JOIN notice_delivery_events nde + ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? + WHERE wo.app_code = ? AND wo.event_type IN (%s) + AND nde.status = ? AND nde.lock_until_ms <= ? + ORDER BY wo.created_at_ms ASC, wo.event_id ASC + LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))), + args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusDelivering, nowMs), + }, } - for _, eventType := range eventTypes { - args = append(args, eventType) + for _, branch := range branches { + remaining := limit - len(candidates) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchCandidates, err := queryWalletBalanceCandidates(ctx, r.db, branch.query, args...) + if err != nil { + return nil, err + } + candidates = append(candidates, branchCandidates...) } - args = append(args, - deliveryStatusRetryable, - nowMs, - deliveryStatusDelivering, - nowMs, - limit, - ) - rows, err := r.db.QueryContext(ctx, query, args...) + return candidates, nil +} + +func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) { + rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() - - candidates := make([]walletBalanceCandidate, 0, limit) + candidates := make([]walletBalanceCandidate, 0) for rows.Next() { var candidate walletBalanceCandidate if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil { @@ -182,6 +214,73 @@ func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candi return event, true, nil } +// ClaimWalletBalanceMessage records a wallet_outbox MQ message in notice_delivery_events. +func (r *MySQLRepository) ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error) { + if r == nil || r.db == nil { + return WalletBalanceEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured") + } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return WalletBalanceEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if lockTTL <= 0 { + lockTTL = 30 * time.Second + } + event, err := walletBalanceEventFromMessage(message) + if err != nil { + return WalletBalanceEvent{}, false, err + } + now := time.Now() + lockUntilMS := now.Add(lockTTL).UnixMilli() + nowMs := now.UnixMilli() + ctx = appcode.WithContext(ctx, event.AppCode) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return WalletBalanceEvent{}, false, err + } + defer func() { _ = tx.Rollback() }() + + retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) + if err != nil || !claimed { + return WalletBalanceEvent{}, false, err + } + event.RetryCount = retryCount + if err := tx.Commit(); err != nil { + return WalletBalanceEvent{}, false, err + } + return event, true, nil +} + +func walletBalanceEventFromMessage(message walletmq.WalletOutboxMessage) (WalletBalanceEvent, error) { + event := WalletBalanceEvent{ + AppCode: appcode.Normalize(message.AppCode), + EventID: message.EventID, + EventType: message.EventType, + TransactionID: message.TransactionID, + CommandID: message.CommandID, + UserID: message.UserID, + AssetType: message.AssetType, + AvailableDelta: message.AvailableDelta, + FrozenDelta: message.FrozenDelta, + PayloadJSON: message.PayloadJSON, + CreatedAtMS: message.OccurredAtMS, + } + if event.PayloadJSON == "" { + event.PayloadJSON = "{}" + } + if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" { + return WalletBalanceEvent{}, fmt.Errorf("wallet outbox message is incomplete") + } + if !isWalletPrivateNoticeEvent(event.EventType) { + return WalletBalanceEvent{}, fmt.Errorf("unexpected wallet event type %q", event.EventType) + } + if event.UserID <= 0 { + return WalletBalanceEvent{}, fmt.Errorf("wallet notice user_id is invalid") + } + return event, nil +} + func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) { var event WalletBalanceEvent query := fmt.Sprintf(` @@ -241,10 +340,7 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev UPDATE notice_delivery_events SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? - AND ( - (status = ? AND next_retry_at_ms <= ?) - OR (status = ? AND lock_until_ms <= ?) - )`, + AND status = ? AND next_retry_at_ms <= ?`, deliveryStatusDelivering, workerID, lockUntilMS, @@ -256,16 +352,40 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev channelTencentIMC2C, deliveryStatusRetryable, nowMs, - deliveryStatusDelivering, - nowMs, ) if err != nil { return 0, false, err } affected, err := result.RowsAffected() - if err != nil || affected == 0 { + if err != nil { return 0, false, err } + if affected == 0 { + result, err = tx.ExecContext(ctx, ` + UPDATE notice_delivery_events + SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? + AND status = ? AND lock_until_ms <= ?`, + deliveryStatusDelivering, + workerID, + lockUntilMS, + event.PayloadJSON, + nowMs, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + deliveryStatusDelivering, + nowMs, + ) + if err != nil { + return 0, false, err + } + affected, err = result.RowsAffected() + if err != nil || affected == 0 { + return 0, false, err + } + } var retryCount int err = tx.QueryRowContext(ctx, ` @@ -369,6 +489,14 @@ func sqlPlaceholders(count int) string { return strings.Join(parts, ",") } +func stringSliceToAny(values []string) []any { + out := make([]any, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + return out +} + func isDuplicateKey(err error) bool { if err == nil { return false diff --git a/services/notice-service/internal/modules/walletnotice/service.go b/services/notice-service/internal/modules/walletnotice/service.go index 135fda1c..73d3fc2a 100644 --- a/services/notice-service/internal/modules/walletnotice/service.go +++ b/services/notice-service/internal/modules/walletnotice/service.go @@ -10,11 +10,13 @@ import ( "hyapp/pkg/logx" "hyapp/pkg/tencentim" + "hyapp/pkg/walletmq" ) // Repository 是 notice-service 需要的持久化能力。 type Repository interface { ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error) + ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error @@ -65,6 +67,26 @@ func (s *Service) ProcessWalletBalanceNotices(ctx context.Context, options Walle return processed, nil } +// ProcessWalletOutboxMessage handles one wallet_outbox MQ message. Unknown +// event types are acknowledged by returning handled=false. +func (s *Service) ProcessWalletOutboxMessage(ctx context.Context, message walletmq.WalletOutboxMessage, options WalletNoticeWorkerOptions) (bool, error) { + if !isWalletPrivateNoticeEvent(message.EventType) { + return false, nil + } + options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID) + if s.repository == nil { + return true, fmt.Errorf("notice repository is not configured") + } + if s.publisher == nil { + return true, fmt.Errorf("notice publisher is not configured") + } + event, claimed, err := s.repository.ClaimWalletBalanceMessage(ctx, options.WorkerID, message, options.LockTTL) + if err != nil || !claimed { + return true, err + } + return true, s.publishWalletBalanceEvent(ctx, event, options) +} + func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions) error { payload, err := walletBalanceNoticePayload(event) if err != nil { @@ -131,6 +153,15 @@ func walletNoticeExt(eventType string) string { return "wallet_notice" } +func isWalletPrivateNoticeEvent(eventType string) bool { + for _, expected := range walletPrivateNoticeEventTypes() { + if eventType == expected { + return true + } + } + return false +} + func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) { var payload map[string]any if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil { diff --git a/services/notice-service/internal/modules/walletnotice/service_test.go b/services/notice-service/internal/modules/walletnotice/service_test.go index c1fd9d1e..45bec50c 100644 --- a/services/notice-service/internal/modules/walletnotice/service_test.go +++ b/services/notice-service/internal/modules/walletnotice/service_test.go @@ -8,6 +8,7 @@ import ( "time" "hyapp/pkg/tencentim" + "hyapp/pkg/walletmq" ) func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) { @@ -86,6 +87,34 @@ func TestProcessWalletBalanceNoticesPublishesVipActivatedIM(t *testing.T) { } } +func TestProcessWalletOutboxMessagePublishesPrivateIM(t *testing.T) { + repo := &fakeRepository{} + publisher := &fakePublisher{} + service := New(Config{NodeID: "node-a"}, repo, publisher) + + handled, err := service.ProcessWalletOutboxMessage(context.Background(), walletmq.WalletOutboxMessage{ + AppCode: "lalu", + EventID: "evt-mq-1", + EventType: "WalletBalanceChanged", + TransactionID: "tx-mq-1", + CommandID: "cmd-mq-1", + UserID: 789, + AssetType: "COIN", + AvailableDelta: 30, + PayloadJSON: `{"available_after":130}`, + OccurredAtMS: 1710000000000, + }, WalletNoticeWorkerOptions{MaxRetryCount: 3}) + if err != nil { + t.Fatalf("ProcessWalletOutboxMessage failed: %v", err) + } + if !handled || len(repo.claimedMessages) != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 1 { + t.Fatalf("unexpected mq result: handled=%v claimed=%d delivered=%d messages=%d", handled, len(repo.claimedMessages), len(repo.delivered), len(publisher.messages)) + } + if publisher.messages[0].ToAccount != "789" || publisher.messages[0].EventID != "evt-mq-1" { + t.Fatalf("unexpected mq c2c message: %+v", publisher.messages[0]) + } +} + func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) { repo := &fakeRepository{ events: []WalletBalanceEvent{{ @@ -117,10 +146,11 @@ func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) { } type fakeRepository struct { - events []WalletBalanceEvent - delivered []WalletBalanceEvent - retryable []retryMarker - failed []WalletBalanceEvent + events []WalletBalanceEvent + claimedMessages []walletmq.WalletOutboxMessage + delivered []WalletBalanceEvent + retryable []retryMarker + failed []WalletBalanceEvent } type retryMarker struct { @@ -134,6 +164,23 @@ func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int, return r.events, nil } +func (r *fakeRepository) ClaimWalletBalanceMessage(_ context.Context, _ string, message walletmq.WalletOutboxMessage, _ time.Duration) (WalletBalanceEvent, bool, error) { + r.claimedMessages = append(r.claimedMessages, message) + return WalletBalanceEvent{ + AppCode: message.AppCode, + EventID: message.EventID, + EventType: message.EventType, + TransactionID: message.TransactionID, + CommandID: message.CommandID, + UserID: message.UserID, + AssetType: message.AssetType, + AvailableDelta: message.AvailableDelta, + FrozenDelta: message.FrozenDelta, + PayloadJSON: message.PayloadJSON, + CreatedAtMS: message.OccurredAtMS, + }, true, nil +} + func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ json.RawMessage, _ int64) error { r.delivered = append(r.delivered, event) return nil diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index 494357e1..26d3b27e 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -143,9 +143,11 @@ CREATE TABLE IF NOT EXISTS room_outbox ( last_error TEXT NULL COMMENT '最后一次失败原因', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, event_id), - KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms), - KEY idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms), - KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms) + KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_room_outbox_retry (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 COMMENT='房间事件 outbox 表'; CREATE TABLE IF NOT EXISTS room_user_feed_entries ( diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index 4c9f4cef..5f984096 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -368,6 +368,12 @@ type SendGift struct { PoolID string `json:"pool_id,omitempty"` // GiftCount 是礼物数量,必须为正数。 GiftCount int32 `json:"gift_count"` + // TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。 + TargetIsHost bool `json:"target_is_host,omitempty"` + // TargetHostRegionID 是工资政策匹配区域;room-service 不自行推导主播身份或区域。 + TargetHostRegionID int64 `json:"target_host_region_id,omitempty"` + // TargetAgencyOwnerUserID 是 gateway 从 user-service active host profile 注入的代理收款人快照。 + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"` // BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。 BillingReceiptID string `json:"billing_receipt_id,omitempty"` // CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。 @@ -380,6 +386,10 @@ type SendGift struct { PriceVersion string `json:"price_version,omitempty"` // GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。 GiftTypeCode string `json:"gift_type_code,omitempty"` + // HostPeriodDiamondAdded 是 wallet-service 给主播周期钻石账户本次入账的钻石数。 + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added,omitempty"` + // HostPeriodCycleKey 是本次周期钻石入账所属 UTC 月周期。 + HostPeriodCycleKey string `json:"host_period_cycle_key,omitempty"` // TreasureTouched 表示本次送礼已经计算过宝箱状态,恢复时直接使用下列确定性快照。 TreasureTouched bool `json:"treasure_touched,omitempty"` // TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。 @@ -473,6 +483,8 @@ func IdempotencyPayload(payload []byte) ([]byte, error) { delete(values, "heat_value") delete(values, "price_version") delete(values, "gift_type_code") + delete(values, "host_period_diamond_added") + delete(values, "host_period_cycle_key") delete(values, "treasure_touched") delete(values, "treasure_added_energy") delete(values, "treasure_effective_energy") diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index eb4135ff..94eed286 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -30,6 +30,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r GiftID: req.GetGiftId(), PoolID: strings.TrimSpace(req.GetPoolId()), GiftCount: req.GetGiftCount(), + TargetIsHost: req.GetTargetIsHost(), + // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 + TargetHostRegionID: req.GetTargetHostRegionId(), + TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), } if cmd.TargetType == "" { cmd.TargetType = "user" @@ -97,14 +101,17 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 walletStartedAt := time.Now() billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{ - CommandId: cmd.ID(), - RoomId: cmd.RoomID(), - SenderUserId: cmd.ActorUserID(), - TargetUserId: cmd.TargetUserID, - GiftId: cmd.GiftID, - GiftCount: cmd.GiftCount, - AppCode: appcode.FromContext(ctx), - RegionId: roomMeta.VisibleRegionID, + CommandId: cmd.ID(), + RoomId: cmd.RoomID(), + SenderUserId: cmd.ActorUserID(), + TargetUserId: cmd.TargetUserID, + GiftId: cmd.GiftID, + GiftCount: cmd.GiftCount, + AppCode: appcode.FromContext(ctx), + RegionId: roomMeta.VisibleRegionID, + TargetIsHost: cmd.TargetIsHost, + TargetHostRegionId: cmd.TargetHostRegionID, + TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID, }) walletDebitMS := elapsedMS(walletStartedAt) if err != nil { @@ -121,6 +128,8 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r settledCommand.HeatValue = heatValue settledCommand.PriceVersion = billing.GetPriceVersion() settledCommand.GiftTypeCode = billing.GetGiftTypeCode() + settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded() + settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey() if !luckyEnabled { // 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。 luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode()) @@ -255,13 +264,15 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r RoomHeat: current.Heat, RoomVersion: current.Version, Attributes: map[string]string{ - "gift_id": cmd.GiftID, - "pool_id": cmd.PoolID, - "gift_count": fmt.Sprintf("%d", cmd.GiftCount), - "billing_receipt_id": billing.GetBillingReceiptId(), - "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), - "gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded), - "price_version": settledCommand.PriceVersion, + "gift_id": cmd.GiftID, + "pool_id": cmd.PoolID, + "gift_count": fmt.Sprintf("%d", cmd.GiftCount), + "billing_receipt_id": billing.GetBillingReceiptId(), + "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), + "gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded), + "price_version": settledCommand.PriceVersion, + "host_period_diamond_added": fmt.Sprintf("%d", settledCommand.HostPeriodDiamondAdded), + "host_period_cycle_key": settledCommand.HostPeriodCycleKey, }, }, }, records, nil diff --git a/services/room-service/internal/room/service/online_users_test.go b/services/room-service/internal/room/service/online_users_test.go index f06167eb..025e605a 100644 --- a/services/room-service/internal/room/service/online_users_test.go +++ b/services/room-service/internal/room/service/online_users_test.go @@ -93,3 +93,42 @@ func TestListRoomOnlineUsersReturnsOwnerAdminRoleAndGiftValue(t *testing.T) { } t.Logf("online users response items: %s", payload) } + +func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{ + BillingReceiptId: "receipt-host-period", + CoinSpent: 14, + GiftPointAdded: 6, + HeatValue: 22, + GiftTypeCode: "normal", + HostPeriodDiamondAdded: 14, + HostPeriodCycleKey: "2026-05", + }}} + now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)} + svc := newTreasureTestService(t, repository, wallet, now) + + roomID := "room-host-period-scope" + senderID := int64(2001) + hostID := int64(2002) + createTreasureRoom(t, ctx, svc, roomID, senderID, 9001) + joinTreasureRoom(t, ctx, svc, roomID, hostID) + + if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: treasureMeta(roomID, senderID, "host-period-scope"), + TargetType: "user", + TargetUserId: hostID, + GiftId: "gift-host-period", + GiftCount: 2, + TargetIsHost: true, + TargetHostRegionId: 8801, + TargetAgencyOwnerUserId: 30001, + }); err != nil { + t.Fatalf("send host period gift failed: %v", err) + } + + if wallet.lastDebit == nil || !wallet.lastDebit.GetTargetIsHost() || wallet.lastDebit.GetTargetHostRegionId() != 8801 || wallet.lastDebit.GetTargetAgencyOwnerUserId() != 30001 { + t.Fatalf("room-service must pass host period scope to wallet: %+v", wallet.lastDebit) + } +} diff --git a/services/room-service/internal/room/service/room_treasure_test.go b/services/room-service/internal/room/service/room_treasure_test.go index acc47c2e..f8997ddf 100644 --- a/services/room-service/internal/room/service/room_treasure_test.go +++ b/services/room-service/internal/room/service/room_treasure_test.go @@ -29,11 +29,13 @@ func (c *fixedRoomTreasureClock) Now() time.Time { } type treasureTestWallet struct { - debits []*walletv1.DebitGiftResponse - grants []*walletv1.GrantResourceGroupRequest + debits []*walletv1.DebitGiftResponse + grants []*walletv1.GrantResourceGroupRequest + lastDebit *walletv1.DebitGiftRequest } -func (w *treasureTestWallet) DebitGift(_ context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { +func (w *treasureTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { + w.lastDebit = req if len(w.debits) == 0 { return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil } diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index eda97585..8560606d 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -199,8 +199,10 @@ func (r *Repository) Migrate(ctx context.Context) error { 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), - KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms) + 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_user_feed_entries ( app_code VARCHAR(32) NOT NULL, @@ -425,14 +427,26 @@ func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error { return err } } - hasIndex, err := r.indexExists(ctx, "room_outbox", "idx_room_outbox_retry") - if 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)`}, } - if !hasIndex { - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms)`); err != nil { + 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 } @@ -1172,15 +1186,14 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox 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 room_outbox + FROM room_outbox FORCE INDEX (idx_room_outbox_pending) WHERE app_code = ? AND status IN (?, ?) - AND (status = ? OR next_retry_at_ms <= ?) + AND next_retry_at_ms <= ? ORDER BY created_at_ms ASC LIMIT ?`, appcode.FromContext(ctx), outbox.StatusPending, outbox.StatusRetryable, - outbox.StatusPending, nowMS, limit, ) @@ -1235,68 +1248,52 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li } nowMS := time.Now().UTC().UnixMilli() - rows, err := tx.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 room_outbox - WHERE app_code = ? - AND ( - status = ? - OR (status = ? AND next_retry_at_ms <= ?) - OR (status = ? AND lock_until_ms IS NOT NULL AND lock_until_ms <= ?) - ) - ORDER BY created_at_ms ASC - LIMIT ? - FOR UPDATE SKIP LOCKED`, - appcode.FromContext(ctx), - outbox.StatusPending, - outbox.StatusRetryable, - nowMS, - outbox.StatusDelivering, - nowMS, - limit, - ) - if err != nil { - _ = tx.Rollback() - return nil, err - } - + appCode := appcode.FromContext(ctx) records := make([]outbox.Record, 0, limit) - eventIDs := make([]string, 0, limit) - 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 { - _ = rows.Close() + 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 room_outbox FORCE INDEX (idx_room_outbox_pending) + 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 room_outbox FORCE INDEX (idx_room_outbox_pending) + 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 room_outbox FORCE INDEX (idx_room_outbox_claim) + 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 } - if lockUntilValue.Valid { - record.LockUntilMS = lockUntilValue.V - } - - var envelope roomeventsv1.EventEnvelope - if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { - _ = rows.Close() - _ = tx.Rollback() - return nil, err - } - - record.Envelope = &envelope - record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode) - records = append(records, record) - eventIDs = append(eventIDs, record.EventID) - } - if err := rows.Close(); err != nil { - _ = tx.Rollback() - return nil, err - } - if err := rows.Err(); err != nil { - _ = tx.Rollback() - return nil, err + records = append(records, branchRecords...) } - for _, eventID := range eventIDs { + for _, record := range records { if _, err := tx.ExecContext(ctx, `UPDATE room_outbox SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ? @@ -1305,8 +1302,8 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li workerID, lockUntilMS, nowMS, - appcode.FromContext(ctx), - eventID, + appCode, + record.EventID, ); err != nil { _ = tx.Rollback() return nil, err @@ -1326,6 +1323,40 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li 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 { // 成功后清空 last_error,后续扫描不再返回该事件。 diff --git a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql index 75ed90da..34e69490 100644 --- a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql +++ b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql @@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS stat_app_day_country ( google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值美元最小单位', gift_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '礼物消耗金币', lucky_gift_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物流水', + lucky_gift_payout BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物返奖', lucky_gift_payers BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物付费去重人数', game_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '游戏流水', game_players BIGINT NOT NULL DEFAULT 0 COMMENT '游戏参与去重人数', diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index 36d19fac..97cdb79a 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -146,10 +146,17 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq } if err := add(cfg.RocketMQ.WalletOutbox, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := rechargeEvent(message.Body) + if err != nil { + return err + } + if ok { + return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event) + } + reward, ok, err := luckyGiftRewardEvent(message.Body) if err != nil || !ok { return err } - return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event) + return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward) }); err != nil { shutdownConsumers(consumers) return nil, err @@ -220,19 +227,41 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) { return mysqlstorage.RechargeEvent{}, false, nil } payload := mysqlstorage.DecodeJSON(message.PayloadJSON) + usdMinor := firstNonZeroInt64(mysqlstorage.Int64(payload, "recharge_usd_minor"), mysqlstorage.Int64(payload, "amount_micro")) + regionID := firstNonZeroInt64(mysqlstorage.Int64(payload, "target_region_id"), mysqlstorage.Int64(payload, "region_id")) return mysqlstorage.RechargeEvent{ AppCode: message.AppCode, EventID: message.EventID, EventType: message.EventType, UserID: message.UserID, - USDMinor: mysqlstorage.Int64(payload, "recharge_usd_minor"), + USDMinor: usdMinor, RechargeSequence: mysqlstorage.Int64(payload, "recharge_sequence"), - TargetRegionID: mysqlstorage.Int64(payload, "target_region_id"), - RechargeType: firstNonEmpty(mysqlstorage.String(payload, "recharge_type"), "coin_seller_transfer"), + TargetRegionID: regionID, + RechargeType: firstNonEmpty(mysqlstorage.String(payload, "recharge_type"), mysqlstorage.String(payload, "channel"), mysqlstorage.String(payload, "provider"), "coin_seller_transfer"), OccurredAtMS: message.OccurredAtMS, }, true, nil } +func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool, error) { + message, err := walletmq.DecodeWalletOutboxMessage(body) + if err != nil { + return mysqlstorage.LuckyGiftRewardEvent{}, false, err + } + if message.EventType != "WalletLuckyGiftRewardCredited" { + return mysqlstorage.LuckyGiftRewardEvent{}, false, nil + } + payload := mysqlstorage.DecodeJSON(message.PayloadJSON) + return mysqlstorage.LuckyGiftRewardEvent{ + AppCode: message.AppCode, + EventID: message.EventID, + EventType: message.EventType, + UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID), + CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id")), + Amount: mysqlstorage.Int64(payload, "amount"), + OccurredAtMS: message.OccurredAtMS, + }, true, nil +} + func roomGiftEvent(body []byte) (mysqlstorage.RoomGiftEvent, bool, error) { envelope, _, err := roommq.DecodeRoomOutboxMessage(body) if err != nil { @@ -291,7 +320,7 @@ func gameOrderEvent(body []byte) (mysqlstorage.GameOrderEvent, bool, error) { AppCode: message.AppCode, EventID: message.EventID, UserID: message.UserID, - CountryID: mysqlstorage.Int64(payload, "visible_region_id"), + CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id")), PlatformCode: message.PlatformCode, GameID: message.GameID, OpType: message.OpType, @@ -345,3 +374,12 @@ func firstNonEmpty(values ...string) string { } return "" } + +func firstNonZeroInt64(values ...int64) int64 { + for _, value := range values { + if value != 0 { + return value + } + } + return 0 +} diff --git a/services/statistics-service/internal/app/local_flow_test.go b/services/statistics-service/internal/app/local_flow_test.go new file mode 100644 index 00000000..ef679489 --- /dev/null +++ b/services/statistics-service/internal/app/local_flow_test.go @@ -0,0 +1,214 @@ +package app + +import ( + "context" + "encoding/json" + "runtime" + "testing" + "time" + + "google.golang.org/protobuf/proto" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/internal/testutil/mysqlschema" + "hyapp/pkg/gamemq" + "hyapp/pkg/roommq" + "hyapp/pkg/walletmq" + mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql" +) + +func TestLocalStatisticsDataScreenFlow(t *testing.T) { + ctx := context.Background() + _, file, _, _ := runtime.Caller(0) + schema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"), + DatabasePrefix: "hy_stats_flow", + }) + repo, err := mysqlstorage.Open(ctx, schema.DSN) + if err != nil { + t.Fatalf("open statistics repository failed: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + + occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli() + dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli() + const ( + appCode = "lalu" + regionID = int64(210) + userID = int64(42) + ) + + googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{ + AppCode: appCode, + EventID: "WalletRechargeRecorded:google:1", + EventType: "WalletRechargeRecorded", + TransactionID: "wallet_tx_google_1", + CommandID: "google_order_1", + UserID: userID, + PayloadJSON: mustJSON(t, map[string]any{ + "amount_micro": int64(1_500_000), + "region_id": regionID, + "channel": "google", + "recharge_sequence": int64(1), + }), + OccurredAtMS: occurredAt, + }) + recharge, ok, err := rechargeEvent(googleRecharge) + if err != nil || !ok { + t.Fatalf("decode google recharge failed: ok=%v err=%v", ok, err) + } + if err := repo.ConsumeRecharge(ctx, recharge); err != nil { + t.Fatalf("consume google recharge failed: %v", err) + } + + roomGiftBody := roomGiftMessage(t, appCode, "room_gift_1", "room_1", occurredAt, &roomeventsv1.RoomGiftSent{ + SenderUserId: userID, + TargetUserId: 99, + GiftId: "rose", + GiftCount: 10, + GiftValue: 1_000, + VisibleRegionId: regionID, + CommandId: "gift_command_1", + PoolId: "lucky_100", + }) + gift, ok, err := roomGiftEvent(roomGiftBody) + if err != nil || !ok { + t.Fatalf("decode room gift failed: ok=%v err=%v", ok, err) + } + if err := repo.ConsumeRoomGift(ctx, gift); err != nil { + t.Fatalf("consume room gift failed: %v", err) + } + + luckyReward := walletMessage(t, walletmq.WalletOutboxMessage{ + AppCode: appCode, + EventID: "WalletLuckyGiftRewardCredited:1", + EventType: "WalletLuckyGiftRewardCredited", + TransactionID: "wallet_tx_lucky_1", + CommandID: "lucky_reward:lucky_draw_1", + UserID: userID, + PayloadJSON: mustJSON(t, map[string]any{ + "user_id": userID, + "draw_id": "lucky_draw_1", + "room_id": "room_1", + "pool_id": "lucky_100", + "amount": int64(300), + "visible_region_id": regionID, + }), + OccurredAtMS: occurredAt, + }) + reward, ok, err := luckyGiftRewardEvent(luckyReward) + if err != nil || !ok { + t.Fatalf("decode lucky reward failed: ok=%v err=%v", ok, err) + } + if err := repo.ConsumeLuckyGiftReward(ctx, reward); err != nil { + t.Fatalf("consume lucky reward failed: %v", err) + } + + for _, message := range []gamemq.GameOutboxMessage{ + gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, regionID, occurredAt), + gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, regionID, occurredAt), + } { + body, err := gamemq.EncodeGameOutboxMessage(message) + if err != nil { + t.Fatalf("encode game message failed: %v", err) + } + event, ok, err := gameOrderEvent(body) + if err != nil || !ok { + t.Fatalf("decode game order failed: ok=%v err=%v", ok, err) + } + if err := repo.ConsumeGameOrder(ctx, event); err != nil { + t.Fatalf("consume game order failed: %v", err) + } + } + + overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{ + AppCode: appCode, + StartMS: dayStart, + EndMS: dayStart + int64(24*time.Hour/time.Millisecond), + CountryID: regionID, + }) + if err != nil { + t.Fatalf("query overview failed: %v", err) + } + if overview.GoogleRechargeUSDMinor != 1_500_000 || overview.RechargeUSDMinor != 1_500_000 || overview.NewUserRechargeUSDMinor != 1_500_000 { + t.Fatalf("google recharge metrics mismatch: %+v", overview) + } + if overview.LuckyGiftTurnover != 1_000 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 700 || overview.LuckyGiftPayers != 1 { + t.Fatalf("lucky gift metrics mismatch: %+v", overview) + } + if overview.GameTurnover != 700 || overview.GamePayout != 200 || overview.GameProfit != 500 || overview.GamePlayers != 1 { + t.Fatalf("game metrics mismatch: %+v", overview) + } +} + +func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte { + t.Helper() + body, err := walletmq.EncodeWalletOutboxMessage(message) + if err != nil { + t.Fatalf("encode wallet message failed: %v", err) + } + return body +} + +func roomGiftMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, gift *roomeventsv1.RoomGiftSent) []byte { + t.Helper() + giftBody, err := proto.Marshal(gift) + if err != nil { + t.Fatalf("marshal gift failed: %v", err) + } + body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{ + EventId: eventID, + RoomId: roomID, + EventType: "RoomGiftSent", + RoomVersion: 1, + OccurredAtMs: occurredAt, + Body: giftBody, + AppCode: appCode, + }) + if err != nil { + t.Fatalf("encode room message failed: %v", err) + } + return body +} + +func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage { + return gamemq.GameOutboxMessage{ + AppCode: appCode, + EventID: "GameOrderSettled:" + orderID, + EventType: "GameOrderSettled", + OrderID: orderID, + UserID: userID, + PlatformCode: "yomi", + GameID: "fish_001", + OpType: opType, + CoinAmount: amount, + PayloadJSON: mustJSONNoT(map[string]any{ + "order_id": orderID, + "platform_code": "yomi", + "game_id": "fish_001", + "user_id": userID, + "room_id": "room_1", + "visible_region_id": regionID, + "region_id": regionID, + "op_type": opType, + "coin_amount": amount, + }), + OccurredAtMS: occurredAt, + } +} + +func mustJSON(t *testing.T, value map[string]any) string { + t.Helper() + body, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal json failed: %v", err) + } + return string(body) +} + +func mustJSONNoT(value map[string]any) string { + body, err := json.Marshal(value) + if err != nil { + panic(err) + } + return string(body) +} diff --git a/services/statistics-service/internal/storage/mysql/query.go b/services/statistics-service/internal/storage/mysql/query.go index 552e94fd..59f81a44 100644 --- a/services/statistics-service/internal/storage/mysql/query.go +++ b/services/statistics-service/internal/storage/mysql/query.go @@ -30,7 +30,11 @@ type Overview struct { GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` GiftCoinSpent int64 `json:"gift_coin_spent"` LuckyGiftTurnover int64 `json:"lucky_gift_turnover"` + LuckyGiftPayout int64 `json:"lucky_gift_payout"` LuckyGiftPayers int64 `json:"lucky_gift_payers"` + LuckyGiftProfit int64 `json:"lucky_gift_profit"` + LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"` + LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"` GameTurnover int64 `json:"game_turnover"` GamePayout int64 `json:"game_payout"` GameRefund int64 `json:"game_refund"` @@ -83,16 +87,19 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0), COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(gift_coin_spent),0), - COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payers),0), + COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0), COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0), COALESCE(SUM(game_players),0) FROM stat_app_day_country WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+countryFilter, args...) var overview Overview overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID - if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil { + if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil { return Overview{}, err } + overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout + overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover) + overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover) overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover) overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers) diff --git a/services/statistics-service/internal/storage/mysql/repository.go b/services/statistics-service/internal/storage/mysql/repository.go index eb5bc2c9..f7025333 100644 --- a/services/statistics-service/internal/storage/mysql/repository.go +++ b/services/statistics-service/internal/storage/mysql/repository.go @@ -75,7 +75,7 @@ func (r *Repository) Migrate(ctx context.Context) error { recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, gift_coin_spent BIGINT NOT NULL DEFAULT 0, - lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0, + lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0, game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0, game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, stat_day, country_id) @@ -124,6 +124,7 @@ func (r *Repository) Migrate(ctx context.Context) error { alterStatements := []string{ `ALTER TABLE stat_app_day_country ADD COLUMN paid_users BIGINT NOT NULL DEFAULT 0 AFTER active_users`, `ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`, + `ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`, `ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`, } for _, statement := range alterStatements { @@ -164,6 +165,16 @@ type RoomGiftEvent struct { OccurredAtMS int64 } +type LuckyGiftRewardEvent struct { + AppCode string + EventID string + EventType string + UserID int64 + CountryID int64 + Amount int64 + OccurredAtMS int64 +} + type UserActiveEvent struct { AppCode string EventID string @@ -227,7 +238,7 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e } coinSellerUSD, mifapayUSD, googleUSD := int64(0), int64(0), int64(0) switch strings.ToLower(strings.TrimSpace(event.RechargeType)) { - case "google": + case "google", "google_play": googleUSD = event.USDMinor case "mifapay": mifapayUSD = event.USDMinor @@ -278,6 +289,22 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e }) } +func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGiftRewardEvent) error { + return r.withEvent(ctx, SourceWallet, event.EventID, "WalletLuckyGiftRewardCredited", func(tx *sql.Tx, nowMS int64) error { + day := statDay(event.OccurredAtMS) + countryID := normalizeID(event.CountryID) + if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil { + return err + } + _, err := tx.ExecContext(ctx, ` + UPDATE stat_app_day_country + SET lucky_gift_payout = lucky_gift_payout + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_day = ? AND country_id = ? + `, event.Amount, nowMS, appcode.Normalize(event.AppCode), day, countryID) + return err + }) +} + func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error { return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error { return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), normalizeID(event.CountryID), event.UserID, event.OccurredAtMS, nowMS) diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 079ac0c2..b9dece67 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -401,11 +401,18 @@ CREATE TABLE IF NOT EXISTS user_outbox ( aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id BIGINT NOT NULL COMMENT '聚合 ID', status VARCHAR(32) NOT NULL COMMENT '业务状态', + worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID', + lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期时间,UTC epoch ms', + retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次 MQ 投递重试时间,UTC epoch ms', + last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因', payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, event_id), - KEY idx_user_outbox_status_created (app_code, status, created_at_ms), + KEY idx_user_outbox_status_created (app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_user_outbox_lock (app_code, status, lock_until_ms, created_at_ms, event_id), + KEY idx_user_outbox_retention (app_code, status, updated_at_ms, event_id), KEY idx_user_outbox_aggregate (app_code, aggregate_type, aggregate_id, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户发件箱表'; diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index d3e2e257..ebc03fd1 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -433,7 +433,8 @@ func (a *App) processUserOutboxBatch(workerID string) (int, error) { cancel() markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), a.cfg.OutboxWorker.PublishTimeout) if err != nil { - markErr := a.mysqlRepo.MarkUserOutboxPending(markCtx, record.EventID) + nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli() + markErr := a.mysqlRepo.MarkUserOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS) markCancel() if markErr != nil { return 0, markErr diff --git a/services/user-service/internal/domain/host/host.go b/services/user-service/internal/domain/host/host.go index d890c8c0..96a29a92 100644 --- a/services/user-service/internal/domain/host/host.go +++ b/services/user-service/internal/domain/host/host.go @@ -135,10 +135,12 @@ type HostProfile struct { RegionID int64 CurrentAgencyID int64 CurrentMembershipID int64 - Source string - FirstBecameHostAtMs int64 - CreatedAtMs int64 - UpdatedAtMs int64 + // CurrentAgencyOwnerUserID 是当前 Agency 的 owner 用户 ID,供 wallet 在送礼时固化代理工资收款人。 + CurrentAgencyOwnerUserID int64 + Source string + FirstBecameHostAtMs int64 + CreatedAtMs int64 + UpdatedAtMs int64 } // Agency 是主播组织事实,由一个用户拥有并归属于 BD。 @@ -148,6 +150,8 @@ type Agency struct { RegionID int64 ParentBDUserID int64 Name string + OwnerAvatar string + ActiveHostCount int32 Status string JoinEnabled bool MaxHosts int32 diff --git a/services/user-service/internal/service/auth/quick_account.go b/services/user-service/internal/service/auth/quick_account.go index c170aa1d..7f99cd26 100644 --- a/services/user-service/internal/service/auth/quick_account.go +++ b/services/user-service/internal/service/auth/quick_account.go @@ -28,9 +28,9 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis // refresh session 必须绑定设备,平台也会进入注册来源和风控审计。 return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "device_id and platform are required") } - if registration.Username == "" || registration.Avatar == "" || registration.Gender == "" || registration.Country == "" { + if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil { // 快捷账号直接跳过 onboarding,因此必须一次性写齐客户端准入所需的最小资料。 - return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "username, avatar, gender and country are required") + return authdomain.Token{}, err } if err := validateThirdPartyRegistration(registration); err != nil { return authdomain.Token{}, err diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index 77a8d94f..ed697bf4 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -126,8 +126,15 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6 } func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration { - // 三方登录是注册入口,测试默认同时提供设备和平台,避免生成缺失注册上下文的用户。 - return authdomain.ThirdPartyRegistration{DeviceID: "dev-" + platform, Platform: platform} + // 部分测试需要覆盖注册快照字段,因此默认提供完整资料;完成态仍必须由 onboarding 流程推进。 + return authdomain.ThirdPartyRegistration{ + Username: "hy", + Avatar: "https://cdn.example/avatar.png", + Gender: "male", + Country: "SG", + DeviceID: "dev-" + platform, + Platform: platform, + } } func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country { @@ -174,7 +181,7 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { // 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。 ctx := context.Background() repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "CN") + seedCountry(t, repository, "SG") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) @@ -286,14 +293,13 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) { } } -func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) { - // gateway profile gate 依赖 access token 快照;refresh 必须在用户完成注册后带出最新状态。 +func TestRefreshTokenCarriesProfileRequiredOnboardingStatus(t *testing.T) { + // gateway profile gate 依赖 access token 快照;未完成 onboarding 的新用户刷新后仍必须保持资料待补态。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"}) - userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"}) if err != nil { @@ -303,18 +309,13 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) { t.Fatalf("new token should require onboarding: %+v", token) } - now = time.UnixMilli(2000) - if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil { - t.Fatalf("CompleteOnboarding failed: %v", err) - } - now = time.UnixMilli(3000) refreshed, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{RequestID: "req-refresh"}) if err != nil { t.Fatalf("RefreshToken failed: %v", err) } - if !refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) { - t.Fatalf("refreshed token must carry completed onboarding: %+v", refreshed) + if refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { + t.Fatalf("refreshed token must carry profile-required onboarding: %+v", refreshed) } } @@ -322,6 +323,7 @@ func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") + seedCountry(t, repository, "SG") now := time.UnixMilli(1000) cache := newMemoryDecisionCache() authSvc := newAuthService(repository, &now, []int64{900020}, []string{"100020"}, @@ -392,6 +394,7 @@ func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "MY") + seedCountry(t, repository, "SG") now := time.UnixMilli(1000) if _, err := repository.RawDB().ExecContext(ctx, ` INSERT INTO login_risk_country_blocks ( @@ -443,31 +446,26 @@ func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) { } } -func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) { - // CompleteOnboarding 后只需要替换 access token;refresh token 原文不可从数据库反查,也不应被静默轮换。 +func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithoutRotatingRefresh(t *testing.T) { + // 重新签发 access token 只能复用已有 session;refresh token 原文不可从数据库反查,也不应被静默轮换。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"}) - userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } - now = time.UnixMilli(2000) - if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil { - t.Fatalf("CompleteOnboarding failed: %v", err) - } now = time.UnixMilli(3000) reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"}) if err != nil { t.Fatalf("IssueAccessTokenForSession failed: %v", err) } - if !reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || reissued.AccessToken == "" { - t.Fatalf("reissued token must carry completed onboarding: %+v", reissued) + if reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) || reissued.AccessToken == "" { + t.Fatalf("reissued token must carry profile-required onboarding: %+v", reissued) } if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" { t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued) @@ -479,6 +477,7 @@ func TestDisabledUserCannotLogin(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") + seedCountry(t, repository, "SG") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) @@ -507,6 +506,7 @@ func TestThirdPartyLoginOrRegister(t *testing.T) { // 首次三方登录创建用户,再次同 provider subject 登录应复用原用户。 ctx := context.Background() repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"}) @@ -517,8 +517,11 @@ func TestThirdPartyLoginOrRegister(t *testing.T) { if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" { t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) } + if firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { + t.Fatalf("first third-party registration must require onboarding: %+v", firstToken) + } - secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-second"}) + secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-second"}) if err != nil { t.Fatalf("second LoginThirdParty failed: %v", err) } @@ -532,6 +535,25 @@ func TestThirdPartyLoginOrRegister(t *testing.T) { } } +func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) { + // 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。 + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := time.UnixMilli(1000) + svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}) + + token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-minimal", authdomain.ThirdPartyRegistration{ + DeviceID: "dev-ios", + Platform: "ios", + }, authservice.Meta{}) + if err != nil { + t.Fatalf("LoginThirdParty with minimal registration failed: %v", err) + } + if !isNewUser || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { + t.Fatalf("minimal third-party registration must create profile-required user: token=%+v isNew=%v", token, isNewUser) + } +} + func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) { // 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。 ctx := context.Background() @@ -624,6 +646,9 @@ func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t * svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"}) firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{ + Username: "hy", + Avatar: "https://cdn.example/avatar.png", + Gender: "male", DeviceID: "ios", Platform: "ios", Country: " sg ", @@ -756,6 +781,7 @@ func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) { // 默认短号候选冲突时,三方注册应在有限次数内重试下一个候选。 ctx := context.Background() repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "163000", Status: userdomain.StatusActive}) now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001, 900002}, []string{"100001", "100002"}) @@ -779,6 +805,7 @@ func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) { // 密码登录必须使用当前 active display_user_id;靓号 active 时默认号不能登录。 ctx := context.Background() repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) diff --git a/services/user-service/internal/service/auth/third_party.go b/services/user-service/internal/service/auth/third_party.go index 6e38e41c..9aa39269 100644 --- a/services/user-service/internal/service/auth/third_party.go +++ b/services/user-service/internal/service/auth/third_party.go @@ -292,6 +292,15 @@ func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistrati return nil } +func validateThirdPartyRegistrationRequiredForCreate(registration authdomain.ThirdPartyRegistration) error { + if registration.Username == "" || registration.Country == "" || registration.Gender == "" || registration.Avatar == "" { + // 注册即落用户主数据;最小公开资料不完整时拒绝创建,避免后台出现空昵称、空头像或 GLOBAL 国家用户。 + return xerr.New(xerr.InvalidArgument, "username, country, gender and avatar are required") + } + + return nil +} + func validateRegistrationLengths(registration authdomain.ThirdPartyRegistration) error { checks := []struct { name string diff --git a/services/user-service/internal/service/host/commands.go b/services/user-service/internal/service/host/commands.go index a0bf1b26..51158198 100644 --- a/services/user-service/internal/service/host/commands.go +++ b/services/user-service/internal/service/host/commands.go @@ -1,6 +1,6 @@ package host -// SearchAgenciesCommand 描述同区域 Agency 搜索条件。 +// SearchAgenciesCommand 描述同国家 Agency 搜索条件。 type SearchAgenciesCommand struct { UserID int64 Keyword string diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index 320c6b4f..673c86f0 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -86,7 +86,7 @@ func WithClock(now func() time.Time) Option { } } -// SearchAgencies 返回当前用户同区域可加入的有效 Agency。 +// SearchAgencies 返回当前用户同国家可加入的有效 Agency。 func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword string, pageSize int32) ([]hostdomain.Agency, error) { if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "host repository is not configured") @@ -100,7 +100,7 @@ func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword stri limit = 50 } - // 业务层只负责输入归一化;用户区域过滤由持久化层读取 users 当前事实完成。 + // 业务层只负责输入归一化;用户国家过滤由持久化层读取 users 当前事实完成。 return s.repository.SearchAgencies(ctx, SearchAgenciesCommand{ UserID: userID, Keyword: strings.TrimSpace(keyword), diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index b75bdc8a..9462e76a 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -31,13 +31,19 @@ func newHostService(repository *mysqltest.Repository, firstID int64, nowMs int64 } func seedActiveUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) { + t.Helper() + seedActiveUserInCountry(t, repository, userID, regionID, "CN", "") +} + +func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64, country string, avatar string) { t.Helper() repository.PutUser(userdomain.User{ UserID: userID, DefaultDisplayUserID: displayID(userID), CurrentDisplayUserID: displayID(userID), - Country: "CN", + Country: country, RegionID: regionID, + Avatar: avatar, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, @@ -360,8 +366,9 @@ func TestAdminCreateBDLeaderRejectsDisabledRegion(t *testing.T) { func TestAdminCreateAgencyControlsAppSearch(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) - seedActiveUser(t, repository, 901, 30) + seedActiveUserInCountry(t, repository, 901, 30, "CN", "https://cdn.example/owner-901.png") seedActiveUser(t, repository, 902, 30) + seedActiveUserInCountry(t, repository, 903, 30, "US", "") repository.PutBDProfile(hostdomain.BDProfile{ UserID: 900, Role: hostdomain.BDRoleLeader, @@ -393,7 +400,27 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) { if created.Membership.MembershipType != hostdomain.MembershipTypeOwner || created.Membership.Status != hostdomain.MembershipStatusActive { t.Fatalf("owner membership mismatch: %+v", created.Membership) } - agencies, err := svc.SearchAgencies(ctx, 902, "Admin Seed", 20) + if _, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ + CommandID: "admin-create-agency-903", + AdminUserID: 1, + OwnerUserID: 903, + ParentBDUserID: 900, + Name: "Other Country Agency", + JoinEnabled: true, + MaxHosts: 100, + Reason: "seed agency", + RequestID: "req-agency-2", + }); err != nil { + t.Fatalf("CreateAgency in other country failed: %v", err) + } + agencies, err := svc.SearchAgencies(ctx, 902, "", 20) + if err != nil { + t.Fatalf("SearchAgencies list failed: %v", err) + } + if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID || agencies[0].OwnerAvatar != "https://cdn.example/owner-901.png" || agencies[0].ActiveHostCount != 1 { + t.Fatalf("created agency list should be scoped by country with display fields: %+v", agencies) + } + agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20) if err != nil { t.Fatalf("SearchAgencies failed: %v", err) } diff --git a/services/user-service/internal/storage/mysql/host/common.go b/services/user-service/internal/storage/mysql/host/common.go index ef1871af..69dd9e86 100644 --- a/services/user-service/internal/storage/mysql/host/common.go +++ b/services/user-service/internal/storage/mysql/host/common.go @@ -31,8 +31,16 @@ const ( // 可空外键在存储边界统一转成 0,让领域结构体保持普通值类型。 hostProfileColumns = ` user_id, status, region_id, COALESCE(current_agency_id, 0), - COALESCE(current_membership_id, 0), source, first_became_host_at_ms, - created_at_ms, updated_at_ms` + COALESCE(current_membership_id, 0), + COALESCE(( + SELECT owner_user_id + FROM agencies + WHERE agencies.app_code = host_profiles.app_code + AND agencies.agency_id = host_profiles.current_agency_id + AND agencies.status = 'active' + LIMIT 1 + ), 0), + source, first_became_host_at_ms, created_at_ms, updated_at_ms` agencyColumns = ` agency_id, owner_user_id, region_id, parent_bd_user_id, name, status, join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms` @@ -97,6 +105,27 @@ func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64, return regionID, nil } +func (r *Repository) userCountry(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (string, error) { + // App 侧 Agency 推荐按用户当前国家收敛;国家来自用户资料事实,不能由客户端传入。 + query := "SELECT COALESCE(country, ''), status FROM users WHERE app_code = ? AND user_id = ?" + if strings.TrimSpace(lockClause) != "" { + query += " " + lockClause + } + var country string + var status string + err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&country, &status) + if err == sql.ErrNoRows { + return "", xerr.New(xerr.NotFound, "user not found") + } + if err != nil { + return "", err + } + if status != "active" { + return "", xerr.New(xerr.UserDisabled, "user is not active") + } + return strings.TrimSpace(country), nil +} + func requireActiveRegion(ctx context.Context, q sqlQueryer, regionID int64) error { if regionID <= 0 { return xerr.New(xerr.InvalidArgument, "region_id is required") diff --git a/services/user-service/internal/storage/mysql/host/queries.go b/services/user-service/internal/storage/mysql/host/queries.go index 5ca03912..5da73090 100644 --- a/services/user-service/internal/storage/mysql/host/queries.go +++ b/services/user-service/internal/storage/mysql/host/queries.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "strconv" "strings" "hyapp/pkg/appcode" @@ -12,33 +13,68 @@ import ( hostservice "hyapp/services/user-service/internal/service/host" ) -// SearchAgencies 返回用户当前区域内可加入的有效 Agency。 +// SearchAgencies 返回用户当前国家内可加入的有效 Agency。 func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.SearchAgenciesCommand) ([]hostdomain.Agency, error) { - regionID, err := r.userRegion(ctx, r.db, command.UserID, "") + country, err := r.userCountry(ctx, r.db, command.UserID, "") if err != nil { return nil, err } - if regionID <= 0 { - // 区域是 Agency 搜索和申请的硬边界,缺失时不能退化成全局搜索。 - return nil, xerr.New(xerr.InvalidArgument, "user region is required") + if country == "" { + // 国家是 App 推荐列表的硬边界,缺失时不能退化成跨国或全局列表。 + return nil, xerr.New(xerr.InvalidArgument, "user country is required") } - query := fmt.Sprintf(` - SELECT %s - FROM agencies - WHERE app_code = ? AND region_id = ? AND status = ? AND join_enabled = TRUE - `, agencyColumns) - args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive} + query := ` + SELECT + a.agency_id, + a.owner_user_id, + a.region_id, + a.parent_bd_user_id, + a.name, + a.status, + a.join_enabled, + a.max_hosts, + a.created_by_user_id, + a.created_at_ms, + a.updated_at_ms, + COALESCE(owner.avatar, ''), + COUNT(active_member.membership_id) + FROM agencies a + INNER JOIN users owner + ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id + LEFT JOIN agency_memberships active_member + ON active_member.app_code = a.app_code + AND active_member.agency_id = a.agency_id + AND active_member.status = ? + WHERE a.app_code = ? + AND owner.country = ? + AND owner.status = 'active' + AND a.status = ? + AND a.join_enabled = TRUE + ` + args := []any{hostdomain.MembershipStatusActive, appcode.FromContext(ctx), country, hostdomain.AgencyStatusActive} if strings.TrimSpace(command.Keyword) != "" { keyword := strings.TrimSpace(command.Keyword) - query += ` AND (name LIKE ? OR owner_user_id IN ( - SELECT user_id - FROM users - WHERE app_code = ? AND current_display_user_id = ? - ))` - args = append(args, "%"+keyword+"%", appcode.FromContext(ctx), keyword) + query += ` AND (a.name LIKE ? OR a.agency_id = ? OR owner.current_display_user_id = ?)` + args = append(args, "%"+keyword+"%", int64FromKeyword(keyword), keyword) } - query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?" + query += ` + GROUP BY + a.agency_id, + a.owner_user_id, + a.region_id, + a.parent_bd_user_id, + a.name, + a.status, + a.join_enabled, + a.max_hosts, + a.created_by_user_id, + a.created_at_ms, + a.updated_at_ms, + owner.avatar + ORDER BY a.created_at_ms DESC, a.agency_id DESC + LIMIT ? + ` args = append(args, command.PageSize) rows, err := r.db.QueryContext(ctx, query, args...) @@ -49,7 +85,7 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea agencies := make([]hostdomain.Agency, 0, command.PageSize) for rows.Next() { - agency, err := scanAgency(rows) + agency, err := scanSearchAgency(rows) if err != nil { return nil, err } @@ -314,6 +350,7 @@ func scanHostProfile(scanner rowScanner) (hostdomain.HostProfile, error) { &profile.RegionID, &profile.CurrentAgencyID, &profile.CurrentMembershipID, + &profile.CurrentAgencyOwnerUserID, &profile.Source, &profile.FirstBecameHostAtMs, &profile.CreatedAtMs, @@ -382,6 +419,34 @@ func scanAgency(scanner rowScanner) (hostdomain.Agency, error) { return agency, err } +func scanSearchAgency(scanner rowScanner) (hostdomain.Agency, error) { + var agency hostdomain.Agency + err := scanner.Scan( + &agency.AgencyID, + &agency.OwnerUserID, + &agency.RegionID, + &agency.ParentBDUserID, + &agency.Name, + &agency.Status, + &agency.JoinEnabled, + &agency.MaxHosts, + &agency.CreatedByUserID, + &agency.CreatedAtMs, + &agency.UpdatedAtMs, + &agency.OwnerAvatar, + &agency.ActiveHostCount, + ) + return agency, err +} + +func int64FromKeyword(keyword string) int64 { + value, err := strconv.ParseInt(strings.TrimSpace(keyword), 10, 64) + if err != nil { + return 0 + } + return value +} + func queryBDProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.BDProfile, error) { clause, args = appScopedClause(ctx, clause, args...) profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_profiles %s`, bdProfileColumns, clause), args...)) diff --git a/services/user-service/internal/storage/mysql/user_outbox.go b/services/user-service/internal/storage/mysql/user_outbox.go index a9431573..047eb457 100644 --- a/services/user-service/internal/storage/mysql/user_outbox.go +++ b/services/user-service/internal/storage/mysql/user_outbox.go @@ -2,6 +2,8 @@ package mysql import ( "context" + "database/sql" + "strings" "time" "hyapp/pkg/appcode" @@ -17,6 +19,10 @@ type UserOutboxRecord struct { AggregateID int64 PayloadJSON string CreatedAtMS int64 + RetryCount int + LastError string + WorkerID string + LockUntilMS int64 } // ClaimPendingUserOutbox locks pending user facts so one worker can publish them. @@ -27,49 +33,102 @@ func (r *Repository) ClaimPendingUserOutbox(ctx context.Context, workerID string if batchSize <= 0 { batchSize = 100 } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + workerID = "user-outbox-worker" + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() - rows, err := tx.QueryContext(ctx, ` + nowMS := time.Now().UTC().UnixMilli() + lockUntilMS := nowMS + (30 * time.Second).Milliseconds() + appCode := appcode.FromContext(ctx) + records := make([]UserOutboxRecord, 0, batchSize) + claimBranches := []struct { + query string + args []any + }{ + { + query: ` SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms - FROM user_outbox - WHERE status = 'pending' + FROM user_outbox FORCE INDEX (idx_user_outbox_status_created) + WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0 ORDER BY created_at_ms ASC, event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED - `, batchSize) - if err != nil { - return nil, err + `, + args: []any{appCode, nowMS}, + }, + { + query: ` + SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms + FROM user_outbox FORCE INDEX (idx_user_outbox_status_created) + WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0 + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? FOR UPDATE SKIP LOCKED + `, + args: []any{appCode, nowMS}, + }, + { + query: ` + SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms + FROM user_outbox FORCE INDEX (idx_user_outbox_lock) + WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ? + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? FOR UPDATE SKIP LOCKED + `, + args: []any{appCode, nowMS}, + }, } - records := make([]UserOutboxRecord, 0, batchSize) - for rows.Next() { - var record UserOutboxRecord - if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil { - _ = rows.Close() + for _, branch := range claimBranches { + remaining := batchSize - len(records) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchRecords, err := queryUserOutboxRecords(ctx, tx, branch.query, args...) + if err != nil { return nil, err } - records = append(records, record) + records = append(records, branchRecords...) } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - nowMS := time.Now().UTC().UnixMilli() for _, record := range records { if _, err := tx.ExecContext(ctx, ` UPDATE user_outbox - SET status = 'running', updated_at_ms = ? - WHERE app_code = ? AND event_id = ? AND status = 'pending' - `, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil { + SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ? + `, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } + for index := range records { + records[index].WorkerID = workerID + records[index].LockUntilMS = lockUntilMS + } + return records, nil +} + +func queryUserOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]UserOutboxRecord, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + records := make([]UserOutboxRecord, 0) + for rows.Next() { + var record UserOutboxRecord + if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil { + return nil, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, err + } return records, nil } @@ -80,21 +139,30 @@ func (r *Repository) MarkUserOutboxDelivered(ctx context.Context, eventID string } _, err := r.db.ExecContext(ctx, ` UPDATE user_outbox - SET status = 'delivered', updated_at_ms = ? + SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ? WHERE app_code = ? AND event_id = ? `, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID) return err } -// MarkUserOutboxPending returns a failed publish to pending so the next tick retries. -func (r *Repository) MarkUserOutboxPending(ctx context.Context, eventID string) error { +// MarkUserOutboxRetryable releases a failed publish with a bounded retry delay. +func (r *Repository) MarkUserOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } _, err := r.db.ExecContext(ctx, ` UPDATE user_outbox - SET status = 'pending', updated_at_ms = ? + SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1, + next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ? AND status = 'running' - `, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID) + `, nextRetryAtMS, trimUserOutboxError(lastErr), time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID) return err } + +func trimUserOutboxError(value string) string { + value = strings.TrimSpace(value) + if len(value) > 512 { + return value[:512] + } + return value +} diff --git a/services/user-service/internal/transport/grpc/convert.go b/services/user-service/internal/transport/grpc/convert.go index c1f5fa06..50392bf9 100644 --- a/services/user-service/internal/transport/grpc/convert.go +++ b/services/user-service/internal/transport/grpc/convert.go @@ -293,15 +293,16 @@ func toProtoHostProfile(profile hostdomain.HostProfile) *userv1.HostProfile { return nil } return &userv1.HostProfile{ - UserId: profile.UserID, - Status: profile.Status, - RegionId: profile.RegionID, - CurrentAgencyId: profile.CurrentAgencyID, - CurrentMembershipId: profile.CurrentMembershipID, - Source: profile.Source, - FirstBecameHostAtMs: profile.FirstBecameHostAtMs, - CreatedAtMs: profile.CreatedAtMs, - UpdatedAtMs: profile.UpdatedAtMs, + UserId: profile.UserID, + Status: profile.Status, + RegionId: profile.RegionID, + CurrentAgencyId: profile.CurrentAgencyID, + CurrentMembershipId: profile.CurrentMembershipID, + CurrentAgencyOwnerUserId: profile.CurrentAgencyOwnerUserID, + Source: profile.Source, + FirstBecameHostAtMs: profile.FirstBecameHostAtMs, + CreatedAtMs: profile.CreatedAtMs, + UpdatedAtMs: profile.UpdatedAtMs, } } @@ -322,6 +323,8 @@ func toProtoAgency(agency hostdomain.Agency) *userv1.Agency { CreatedByUserId: agency.CreatedByUserID, CreatedAtMs: agency.CreatedAtMs, UpdatedAtMs: agency.UpdatedAtMs, + Avatar: agency.OwnerAvatar, + HostCount: agency.ActiveHostCount, } } diff --git a/services/user-service/internal/transport/grpc/host.go b/services/user-service/internal/transport/grpc/host.go index d7dd0a7b..3df908e0 100644 --- a/services/user-service/internal/transport/grpc/host.go +++ b/services/user-service/internal/transport/grpc/host.go @@ -12,7 +12,7 @@ import ( // 这个文件保持薄传输层:只做 proto 与业务层的数据转换和错误映射。 // 角色授权、区域校验、幂等和事务都在业务层/持久化层,避免不同入口行为分叉。 -// SearchAgencies 查询当前用户同区域可加入 Agency。 +// SearchAgencies 查询当前用户同国家可加入 Agency。 func (s *Server) SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) { if s.hostSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) diff --git a/services/user-service/internal/transport/grpc/server_test.go b/services/user-service/internal/transport/grpc/server_test.go index 5eaa48da..5f603b20 100644 --- a/services/user-service/internal/transport/grpc/server_test.go +++ b/services/user-service/internal/transport/grpc/server_test.go @@ -157,6 +157,10 @@ func TestCompleteOnboardingReturnsReplacementAccessToken(t *testing.T) { Meta: &userv1.RequestMeta{RequestId: "req-login", DeviceId: "ios"}, Provider: "wechat", Credential: "openid-complete-token", + Username: "hy", + Avatar: "https://cdn.example/a.png", + Gender: "male", + Country: "SG", DeviceId: "ios", Platform: "ios", }) diff --git a/services/wallet-service/configs/config.docker.yaml b/services/wallet-service/configs/config.docker.yaml index ae296fd2..fa176a70 100644 --- a/services/wallet-service/configs/config.docker.yaml +++ b/services/wallet-service/configs/config.docker.yaml @@ -17,6 +17,13 @@ red_packet_expiry_worker: app_code: "lalu" poll_interval: "5s" batch_size: 50 +projection_worker: + enabled: true + poll_interval: "60s" + batch_size: 10 + lock_ttl: "30s" + consumer_group: "hyapp-wallet-projection" + consumer_max_reconsume_times: 16 rocketmq: # Docker 本地使用 compose RocketMQ 发布 wallet_outbox 事实。 enabled: true diff --git a/services/wallet-service/configs/config.tencent.example.yaml b/services/wallet-service/configs/config.tencent.example.yaml index 967d7a30..39c8a358 100644 --- a/services/wallet-service/configs/config.tencent.example.yaml +++ b/services/wallet-service/configs/config.tencent.example.yaml @@ -17,6 +17,13 @@ red_packet_expiry_worker: app_code: "lalu" poll_interval: "5s" batch_size: 50 +projection_worker: + enabled: true + poll_interval: "60s" + batch_size: 10 + lock_ttl: "30s" + consumer_group: "hyapp-wallet-projection" + consumer_max_reconsume_times: 16 rocketmq: enabled: true name_servers: diff --git a/services/wallet-service/configs/config.yaml b/services/wallet-service/configs/config.yaml index cc0cecb3..dfc32b74 100644 --- a/services/wallet-service/configs/config.yaml +++ b/services/wallet-service/configs/config.yaml @@ -17,6 +17,13 @@ red_packet_expiry_worker: app_code: "lalu" poll_interval: "5s" batch_size: 50 +projection_worker: + enabled: false + poll_interval: "60s" + batch_size: 10 + lock_ttl: "30s" + consumer_group: "hyapp-wallet-projection" + consumer_max_reconsume_times: 16 rocketmq: # 本地默认关闭;线上由 wallet-service outbox worker 投递账务事实到 MQ。 enabled: false diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 3c8ea983..010c4ea8 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -73,11 +73,218 @@ CREATE TABLE IF NOT EXISTS wallet_outbox ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, event_id), - KEY idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms), - KEY idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms), + KEY idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), + KEY idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id), + KEY idx_wallet_outbox_event_created (app_code, event_type, created_at_ms, event_id), + KEY idx_wallet_outbox_asset_event_created (app_code, asset_type, event_type, created_at_ms, event_id), KEY idx_wallet_outbox_tx (app_code, transaction_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包事件 outbox 表'; +-- 主播工资周期钻石账户:只记录政策结算用累计值,不作为用户可消费或可提现的钱包资产。 +-- cycle_key 当前固定为 UTC yyyy-MM;后续日结/半月结不清空该账户,月末清算任务按策略处理。 +CREATE TABLE IF NOT EXISTS host_period_diamond_accounts ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL COMMENT '主播用户 ID', + cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM', + region_id BIGINT NOT NULL COMMENT '主播身份所属区域 ID,用于匹配区域工资政策', + agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼入账时主播归属 Agency 的 owner 用户 ID,0 表示无代理收款人', + total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计钻石', + gift_diamond_total BIGINT NOT NULL DEFAULT 0 COMMENT '送礼产生的周期钻石累计', + version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', + first_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本周期首次收礼时间,UTC epoch ms', + last_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本周期最后收礼时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, user_id, cycle_key), + KEY idx_host_period_diamond_region_cycle (app_code, region_id, cycle_key), + KEY idx_host_period_diamond_cycle_total (app_code, cycle_key, total_diamonds) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石账户表'; + +-- 主播工资周期钻石流水:每笔成功送礼只在 target 为 active host 时写入,用于审计和补偿重建周期账户。 +CREATE TABLE IF NOT EXISTS host_period_diamond_entries ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '流水 ID', + transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID', + command_id VARCHAR(128) NOT NULL COMMENT '业务命令幂等 ID', + user_id BIGINT NOT NULL COMMENT '主播用户 ID', + cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM', + region_id BIGINT NOT NULL COMMENT '主播身份所属区域 ID', + agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '本次送礼入账使用的代理收款用户快照', + diamond_delta BIGINT NOT NULL COMMENT '本次增加钻石', + diamond_after BIGINT NOT NULL COMMENT '本次入账后周期累计钻石', + gift_charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '礼物扣费资产类型快照', + gift_charge_amount BIGINT NOT NULL DEFAULT 0 COMMENT '礼物扣费金额快照', + gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID', + gift_count INT NOT NULL DEFAULT 0 COMMENT '礼物数量', + sender_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户 ID', + room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + UNIQUE KEY uk_host_period_diamond_tx (app_code, transaction_id, user_id, cycle_key), + KEY idx_host_period_diamond_entries_user_cycle (app_code, user_id, cycle_key, created_at_ms), + KEY idx_host_period_diamond_entries_region_cycle (app_code, region_id, cycle_key, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石流水表'; + +-- 运行侧工资政策表:字段与 admin_host_agency_salary_policies 对齐,wallet-service 结算只读取这里的已发布快照。 +-- 后台配置仍由 admin-server 管理;同步/导入时必须整份写入 policy + levels,结算事务不直接读 admin 库。 +CREATE TABLE IF NOT EXISTS host_agency_salary_policies ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID,来自后台配置', + name VARCHAR(120) NOT NULL DEFAULT '' COMMENT '政策名称', + region_id BIGINT NOT NULL COMMENT '适用区域 ID', + status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled', + settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式:daily/half_month', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual', + gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例快照', + residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例', + effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms', + effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, policy_id), + KEY idx_host_agency_salary_policy_active (app_code, region_id, status, settlement_mode, settlement_trigger_mode, effective_from_ms, effective_to_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策运行快照表'; + +CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID', + level_no INT NOT NULL COMMENT '等级', + required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石', + host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计美元工资,单位美分', + host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计金币奖励', + agency_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '代理累计美元工资,单位美分', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, policy_id, level_no), + KEY idx_host_agency_salary_policy_level_required (app_code, policy_id, status, required_diamonds) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级运行快照表'; + +-- 结算进度按主播+月份独立记录累计已发值;日结/半月结只更新累计,月底清算只打 cleared 标记。 +-- 这里不覆盖 host_period_diamond_accounts.total_diamonds,历史累计钻石必须可审计和可重放。 +CREATE TABLE IF NOT EXISTS host_salary_settlement_progress ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL COMMENT '主播用户 ID', + cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM', + settled_level_no INT NOT NULL DEFAULT 0 COMMENT '已结算到的最高等级', + settled_host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发主播美元工资累计,单位美分', + settled_host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '已发主播金币奖励累计', + settled_agency_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发代理美元工资累计,单位美分', + last_settled_total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '上次结算任务已处理到的周期累计钻石,避免未升档时重复空跑', + residual_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '月底剩余钻石已转主播美元金额,单位美分', + month_end_cleared_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '月底清算完成时间,0 表示未清算', + last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的政策 ID', + version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, user_id, cycle_key), + KEY idx_host_salary_settlement_progress_cycle (app_code, cycle_key, month_end_cleared_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期结算进度表'; + +CREATE TABLE IF NOT EXISTS host_salary_settlement_records ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + settlement_id VARCHAR(128) NOT NULL COMMENT '结算记录 ID', + command_id VARCHAR(128) NOT NULL COMMENT '钱包交易幂等命令 ID', + transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID', + settlement_type VARCHAR(24) NOT NULL COMMENT '结算类型:daily/half_month/month_end', + user_id BIGINT NOT NULL COMMENT '主播用户 ID', + agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '代理收款用户 ID', + cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM', + policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的政策 ID', + level_no INT NOT NULL DEFAULT 0 COMMENT '本次结算后的等级', + total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '结算时周期累计钻石', + host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次主播美元工资增量,单位美分', + host_coin_reward_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次主播金币奖励增量', + agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次代理美元工资增量,单位美分', + residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次月底剩余钻石转美元增量,单位美分', + status VARCHAR(24) NOT NULL DEFAULT 'succeeded' COMMENT '状态:succeeded/skipped/failed', + reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过或失败原因', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, settlement_id), + UNIQUE KEY uk_host_salary_settlement_command (app_code, command_id), + KEY idx_host_salary_settlement_user_cycle (app_code, user_id, cycle_key, created_at_ms), + KEY idx_host_salary_settlement_agency_cycle (app_code, agency_owner_user_id, cycle_key, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资结算记录表'; + +CREATE TABLE IF NOT EXISTS team_salary_settlement_progress ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + policy_type VARCHAR(24) NOT NULL COMMENT '团队工资类型:bd/admin', + user_id BIGINT NOT NULL COMMENT '收款用户 ID:BD 或 Admin', + cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM', + region_id BIGINT NOT NULL COMMENT '结算时使用的区域 ID', + settled_level_no INT NOT NULL DEFAULT 0 COMMENT '已结算到的最高等级', + settled_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发团队工资累计,单位美分', + last_settled_income_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '上次已处理收入累计,单位美分,用于避免未升档重复空跑', + last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的政策 ID', + month_closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完整月结算关闭时间,0 表示未关闭', + version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, policy_type, user_id, cycle_key), + KEY idx_team_salary_progress_cycle (app_code, policy_type, cycle_key, region_id, month_closed_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资周期结算进度表'; + +CREATE TABLE IF NOT EXISTS team_salary_settlement_records ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + settlement_id VARCHAR(128) NOT NULL COMMENT '结算记录 ID', + command_id VARCHAR(128) NOT NULL COMMENT '钱包交易幂等命令 ID', + transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID', + policy_type VARCHAR(24) NOT NULL COMMENT '团队工资类型:bd/admin', + trigger_mode VARCHAR(24) NOT NULL DEFAULT 'manual' COMMENT '触发方式:automatic/manual', + user_id BIGINT NOT NULL COMMENT '收款用户 ID:BD 或 Admin', + cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM', + region_id BIGINT NOT NULL COMMENT '结算时使用的区域 ID', + policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的政策 ID', + level_no INT NOT NULL DEFAULT 0 COMMENT '本次结算后的等级', + income_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '结算时团队收入累计,单位美分', + salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次团队美元工资增量,单位美分', + status VARCHAR(24) NOT NULL DEFAULT 'succeeded' COMMENT '状态:succeeded/skipped/failed', + reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过或失败原因', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, settlement_id), + UNIQUE KEY uk_team_salary_settlement_command (app_code, command_id), + KEY idx_team_salary_records_user_cycle (app_code, policy_type, user_id, cycle_key, created_at_ms), + KEY idx_team_salary_records_cycle (app_code, policy_type, cycle_key, region_id, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资结算记录表'; + +-- 老环境已建过主播周期账户时,CREATE TABLE IF NOT EXISTS 不会补新字段;结算前必须有代理收款快照。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_period_diamond_accounts' AND COLUMN_NAME = 'agency_owner_user_id') = 0, + 'ALTER TABLE host_period_diamond_accounts ADD COLUMN agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''送礼入账时主播归属 Agency 的 owner 用户 ID,0 表示无代理收款人'' AFTER region_id', + '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 = 'host_period_diamond_entries' AND COLUMN_NAME = 'agency_owner_user_id') = 0, + 'ALTER TABLE host_period_diamond_entries ADD COLUMN agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''本次送礼入账使用的代理收款用户快照'' AFTER region_id', + '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 = 'host_agency_salary_policies' AND COLUMN_NAME = 'settlement_trigger_mode') = 0, + 'ALTER TABLE host_agency_salary_policies ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT ''automatic'' COMMENT ''结算触发方式:automatic/manual'' AFTER settlement_mode', + '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 = 'host_salary_settlement_progress' AND COLUMN_NAME = 'last_settled_total_diamonds') = 0, + 'ALTER TABLE host_salary_settlement_progress ADD COLUMN last_settled_total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT ''上次结算任务已处理到的周期累计钻石,避免未升档时重复空跑'' AFTER settled_agency_salary_usd_minor', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- 老环境可能已经存在 wallet_outbox,CREATE TABLE IF NOT EXISTS 不会补齐后续新增的投递锁字段。 -- 这里用 information_schema 生成幂等 ALTER,保证重复执行 initdb 时不会因重复列名失败。 SET @ddl := IF( @@ -119,15 +326,48 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl := 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms)'; +SET @ddl := 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)'; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; --- claim 索引用于查找锁过期的消息;仅在缺失时添加,避免重复执行 initdb 报重复索引。 +-- claim 索引用于查找锁过期的消息;列顺序也跟查询条件绑定,存在旧结构时重建。 SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_claim') = 0, - 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms)', + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_claim') > 0, + 'ALTER TABLE wallet_outbox DROP INDEX idx_wallet_outbox_claim', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)'; +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_retention') = 0, + 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- projection worker 只扫描单租户、单事件类型的 wallet_outbox;缺少该索引会退化成百万级全表扫描。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_event_created') = 0, + 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_event_created (app_code, event_type, created_at_ms, event_id)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_asset_event_created') = 0, + 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_asset_event_created (app_code, asset_type, event_type, created_at_ms, event_id)', 'SELECT 1' ); PREPARE stmt FROM @ddl; diff --git a/services/wallet-service/internal/app/app.go b/services/wallet-service/internal/app/app.go index 3b5a891a..20d7b358 100644 --- a/services/wallet-service/internal/app/app.go +++ b/services/wallet-service/internal/app/app.go @@ -39,7 +39,9 @@ type App struct { walletSvc *walletservice.Service activityConn *grpc.ClientConn outboxProducer *rocketmqx.Producer + projectionConsumer *rocketmqx.Consumer outboxWorkerCfg config.OutboxWorkerConfig + projectionWorkerCfg config.ProjectionWorkerConfig walletOutboxTopic string nodeID string redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig @@ -97,6 +99,7 @@ func New(cfg config.Config) (*App, error) { svc.SetGooglePlayClient(googleClient) } walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc)) + walletv1.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc)) health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{ Name: "mysql", Check: repository.Ping, @@ -112,6 +115,16 @@ func New(cfg config.Config) (*App, error) { _ = repository.Close() return nil, err } + projectionConsumer, err := newWalletProjectionConsumer(cfg, svc) + if err != nil { + if outboxProducer != nil { + _ = outboxProducer.Shutdown() + } + _ = activityConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } return &App{ server: server, @@ -122,7 +135,9 @@ func New(cfg config.Config) (*App, error) { walletSvc: svc, activityConn: activityConn, outboxProducer: outboxProducer, + projectionConsumer: projectionConsumer, outboxWorkerCfg: cfg.OutboxWorker, + projectionWorkerCfg: cfg.ProjectionWorker, walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic, nodeID: cfg.NodeID, redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker, @@ -136,11 +151,11 @@ func (a *App) Run() error { a.health.MarkStopped() return err } - a.runGiftWallProjectionWorker() + a.runBackgroundWorkers() a.health.MarkServing() err := a.server.Serve(a.listener) a.health.MarkStopped() - a.closeGiftWallProjectionWorker() + a.closeBackgroundWorkers() a.shutdownMQ() if errors.Is(err, grpc.ErrServerStopped) { return nil @@ -153,7 +168,7 @@ func (a *App) Run() error { func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() - a.closeGiftWallProjectionWorker() + a.closeBackgroundWorkers() grpcshutdown.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.activityConn != nil { @@ -167,58 +182,12 @@ func (a *App) Close() { }) } -func (a *App) runGiftWallProjectionWorker() { +func (a *App) runBackgroundWorkers() { if a.walletSvc == nil { return } ctx, cancel := context.WithCancel(context.Background()) a.stopWorker = cancel - giftWallWorkerID := "gift-wall-" + a.nodeID - a.workers.Add(1) - go func() { - defer a.workers.Done() - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - // 礼物墙是展示读模型,worker 常驻在 wallet-service 内消费账务 outbox。 - // 服务重启会回滚未提交的投影事务;已完成事件由 projection done 状态防重。 - processed, err := a.walletSvc.ProjectPendingGiftWallEvents(ctx, giftWallWorkerID, 50, 30*time.Second) - if err != nil && !errors.Is(err, context.Canceled) { - logx.Error(ctx, "gift_wall_projection_failed", err, slog.String("worker_id", giftWallWorkerID)) - } - if processed >= 50 { - // 批次打满说明还有积压,立即继续 drain,避免固定 tick 放大展示延迟。 - continue - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } - }() - badgeWorkerID := "badge-grant-" + a.nodeID - a.workers.Add(1) - go func() { - defer a.workers.Done() - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - // 徽章展示槽位属于 activity-service 读模型;wallet 只 relay 自己 outbox 中的资源赠送事实。 - processed, err := a.walletSvc.ProjectPendingBadgeGrantEvents(ctx, badgeWorkerID, 50, 30*time.Second) - if err != nil && !errors.Is(err, context.Canceled) { - logx.Error(ctx, "badge_grant_projection_failed", err, slog.String("worker_id", badgeWorkerID)) - } - if processed >= 50 { - continue - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } - }() if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil { outboxWorkerID := "wallet-outbox-" + a.nodeID a.workers.Add(1) @@ -367,7 +336,7 @@ func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time return backoff } -func (a *App) closeGiftWallProjectionWorker() { +func (a *App) closeBackgroundWorkers() { if a.stopWorker != nil { a.stopWorker() } @@ -375,18 +344,72 @@ func (a *App) closeGiftWallProjectionWorker() { } func (a *App) startMQ() error { - if a.outboxProducer == nil { - return nil + if a.outboxProducer != nil { + if err := a.outboxProducer.Start(); err != nil { + return err + } } - return a.outboxProducer.Start() + if a.projectionConsumer != nil { + if err := a.projectionConsumer.Start(); err != nil { + if a.outboxProducer != nil { + _ = a.outboxProducer.Shutdown() + } + return err + } + } + return nil } func (a *App) shutdownMQ() { - if a.outboxProducer == nil { - return + if a.projectionConsumer != nil { + if err := a.projectionConsumer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_projection_consumer_shutdown_failed", slog.String("error", err.Error())) + } } - if err := a.outboxProducer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) + if a.outboxProducer != nil { + if err := a.outboxProducer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) + } + } +} + +func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) { + if svc == nil || !cfg.ProjectionWorker.Enabled { + return nil, nil + } + consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker)) + if err != nil { + return nil, err + } + workerID := "wallet-projection-" + cfg.NodeID + if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) + if err != nil { + return err + } + _, err = svc.ProcessWalletProjectionMessage(appcode.WithContext(ctx, walletMessage.AppCode), workerID, walletMessage, cfg.ProjectionWorker.LockTTL) + return err + }); err != nil { + _ = consumer.Shutdown() + return nil, err + } + return consumer, nil +} + +func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection config.ProjectionWorkerConfig) rocketmqx.ConsumerConfig { + return rocketmqx.ConsumerConfig{ + EndpointConfig: rocketmqx.EndpointConfig{ + NameServers: cfg.NameServers, + NameServerDomain: cfg.NameServerDomain, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + SecurityToken: cfg.SecurityToken, + Namespace: cfg.Namespace, + VIPChannel: cfg.VIPChannel, + }, + GroupName: projection.ConsumerGroup, + MaxReconsumeTimes: projection.ConsumerMaxReconsumeTimes, + ConsumePullBatch: int32(projection.BatchSize), } } diff --git a/services/wallet-service/internal/config/config.go b/services/wallet-service/internal/config/config.go index eb6bb235..b89f2ab3 100644 --- a/services/wallet-service/internal/config/config.go +++ b/services/wallet-service/internal/config/config.go @@ -29,6 +29,8 @@ type Config struct { GooglePlay GooglePlayConfig `yaml:"google_play"` // OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。 OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` + // ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费。 + ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"` // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` Log logx.Config `yaml:"log"` @@ -75,6 +77,16 @@ type OutboxWorkerConfig struct { MaxBackoff time.Duration `yaml:"max_backoff"` } +// ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。 +type ProjectionWorkerConfig struct { + Enabled bool `yaml:"enabled"` + PollInterval time.Duration `yaml:"poll_interval"` // Deprecated: runtime projection is MQ-driven. + BatchSize int `yaml:"batch_size"` + LockTTL time.Duration `yaml:"lock_ttl"` + ConsumerGroup string `yaml:"consumer_group"` + ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` +} + // GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。 type GooglePlayConfig struct { Enabled bool `yaml:"enabled"` @@ -120,6 +132,14 @@ func Default() Config { InitialBackoff: 5 * time.Second, MaxBackoff: 5 * time.Minute, }, + ProjectionWorker: ProjectionWorkerConfig{ + Enabled: false, + PollInterval: 60 * time.Second, + BatchSize: 10, + LockTTL: 30 * time.Second, + ConsumerGroup: "hyapp-wallet-projection", + ConsumerMaxReconsumeTimes: 16, + }, MySQLAutoMigrate: false, Log: logx.Config{ Level: "info", @@ -234,6 +254,27 @@ func Load(path string) (Config, error) { if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled { return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled") } + if cfg.ProjectionWorker.PollInterval <= 0 { + cfg.ProjectionWorker.PollInterval = Default().ProjectionWorker.PollInterval + } + if cfg.ProjectionWorker.BatchSize <= 0 { + cfg.ProjectionWorker.BatchSize = Default().ProjectionWorker.BatchSize + } + if cfg.ProjectionWorker.BatchSize > 100 { + cfg.ProjectionWorker.BatchSize = 100 + } + if cfg.ProjectionWorker.LockTTL <= 0 { + cfg.ProjectionWorker.LockTTL = Default().ProjectionWorker.LockTTL + } + if cfg.ProjectionWorker.ConsumerGroup = strings.TrimSpace(cfg.ProjectionWorker.ConsumerGroup); cfg.ProjectionWorker.ConsumerGroup == "" { + cfg.ProjectionWorker.ConsumerGroup = Default().ProjectionWorker.ConsumerGroup + } + if cfg.ProjectionWorker.ConsumerMaxReconsumeTimes <= 0 { + cfg.ProjectionWorker.ConsumerMaxReconsumeTimes = Default().ProjectionWorker.ConsumerMaxReconsumeTimes + } + if cfg.ProjectionWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled { + return Config{}, errors.New("projection_worker requires rocketmq.wallet_outbox.enabled") + } return cfg, nil } diff --git a/services/wallet-service/internal/domain/ledger/ledger.go b/services/wallet-service/internal/domain/ledger/ledger.go index 19683a27..0cf6307f 100644 --- a/services/wallet-service/internal/domain/ledger/ledger.go +++ b/services/wallet-service/internal/domain/ledger/ledger.go @@ -11,6 +11,8 @@ const ( AssetGiftPoint = "GIFT_POINT" // AssetDiamond 是后续兑换用资产,当前只在余额模型中保留。 AssetDiamond = "DIAMOND" + // AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。 + AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND" // AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。 AssetUSDBalance = "USD_BALANCE" @@ -46,6 +48,17 @@ const ( // WithdrawalStatusPending 表示主播美元余额提现已提交,等待后台人工审核和线下转账。 WithdrawalStatusPending = "pending" + // HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。 + HostSalarySettlementModeDaily = "daily" + // HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。 + HostSalarySettlementModeHalfMonth = "half_month" + // HostSalarySettlementTypeMonthEnd 表示月底清算等级增量、剩余钻石折美元和周期关闭。 + HostSalarySettlementTypeMonthEnd = "month_end" + // HostSalarySettlementTriggerAutomatic 表示由 cron-service 自动扫描结算的政策。 + HostSalarySettlementTriggerAutomatic = "automatic" + // HostSalarySettlementTriggerManual 表示只允许后台工资结算页人工触发的政策。 + HostSalarySettlementTriggerManual = "manual" + // VipStatusActive 表示 VIP 配置或用户会员状态当前有效。 VipStatusActive = "active" // VipStatusDisabled 表示 VIP 配置被后台停用。 @@ -96,6 +109,12 @@ type DebitGiftCommand struct { GiftCount int32 PriceVersion string RegionID int64 + // TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。 + TargetIsHost bool + // TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。 + TargetHostRegionID int64 + // TargetAgencyOwnerUserID 是送礼瞬间主播上级代理的收款用户快照;结算按快照发放,避免月底组织变化错账。 + TargetAgencyOwnerUserID int64 } // Receipt 是账务命令落账后的稳定回执。 @@ -110,6 +129,58 @@ type Receipt struct { GiftTypeCode string PriceVersion string BalanceAfter int64 + // HostPeriodDiamondAdded 是本次送礼写入主播工资周期账户的钻石数;非主播恒为 0。 + HostPeriodDiamondAdded int64 + // HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。 + HostPeriodCycleKey string +} + +// HostSalarySettlementBatchCommand 是 cron 或测试触发结算批处理的最小输入。 +type HostSalarySettlementBatchCommand struct { + AppCode string + RunID string + WorkerID string + BatchSize int + SettlementType string + // TriggerMode 决定本次批处理读取自动政策还是手动政策;空值按 automatic 处理,兼容已有定时任务。 + TriggerMode string + CycleKey string + NowMs int64 +} + +// HostSalarySettlementBatchResult 汇总一个结算批次的扫描和入账结果。 +type HostSalarySettlementBatchResult struct { + ClaimedCount int + ProcessedCount int + SuccessCount int + FailureCount int + HasMore bool +} + +// HostSalaryPolicy 是 wallet-service 结算读取的后台政策运行快照。 +type HostSalaryPolicy struct { + PolicyID uint64 + Name string + RegionID int64 + Status string + SettlementMode string + SettlementTriggerMode string + GiftCoinToDiamondRatio string + ResidualDiamondToUSDRate string + EffectiveFromMs int64 + EffectiveToMs int64 + Levels []HostSalaryPolicyLevel +} + +// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。 +type HostSalaryPolicyLevel struct { + LevelNo int + RequiredDiamonds int64 + HostSalaryUSDMinor int64 + HostCoinReward int64 + AgencySalaryUSDMinor int64 + Status string + SortOrder int } // CoinSellerTransferCommand 是币商给玩家转普通金币的账务命令。 @@ -558,15 +629,16 @@ type TaskRewardReceipt struct { // LuckyGiftRewardCommand 是 activity-service 用抽奖 draw_id 发起的 COIN 入账命令。 type LuckyGiftRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - DrawID string - RoomID string - GiftID string - PoolID string - Reason string + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + DrawID string + RoomID string + VisibleRegionID int64 + GiftID string + PoolID string + Reason string } // LuckyGiftRewardReceipt 是幸运礼物返奖入账后的稳定回执。 diff --git a/services/wallet-service/internal/service/wallet/service.go b/services/wallet-service/internal/service/wallet/service.go index 0ae7d828..5309c5d5 100644 --- a/services/wallet-service/internal/service/wallet/service.go +++ b/services/wallet-service/internal/service/wallet/service.go @@ -8,6 +8,7 @@ import ( "google.golang.org/grpc" activityv1 "hyapp.local/api/proto/activity/v1" "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" @@ -54,6 +55,7 @@ type Repository interface { GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) + ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) @@ -80,7 +82,9 @@ type Repository interface { SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) + ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error) + ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error } @@ -126,6 +130,10 @@ func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand if command.GiftCount <= 0 { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") } + if command.TargetIsHost && command.TargetHostRegionID <= 0 { + // 主播周期钻石后续要按区域工资政策结算;只有 active host profile 且带区域时才允许入账。 + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required") + } if s.repository == nil { return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } @@ -135,6 +143,41 @@ func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand return s.repository.DebitGift(ctx, command) } +// ProcessHostSalarySettlementBatch 执行主播/代理工资结算批处理;日结和半月结不清空周期钻石,月底只做逻辑清算标记。 +func (s *Service) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { + if s.repository == nil { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + switch strings.TrimSpace(command.SettlementType) { + case ledger.HostSalarySettlementModeDaily, ledger.HostSalarySettlementModeHalfMonth, ledger.HostSalarySettlementTypeMonthEnd: + default: + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") + } + if command.BatchSize <= 0 { + command.BatchSize = 100 + } + if command.BatchSize > 500 { + command.BatchSize = 500 + } + if strings.TrimSpace(command.RunID) == "" || strings.TrimSpace(command.WorkerID) == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "run_id and worker_id are required") + } + command.AppCode = appcode.Normalize(command.AppCode) + if command.NowMs <= 0 { + command.NowMs = s.now().UTC().UnixMilli() + } + if strings.TrimSpace(command.CycleKey) == "" { + command.CycleKey = hostSalaryCycleKey(command.NowMs) + } + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.ProcessHostSalarySettlementBatch(ctx, command) +} + +func hostSalaryCycleKey(nowMs int64) string { + // 结算周期按 UTC 月锚定,和送礼入账的 host_period_diamond_accounts.cycle_key 保持同一口径。 + return time.UnixMilli(nowMs).UTC().Format("2006-01") +} + // GetBalances 返回用户多资产余额;鉴权范围由 gateway 或后台入口控制。 func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { if userID <= 0 { @@ -386,6 +429,30 @@ func (s *Service) ProjectPendingGiftWallEvents(ctx context.Context, workerID str return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL) } +// ProcessWalletProjectionMessage consumes one wallet_outbox MQ fact for wallet-owned projections. +func (s *Service) ProcessWalletProjectionMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return false, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if s.repository == nil { + return false, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, message.AppCode) + handled := false + projected, err := s.repository.ProjectGiftWallMessage(ctx, workerID+"-gift-wall", message, lockTTL) + if err != nil { + return handled, err + } + handled = handled || projected + projected, err = s.projectBadgeGrantMessage(ctx, workerID+"-badge-grant", message, lockTTL) + if err != nil { + return handled, err + } + handled = handled || projected + return handled, nil +} + // ProjectPendingBadgeGrantEvents relays wallet resource grant outbox facts to activity badge display projection. func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) { workerID = strings.TrimSpace(workerID) @@ -412,41 +479,69 @@ func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID s processed := 0 for _, event := range events { processed++ - reqCtx := appcode.WithContext(ctx, event.AppCode) - resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{ - Meta: &activityv1.RequestMeta{ - Caller: "wallet-service", - AppCode: event.AppCode, - SentAtMs: s.now().UnixMilli(), - }, - EventId: event.EventID, - EventType: event.EventType, - SourceService: "wallet-service", - UserId: event.UserID, - MetricType: walletBadgeGrantMetric, - Value: 1, - OccurredAtMs: event.CreatedAtMS, - DimensionsJson: event.PayloadJSON, - }) - if consumeErr != nil { - if markErr := s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli()); markErr != nil { - return processed, markErr - } - continue - } - if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) { - if markErr := s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli()); markErr != nil { - return processed, markErr - } - continue - } - if err := s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli()); err != nil { + if err := s.deliverBadgeGrantProjection(ctx, event); err != nil { return processed, err } } return processed, nil } +func (s *Service) projectBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { + if !isBadgeGrantWalletProjectionMessage(message) { + return false, nil + } + if s.activity == nil { + return false, xerr.New(xerr.Unavailable, "activity badge client is not configured") + } + nowMS := s.now().UnixMilli() + event, claimed, err := s.repository.ClaimBadgeGrantMessage(ctx, workerID, message, nowMS, lockTTL) + if err != nil || !claimed { + return claimed, err + } + if err := s.deliverBadgeGrantProjection(ctx, event); err != nil { + return true, err + } + return true, nil +} + +func (s *Service) deliverBadgeGrantProjection(ctx context.Context, event resourcedomain.BadgeGrantOutbox) error { + reqCtx := appcode.WithContext(ctx, event.AppCode) + resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{ + Meta: &activityv1.RequestMeta{ + Caller: "wallet-service", + AppCode: event.AppCode, + SentAtMs: s.now().UnixMilli(), + }, + EventId: event.EventID, + EventType: event.EventType, + SourceService: "wallet-service", + UserId: event.UserID, + MetricType: walletBadgeGrantMetric, + Value: 1, + OccurredAtMs: event.CreatedAtMS, + DimensionsJson: event.PayloadJSON, + }) + if consumeErr != nil { + return s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli()) + } + if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) { + return s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli()) + } + return s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli()) +} + +func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) bool { + if strings.TrimSpace(message.AssetType) != "RESOURCE" { + return false + } + switch message.EventType { + case "ResourceGranted", "ResourceGroupGranted": + return true + default: + return false + } +} + func isBadgeGrantProjectionStatusTerminal(status string) bool { switch status { case "consumed", "skipped", "duplicate": diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index e3f504ff..0b03cd92 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -9,13 +9,31 @@ import ( "google.golang.org/grpc" activityv1 "hyapp.local/api/proto/activity/v1" "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" walletservice "hyapp/services/wallet-service/internal/service/wallet" + mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" "hyapp/services/wallet-service/internal/testutil/mysqltest" ) +func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walletmq.WalletOutboxMessage { + return walletmq.WalletOutboxMessage{ + AppCode: record.AppCode, + EventID: record.EventID, + EventType: record.EventType, + TransactionID: record.TransactionID, + CommandID: record.CommandID, + UserID: record.UserID, + AssetType: record.AssetType, + AvailableDelta: record.AvailableDelta, + FrozenDelta: record.FrozenDelta, + PayloadJSON: record.PayloadJSON, + OccurredAtMS: record.CreatedAtMS, + } +} + // TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。 func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) { repository := mysqltest.NewRepository(t) @@ -63,6 +81,641 @@ func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) { if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 3 { t.Fatalf("gift debit should write balance and gift outbox events, got %d", got) } + if receipt.HostPeriodDiamondAdded != 0 || receipt.HostPeriodCycleKey != "" { + t.Fatalf("non-host gift must not credit host period diamonds: %+v", receipt) + } + if got := repository.CountRows("host_period_diamond_accounts", "user_id = ?", int64(10002)); got != 0 { + t.Fatalf("non-host gift must not create host period diamond account, got %d", got) + } +} + +// TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。 +func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "v9", 7, 3, 11) + svc := walletservice.New(repository) + cycleBefore := time.Now().UTC().Format("2006-01") + + command := ledger.DebitGiftCommand{ + CommandID: "cmd-gift-host-period", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 2, + PriceVersion: "v9", + TargetIsHost: true, + TargetHostRegionID: 8801, + } + first, err := svc.DebitGift(context.Background(), command) + if err != nil { + t.Fatalf("host DebitGift failed: %v", err) + } + cycleAfter := time.Now().UTC().Format("2006-01") + + if first.HostPeriodDiamondAdded != 14 || first.HostPeriodCycleKey == "" { + t.Fatalf("host period diamond receipt mismatch: %+v", first) + } + if first.HostPeriodCycleKey != cycleBefore && first.HostPeriodCycleKey != cycleAfter { + t.Fatalf("host period cycle must use current UTC month, got %q", first.HostPeriodCycleKey) + } + if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND region_id = ? AND total_diamonds = ? AND gift_diamond_total = ?", int64(10002), first.HostPeriodCycleKey, int64(8801), int64(14), int64(14)); got != 1 { + t.Fatalf("host period diamond account mismatch, got %d", got) + } + if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ? AND diamond_delta = ? AND diamond_after = ?", int64(10002), first.HostPeriodCycleKey, int64(14), int64(14)); got != 1 { + t.Fatalf("host period diamond entry mismatch, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "HostPeriodDiamondCredited"); got != 1 { + t.Fatalf("host period diamond outbox event mismatch, got %d", got) + } + + second, err := svc.DebitGift(context.Background(), command) + if err != nil { + t.Fatalf("host DebitGift replay failed: %v", err) + } + if second.TransactionID != first.TransactionID || second.HostPeriodDiamondAdded != 14 || second.HostPeriodCycleKey != first.HostPeriodCycleKey { + t.Fatalf("host period idempotent receipt mismatch: first=%+v second=%+v", first, second) + } + if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND total_diamonds = ?", int64(10002), first.HostPeriodCycleKey, int64(14)); got != 1 { + t.Fatalf("host period replay must not add diamonds again, got %d", got) + } + if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 1 { + t.Fatalf("host period replay must not add duplicate entries, got %d", got) + } +} + +// TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。 +func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "default", 10, 10, 10) + svc := walletservice.New(repository) + + _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-gift-host-no-region", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + TargetIsHost: true, + }) + if !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("expected INVALID_ARGUMENT, got %v", err) + } + if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-gift-host-no-region"); got != 0 { + t.Fatalf("invalid host period command must not write transaction, got %d", got) + } +} + +// TestHostSalarySettlementCreditsIncrementalRewards 走真实送礼入钻石账户后,验证日结只发放等级累计差额。 +func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 1000) + repository.SetGiftPrice("rose", "salary", 200, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 101, + Name: "daily salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, + }, + }) + svc := walletservice.New(repository) + + first, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-gift-1", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("first salary gift failed: %v", err) + } + result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-run-1", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: first.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("first salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("first settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50) + + second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-gift-2", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("second salary gift failed: %v", err) + } + if second.HostPeriodCycleKey != first.HostPeriodCycleKey { + t.Fatalf("salary gifts should be in same cycle: first=%q second=%q", first.HostPeriodCycleKey, second.HostPeriodCycleKey) + } + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-run-2", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: first.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("second salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("second settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 300) + assertBalance(t, svc, 10002, ledger.AssetCoin, 198) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 80) + if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 { + t.Fatalf("salary settlement records mismatch, got %d", got) + } + + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-run-3", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: first.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("noop salary settlement failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("settlement without new diamonds should not claim rows: %+v", result) + } +} + +// TestHostSalaryManualPolicyRequiresManualTrigger 验证手动政策不会被 cron 默认自动结算,必须由后台人工入口显式传 manual。 +func TestHostSalaryManualPolicyRequiresManualTrigger(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 500) + repository.SetGiftPrice("rose", "salary-manual", 200, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 105, + Name: "manual salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + }, + }) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-manual-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-manual", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("manual salary gift failed: %v", err) + } + + result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-manual-auto", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("automatic scan for manual policy failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("manual policy must not be claimed by automatic scan: %+v", result) + } + + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-manual-explicit", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + TriggerMode: ledger.HostSalarySettlementTriggerManual, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("manual salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 { + t.Fatalf("manual settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50) +} + +// TestHostSalaryMonthEndCreditsResidualAndClearsCycle 验证月底结算补发剩余钻石折美元,并逻辑关闭当月周期。 +func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 1000) + repository.SetGiftPrice("rose", "salary-residual", 350, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 102, + Name: "daily residual", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + ResidualDiamondToUSDRate: "0.01", + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + {LevelNo: 2, RequiredDiamonds: 500, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, + }, + }) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-residual-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-residual", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("residual salary gift failed: %v", err) + } + if _, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-residual-daily", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("daily residual salary settlement failed: %v", err) + } + result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-residual-month-end", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementTypeMonthEnd, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("month-end residual salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("month-end settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 400) + if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), receipt.HostPeriodCycleKey, int64(250)); got != 1 { + t.Fatalf("month-end progress should store residual and cleared marker, got %d", got) + } + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-residual-after-clear", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("after-clear salary settlement failed: %v", err) + } + if result.ClaimedCount != 0 { + t.Fatalf("cleared cycle must not be claimed again: %+v", result) + } +} + +// TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle 走通“已发布政策 -> 主播收礼 -> 多次增量结算 -> 月底清算 -> 下周期重新起算”的真实账务链路。 +func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 2000) + repository.SetGiftPrice("rose", "salary-full-lifecycle", 120, 10, 10) + // 管理后台发布后 wallet-service 消费的是 host_agency_salary_policies 运行时快照;这里直接 seed 该表来锁定送礼和结算链路。 + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 301, + Name: "published daily salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + ResidualDiamondToUSDRate: "0.01", + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, + }, + }) + svc := walletservice.New(repository) + ctx := context.Background() + previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01") + + first, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-full-lifecycle-gift-1", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-full-lifecycle", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("first lifecycle gift failed: %v", err) + } + if first.HostPeriodDiamondAdded != 120 { + t.Fatalf("first gift should add host diamonds: %+v", first) + } + // DebitGift 使用真实当前月份入账;测试把真实送礼产生的账户移动到上月,模拟月底清算前的完整历史周期。 + repository.MoveHostPeriodCycle(10002, first.HostPeriodCycleKey, previousCycle) + + result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-daily-level-1", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("level 1 daily settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("level 1 daily settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50) + previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle) + if len(previousIDs) != 1 || previousIDs[0] == "" { + t.Fatalf("level 1 settlement id missing: %v", previousIDs) + } + t.Logf("settlement ids after level 1: %v", previousIDs) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-daily-repeat", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("repeat daily settlement failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("repeat daily settlement should not claim unchanged account: %+v", result) + } + if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) { + t.Fatalf("repeat daily settlement must not create record: before=%v after=%v", previousIDs, ids) + } + + second, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-full-lifecycle-gift-2", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 3, + PriceVersion: "salary-full-lifecycle", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("second lifecycle gift failed: %v", err) + } + if second.HostPeriodDiamondAdded != 360 { + t.Fatalf("second gift should add host diamonds: %+v", second) + } + repository.MoveHostPeriodCycle(10002, second.HostPeriodCycleKey, previousCycle) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-daily-level-2", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("level 2 daily settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("level 2 daily settlement result mismatch: %+v", result) + } + // 第二次只补发累计 2 级与已发 1 级之间的差额,最终余额等于 2 级累计权益。 + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 300) + assertBalance(t, svc, 10002, ledger.AssetCoin, 198) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 80) + previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) + if len(previousIDs) != 2 { + t.Fatalf("level 2 settlement should create second record: %v", previousIDs) + } + t.Logf("settlement ids after level 2: %v", previousIDs) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-month-end", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementTypeMonthEnd, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("month-end lifecycle settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("month-end lifecycle result mismatch: %+v", result) + } + // 480 钻石达到 2 级后剩余 80 钻石,按 0.01 USD/diamond 折算为 80 美分并标记周期已清算。 + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 380) + if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND last_settled_total_diamonds = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), previousCycle, int64(480), int64(80)); got != 1 { + t.Fatalf("month-end progress should store residual and clear marker, got %d", got) + } + previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) + if len(previousIDs) != 3 { + t.Fatalf("month-end settlement should create third record: %v", previousIDs) + } + t.Logf("settlement ids after month-end clear: %v", previousIDs) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-month-end-repeat", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementTypeMonthEnd, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("repeat month-end settlement failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("cleared cycle must not be claimed by repeat month-end: %+v", result) + } + if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) { + t.Fatalf("repeat month-end must not create record: before=%v after=%v", previousIDs, ids) + } + + third, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-full-lifecycle-gift-next-cycle", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-full-lifecycle", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("next-cycle gift failed: %v", err) + } + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-next-cycle-level-1", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: third.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("next-cycle daily settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("next-cycle daily result mismatch: %+v", result) + } + // 新 cycle_key 使用独立进度,因此月末清算后的下一周期从 1 级累计权益重新开始发放。 + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 530) + assertBalance(t, svc, 10002, ledger.AssetCoin, 288) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 130) + nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey) + if len(nextIDs) != 1 || nextIDs[0] == "" { + t.Fatalf("next-cycle settlement id missing: %v", nextIDs) + } + t.Logf("settlement ids after next cycle level 1: %v", nextIDs) +} + +// TestHostSalaryHalfMonthSettlementUsesPolicyMode 验证半月结政策不会被日结批次误结算,只会由 half-month 批次处理。 +func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 500) + repository.SetGiftPrice("rose", "salary-half-month", 200, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 302, + Name: "published half-month salary", + RegionID: 8802, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeHalfMonth, + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + }, + }) + svc := walletservice.New(repository) + ctx := context.Background() + + receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-half-month-policy-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10003, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-half-month", + TargetIsHost: true, + TargetHostRegionID: 8802, + TargetAgencyOwnerUserID: 30002, + }) + if err != nil { + t.Fatalf("half-month gift failed: %v", err) + } + result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "half-month-daily-should-skip", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("daily settlement for half-month policy failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("daily batch must skip half-month policy: %+v", result) + } + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "half-month-policy-settlement", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeHalfMonth, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("half-month settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("half-month settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10003, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 10003, ledger.AssetCoin, 90) + assertBalance(t, svc, 30002, ledger.AssetUSDBalance, 50) + if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" { + t.Fatalf("half-month settlement id missing: %v", ids) + } } // TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼,扣 COIN 和收 GIFT_POINT 仍然分别落账。 @@ -121,16 +774,32 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 { t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got) } - processed, err := svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute) + records, err := repository.ClaimPendingWalletOutbox(context.Background(), "gift-wall-mq-test", 20, time.Now().Add(time.Minute).UnixMilli()) if err != nil { - t.Fatalf("ProjectPendingGiftWallEvents failed: %v", err) + t.Fatalf("ClaimPendingWalletOutbox failed: %v", err) + } + processed := 0 + for _, record := range records { + handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute) + if err != nil { + t.Fatalf("ProcessWalletProjectionMessage failed: %v", err) + } + if handled { + processed++ + } } if processed != 3 { t.Fatalf("projection should process three gift events, got %d", processed) } - processed, err = svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute) - if err != nil { - t.Fatalf("ProjectPendingGiftWallEvents retry failed: %v", err) + processed = 0 + for _, record := range records { + handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute) + if err != nil { + t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err) + } + if handled { + processed++ + } } if processed != 0 { t.Fatalf("projection must be idempotent after done events, got %d", processed) @@ -143,11 +812,12 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 255 { t.Fatalf("gift wall summary mismatch: %+v", wall) } - if len(wall.Items) != 2 || wall.Items[0].GiftID != "rose" || wall.Items[0].GiftCount != 5 || wall.Items[0].TotalValue != 35 { + rose, ok := giftWallItemByID(wall.Items, "rose") + if len(wall.Items) != 2 || !ok || rose.GiftCount != 5 || rose.TotalValue != 35 { t.Fatalf("rose gift wall item mismatch: %+v", wall.Items) } - if wall.Items[0].Resource.ResourceID == 0 || wall.Items[0].ResourceSnapshotJSON == "" { - t.Fatalf("gift wall should keep display resource snapshot: %+v", wall.Items[0]) + if rose.Resource.ResourceID == 0 || rose.ResourceSnapshotJSON == "" { + t.Fatalf("gift wall should keep display resource snapshot: %+v", rose) } if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 { t.Fatalf("gift wall should keep one row per gift_id, got %d", got) @@ -161,6 +831,7 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { func TestDebitGiftIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{ CommandID: "cmd-1", @@ -192,6 +863,7 @@ func TestDebitGiftIsIdempotent(t *testing.T) { func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1} if _, err := svc.DebitGift(context.Background(), command); err != nil { @@ -208,6 +880,7 @@ func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) { func TestDebitGiftInsufficientBalance(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1) + repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ @@ -1402,9 +2075,19 @@ func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) { t.Fatalf("GrantResource failed: %v", err) } - processed, err := svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute) + records, err := repository.ClaimPendingWalletOutbox(ctx, "badge-mq-test", 20, time.Now().Add(time.Minute).UnixMilli()) if err != nil { - t.Fatalf("ProjectPendingBadgeGrantEvents failed: %v", err) + t.Fatalf("ClaimPendingWalletOutbox failed: %v", err) + } + processed := 0 + for _, record := range records { + handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute) + if err != nil { + t.Fatalf("ProcessWalletProjectionMessage failed: %v", err) + } + if handled { + processed++ + } } if processed != 1 || len(activity.requests) != 1 { t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests)) @@ -1435,9 +2118,18 @@ func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) { if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 { t.Fatalf("badge projection should mark event done, got %d", got) } - processed, err = svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute) - if err != nil || processed != 0 || len(activity.requests) != 1 { - t.Fatalf("badge projection must be idempotent: processed=%d requests=%d err=%v", processed, len(activity.requests), err) + processed = 0 + for _, record := range records { + handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute) + if err != nil { + t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err) + } + if handled { + processed++ + } + } + if processed != 0 || len(activity.requests) != 1 { + t.Fatalf("badge projection must be idempotent: processed=%d requests=%d", processed, len(activity.requests)) } } @@ -2305,6 +2997,27 @@ func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 { return -1 } +func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) { + t.Helper() + + balances, err := svc.GetBalances(context.Background(), userID, []string{assetType}) + if err != nil { + t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err) + } + if got := balanceAmount(balances, assetType); got != want { + t.Fatalf("balance user=%d asset=%s mismatch: got %d want %d", userID, assetType, got, want) + } +} + +func giftWallItemByID(items []ledger.GiftWallItem, giftID string) (ledger.GiftWallItem, bool) { + for _, item := range items { + if item.GiftID == giftID { + return item, true + } + } + return ledger.GiftWallItem{}, false +} + func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool { for _, item := range items { if item.GiftID == giftID { diff --git a/services/wallet-service/internal/storage/mysql/badge_projection_relay.go b/services/wallet-service/internal/storage/mysql/badge_projection_relay.go index 4a2a1d64..af4477ec 100644 --- a/services/wallet-service/internal/storage/mysql/badge_projection_relay.go +++ b/services/wallet-service/internal/storage/mysql/badge_projection_relay.go @@ -5,10 +5,12 @@ import ( "database/sql" "encoding/json" "errors" + "sort" "strings" "time" "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" "hyapp/pkg/xerr" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) @@ -21,8 +23,9 @@ const ( ) type badgeGrantProjectionCandidate struct { - AppCode string - EventID string + AppCode string + EventID string + CreatedAtMS int64 } type badgeGrantOutboxPayload struct { @@ -73,38 +76,135 @@ func (r *Repository) ClaimPendingBadgeGrantEvents(ctx context.Context, workerID return events, nil } +// ClaimBadgeGrantMessage claims one wallet_outbox MQ fact for badge display projection. +// Runtime projection consumes MQ and does not scan wallet_outbox for candidates. +func (r *Repository) ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { + if r == nil || r.db == nil { + return resourcedomain.BadgeGrantOutbox{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if !isBadgeGrantWalletMessage(message) { + return resourcedomain.BadgeGrantOutbox{}, false, nil + } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return resourcedomain.BadgeGrantOutbox{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if nowMS <= 0 { + nowMS = time.Now().UnixMilli() + } + if lockTTL <= 0 { + lockTTL = 30 * time.Second + } + event := resourcedomain.BadgeGrantOutbox{ + AppCode: appcode.Normalize(message.AppCode), + EventID: strings.TrimSpace(message.EventID), + EventType: strings.TrimSpace(message.EventType), + CommandID: strings.TrimSpace(message.CommandID), + UserID: message.UserID, + PayloadJSON: strings.TrimSpace(message.PayloadJSON), + CreatedAtMS: message.OccurredAtMS, + UpdatedAtMS: message.OccurredAtMS, + } + if event.PayloadJSON == "" { + event.PayloadJSON = "{}" + } + ctx = appcode.WithContext(ctx, event.AppCode) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.BadgeGrantOutbox{}, false, err + } + defer func() { _ = tx.Rollback() }() + return r.claimBadgeGrantEventInTx(ctx, tx, event, workerID, nowMS, lockTTL) +} + func (r *Repository) listBadgeGrantProjectionCandidates(ctx context.Context, nowMS int64, limit int) ([]badgeGrantProjectionCandidate, error) { + appCode := appcode.FromContext(ctx) rows, err := r.db.QueryContext(ctx, ` - SELECT wo.app_code, wo.event_id - FROM wallet_outbox wo + SELECT wo.app_code, wo.event_id, wo.created_at_ms + FROM wallet_outbox wo FORCE INDEX (idx_wallet_outbox_asset_event_created) LEFT JOIN wallet_projection_events pe ON pe.projection_name = ? AND pe.app_code = wo.app_code AND pe.event_id = wo.event_id - WHERE wo.asset_type = ? + WHERE wo.app_code = ? + AND wo.asset_type = ? AND wo.event_type IN (?, ?) - AND ( - pe.event_id IS NULL - OR (pe.status IN (?, ?) AND pe.locked_until_ms <= ?) - ) + AND pe.event_id IS NULL ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, badgeDisplayProjectionName, + appCode, resourceOutboxAsset, walletResourceGrantedEvent, walletResourceGroupGrantedEvent, - projectionStatusFailed, - projectionStatusProcessing, - nowMS, limit, ) if err != nil { return nil, err } - defer rows.Close() + candidates, err := scanBadgeGrantProjectionCandidates(rows, limit) + if err != nil { + return nil, err + } + rows, err = r.db.QueryContext(ctx, ` + SELECT wo.app_code, wo.event_id, wo.created_at_ms + FROM wallet_projection_events pe + JOIN wallet_outbox wo + ON wo.app_code = pe.app_code AND wo.event_id = pe.event_id + WHERE pe.projection_name = ? + AND pe.app_code = ? + AND pe.status IN (?, ?) + AND pe.locked_until_ms <= ? + AND wo.asset_type = ? + AND wo.event_type IN (?, ?) + ORDER BY wo.created_at_ms ASC, wo.event_id ASC + LIMIT ?`, + badgeDisplayProjectionName, + appCode, + projectionStatusFailed, + projectionStatusProcessing, + nowMS, + resourceOutboxAsset, + walletResourceGrantedEvent, + walletResourceGroupGrantedEvent, + limit, + ) + if err != nil { + return nil, err + } + retryCandidates, err := scanBadgeGrantProjectionCandidates(rows, limit) + if err != nil { + return nil, err + } + candidates = append(candidates, retryCandidates...) + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].CreatedAtMS == candidates[j].CreatedAtMS { + return candidates[i].EventID < candidates[j].EventID + } + return candidates[i].CreatedAtMS < candidates[j].CreatedAtMS + }) + if len(candidates) > limit { + candidates = candidates[:limit] + } + return candidates, nil +} +func isBadgeGrantWalletMessage(message walletmq.WalletOutboxMessage) bool { + if strings.TrimSpace(message.AssetType) != resourceOutboxAsset { + return false + } + switch message.EventType { + case walletResourceGrantedEvent, walletResourceGroupGrantedEvent: + return true + default: + return false + } +} + +func scanBadgeGrantProjectionCandidates(rows *sql.Rows, limit int) ([]badgeGrantProjectionCandidate, error) { + defer rows.Close() candidates := make([]badgeGrantProjectionCandidate, 0, limit) for rows.Next() { var candidate badgeGrantProjectionCandidate - if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil { + if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.CreatedAtMS); err != nil { return nil, err } candidates = append(candidates, candidate) @@ -113,7 +213,6 @@ func (r *Repository) listBadgeGrantProjectionCandidates(ctx context.Context, now } func (r *Repository) claimBadgeGrantEvent(ctx context.Context, candidate badgeGrantProjectionCandidate, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { - lockUntilMS := time.UnixMilli(nowMS).Add(lockTTL).UnixMilli() ctx = appcode.WithContext(ctx, candidate.AppCode) tx, err := r.db.BeginTx(ctx, nil) @@ -129,6 +228,12 @@ func (r *Repository) claimBadgeGrantEvent(ctx context.Context, candidate badgeGr } return resourcedomain.BadgeGrantOutbox{}, false, err } + return r.claimBadgeGrantEventInTx(ctx, tx, event, workerID, nowMS, lockTTL) +} + +func (r *Repository) claimBadgeGrantEventInTx(ctx context.Context, tx *sql.Tx, event resourcedomain.BadgeGrantOutbox, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { + lockUntilMS := time.UnixMilli(nowMS).Add(lockTTL).UnixMilli() + claimed, err := r.claimBadgeProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMS) if err != nil || !claimed { return resourcedomain.BadgeGrantOutbox{}, false, err diff --git a/services/wallet-service/internal/storage/mysql/gift_wall_projection.go b/services/wallet-service/internal/storage/mysql/gift_wall_projection.go index 4fd54687..d69f4c62 100644 --- a/services/wallet-service/internal/storage/mysql/gift_wall_projection.go +++ b/services/wallet-service/internal/storage/mysql/gift_wall_projection.go @@ -5,10 +5,12 @@ import ( "database/sql" "encoding/json" "errors" + "sort" "strings" "time" "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" "hyapp/pkg/xerr" ) @@ -26,8 +28,9 @@ const ( ) type giftWallProjectionCandidate struct { - AppCode string - EventID string + AppCode string + EventID string + CreatedAtMS int64 } type giftWallProjectionEvent struct { @@ -77,36 +80,115 @@ func (r *Repository) ProjectPendingGiftWallEvents(ctx context.Context, workerID return processed, nil } +// ProjectGiftWallMessage projects one wallet_outbox MQ fact into user_gift_wall. +// Runtime projection consumes MQ and does not scan wallet_outbox for candidates. +func (r *Repository) ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { + if r == nil || r.db == nil { + return false, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if message.EventType != walletGiftDebitedEvent { + return false, nil + } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return false, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if lockTTL <= 0 { + lockTTL = 30 * time.Second + } + nowMs := time.Now().UnixMilli() + lockUntilMS := time.Now().Add(lockTTL).UnixMilli() + event := giftWallProjectionEvent{ + AppCode: appcode.Normalize(message.AppCode), + EventID: strings.TrimSpace(message.EventID), + TransactionID: strings.TrimSpace(message.TransactionID), + CommandID: strings.TrimSpace(message.CommandID), + PayloadJSON: strings.TrimSpace(message.PayloadJSON), + CreatedAtMS: message.OccurredAtMS, + } + if event.PayloadJSON == "" { + event.PayloadJSON = "{}" + } + ctx = appcode.WithContext(ctx, event.AppCode) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) +} + func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) { nowMs := time.Now().UnixMilli() + appCode := appcode.FromContext(ctx) rows, err := r.db.QueryContext(ctx, ` - SELECT wo.app_code, wo.event_id - FROM wallet_outbox wo + SELECT wo.app_code, wo.event_id, wo.created_at_ms + FROM wallet_outbox wo FORCE INDEX (idx_wallet_outbox_event_created) LEFT JOIN wallet_projection_events pe ON pe.projection_name = ? AND pe.app_code = wo.app_code AND pe.event_id = wo.event_id - WHERE wo.event_type = ? - AND ( - pe.event_id IS NULL - OR (pe.status IN (?, ?) AND pe.locked_until_ms <= ?) - ) + WHERE wo.app_code = ? + AND wo.event_type = ? + AND pe.event_id IS NULL ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, giftWallProjectionName, + appCode, walletGiftDebitedEvent, - projectionStatusFailed, - projectionStatusProcessing, - nowMs, limit, ) if err != nil { return nil, err } - defer rows.Close() + candidates, err := scanGiftWallProjectionCandidates(rows, limit) + if err != nil { + return nil, err + } + rows, err = r.db.QueryContext(ctx, ` + SELECT wo.app_code, wo.event_id, wo.created_at_ms + FROM wallet_projection_events pe + JOIN wallet_outbox wo + ON wo.app_code = pe.app_code AND wo.event_id = pe.event_id + WHERE pe.projection_name = ? + AND pe.app_code = ? + AND pe.status IN (?, ?) + AND pe.locked_until_ms <= ? + AND wo.event_type = ? + ORDER BY wo.created_at_ms ASC, wo.event_id ASC + LIMIT ?`, + giftWallProjectionName, + appCode, + projectionStatusFailed, + projectionStatusProcessing, + nowMs, + walletGiftDebitedEvent, + limit, + ) + if err != nil { + return nil, err + } + retryCandidates, err := scanGiftWallProjectionCandidates(rows, limit) + if err != nil { + return nil, err + } + candidates = append(candidates, retryCandidates...) + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].CreatedAtMS == candidates[j].CreatedAtMS { + return candidates[i].EventID < candidates[j].EventID + } + return candidates[i].CreatedAtMS < candidates[j].CreatedAtMS + }) + if len(candidates) > limit { + candidates = candidates[:limit] + } + return candidates, nil +} +func scanGiftWallProjectionCandidates(rows *sql.Rows, limit int) ([]giftWallProjectionCandidate, error) { + defer rows.Close() candidates := make([]giftWallProjectionCandidate, 0, limit) for rows.Next() { var candidate giftWallProjectionCandidate - if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil { + if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.CreatedAtMS); err != nil { return nil, err } candidates = append(candidates, candidate) @@ -133,6 +215,10 @@ func (r *Repository) projectGiftWallEvent(ctx context.Context, candidate giftWal } return false, err } + return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) +} + +func (r *Repository) projectGiftWallLockedEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) { claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) if err != nil || !claimed { return false, err diff --git a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go new file mode 100644 index 00000000..0b444a34 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go @@ -0,0 +1,642 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "math/big" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +type hostSalaryCandidate struct { + // UserID 与 CycleKey 唯一定位主播当月工资周期账户;后续所有结算进度都挂在这组键上。 + UserID int64 + CycleKey string + RegionID int64 + AgencyOwnerUserID int64 + TotalDiamonds int64 + GiftDiamondTotal int64 +} + +type hostSalaryProgress struct { + // 这些 settled_* 字段保存“已发累计权益”,结算时只对比当前等级累计值来补差额,天然防重复发放。 + SettledLevelNo int + SettledHostSalaryUSDMinor int64 + SettledHostCoinReward int64 + SettledAgencySalaryUSDMinor int64 + LastSettledTotalDiamonds int64 + ResidualUSDMinor int64 + MonthEndClearedAtMS int64 + LastPolicyID uint64 + Version int64 +} + +type hostSalarySettlementMetadata struct { + // metadata 是审计快照:钱包交易、outbox、结算记录共享同一份口径,方便后续从任一链路追账。 + AppCode string `json:"app_code"` + SettlementType string `json:"settlement_type"` + HostUserID int64 `json:"host_user_id"` + AgencyOwnerUserID int64 `json:"agency_owner_user_id"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + LevelNo int `json:"level_no"` + RequiredDiamonds int64 `json:"required_diamonds"` + TotalDiamonds int64 `json:"total_diamonds"` + HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"` + HostCoinRewardDelta int64 `json:"host_coin_reward_delta"` + AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"` + ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"` + HostUSDBalanceAfter int64 `json:"host_usd_balance_after"` + HostCoinBalanceAfter int64 `json:"host_coin_balance_after"` + AgencyUSDBalanceAfter int64 `json:"agency_usd_balance_after"` + SettledLevelNo int `json:"settled_level_no"` + SettledHostSalaryUSDMinor int64 `json:"settled_host_salary_usd_minor"` + SettledHostCoinReward int64 `json:"settled_host_coin_reward"` + SettledAgencySalaryUSDMinor int64 `json:"settled_agency_salary_usd_minor"` + ResidualUSDMinor int64 `json:"residual_usd_minor"` + MonthEndClearedAtMS int64 `json:"month_end_cleared_at_ms"` + ProcessedAtMS int64 `json:"processed_at_ms"` +} + +// ProcessHostSalarySettlementBatch 按周期账户扫描并结算主播美元、主播金币奖励和代理美元工资。 +// 每个主播单独开事务,避免某个主播配置异常拖垮整个批次,也让 cron-service 可按 has_more 继续拉下一页。 +func (r *Repository) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { + if r == nil || r.db == nil { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.SettlementType = normalizeHostSalarySettlementType(command.SettlementType) + if command.SettlementType == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") + } + command.TriggerMode = normalizeHostSalarySettlementTriggerMode(command.TriggerMode) + if command.TriggerMode == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "trigger_mode is invalid") + } + if command.NowMs <= 0 { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "now_ms is required") + } + if command.BatchSize <= 0 { + command.BatchSize = 100 + } + limit := command.BatchSize + 1 + candidates, err := r.listHostSalaryCandidates(ctx, command, limit) + if err != nil { + return ledger.HostSalarySettlementBatchResult{}, err + } + hasMore := len(candidates) > command.BatchSize + if hasMore { + candidates = candidates[:command.BatchSize] + } + + result := ledger.HostSalarySettlementBatchResult{ClaimedCount: len(candidates), HasMore: hasMore} + var firstErr error + for _, candidate := range candidates { + processed, err := r.processHostSalaryCandidate(ctx, command, candidate) + result.ProcessedCount++ + if err != nil { + if firstErr == nil { + firstErr = err + } + result.FailureCount++ + continue + } + if processed { + result.SuccessCount++ + } + } + if result.ClaimedCount > 0 && result.SuccessCount == 0 && firstErr != nil { + return result, firstErr + } + return result, nil +} + +func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledger.HostSalarySettlementBatchCommand, limit int) ([]hostSalaryCandidate, error) { + policyModeClause := "" + args := []any{appcode.FromContext(ctx), command.CycleKey, command.NowMs, command.NowMs, command.TriggerMode} + if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd { + // 日结和半月结必须精确匹配政策配置的结算模式;月底清算复用有效政策处理剩余钻石和周期关闭。 + policyModeClause = "AND p.settlement_mode = ?" + args = append(args, command.SettlementType) + } + // 日结/半月结只扫描“本周期钻石比上次结算更多”的主播,避免 cron 重跑时反复锁定无新增收益的账户。 + // 月底批次必须扫描全部未关闭周期账户,因为它还负责剩余钻石折美元和周期逻辑关闭。 + progressClause := "AND a.total_diamonds > COALESCE(progress.last_settled_total_diamonds, 0)" + if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { + progressClause = "" + } + args = append(args, limit) + query := fmt.Sprintf(` + SELECT a.user_id, a.cycle_key, a.region_id, a.agency_owner_user_id, a.total_diamonds, a.gift_diamond_total + FROM host_period_diamond_accounts a + LEFT JOIN host_salary_settlement_progress progress + ON progress.app_code = a.app_code AND progress.user_id = a.user_id AND progress.cycle_key = a.cycle_key + WHERE a.app_code = ? + AND a.cycle_key = ? + AND a.total_diamonds > 0 + AND COALESCE(progress.month_end_cleared_at_ms, 0) = 0 + %s + AND EXISTS ( + SELECT 1 + FROM host_agency_salary_policies p + WHERE p.app_code = a.app_code + AND p.region_id = a.region_id + AND p.status = 'active' + AND p.effective_from_ms <= ? + AND (p.effective_to_ms = 0 OR p.effective_to_ms > ?) + AND p.settlement_trigger_mode = ? + %s + ) + ORDER BY a.updated_at_ms ASC, a.user_id ASC + LIMIT ?`, progressClause, policyModeClause) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + candidates := make([]hostSalaryCandidate, 0, limit) + for rows.Next() { + var candidate hostSalaryCandidate + if err := rows.Scan(&candidate.UserID, &candidate.CycleKey, &candidate.RegionID, &candidate.AgencyOwnerUserID, &candidate.TotalDiamonds, &candidate.GiftDiamondTotal); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +func (r *Repository) processHostSalaryCandidate(ctx context.Context, command ledger.HostSalarySettlementBatchCommand, selected hostSalaryCandidate) (bool, error) { + // 账务、进度和 outbox 必须在同一个主播级事务中提交;批次层只负责继续处理下一位主播。 + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + + account, exists, err := r.lockHostSalaryCandidate(ctx, tx, selected.UserID, selected.CycleKey) + if err != nil || !exists { + return false, err + } + progress, err := r.lockHostSalaryProgress(ctx, tx, account.UserID, account.CycleKey, command.NowMs) + if err != nil { + return false, err + } + if progress.MonthEndClearedAtMS > 0 { + return true, tx.Commit() + } + // 非月底批次不关闭周期,若同一周期已结算到当前钻石数,则本次只作为幂等空跑通过。 + if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd && account.TotalDiamonds <= progress.LastSettledTotalDiamonds { + return true, tx.Commit() + } + + // 政策按周期账户里的区域快照解析;该快照由同周期最近一次主播收礼写入。 + policy, ok, err := r.resolveHostSalaryPolicy(ctx, tx, account.RegionID, command.SettlementType, command.TriggerMode, command.NowMs) + if err != nil || !ok { + return false, err + } + // 工资表是累计值,因此每次只发“目标累计值 - 已发累计值”;这对应产品规则里的 2 级只补 2 级减 1 级。 + level := highestHostSalaryLevel(policy.Levels, account.TotalDiamonds) + residualTarget := progress.ResidualUSDMinor + residualDelta := int64(0) + if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { + // 月底不物理清零历史钻石,改为把剩余钻石折算入账并写 month_end_cleared_at_ms,保留审计和重放依据。 + residualDiamonds := residualDiamondRemainder(account.TotalDiamonds, level) + residualTarget, err = usdMinorFromDiamondRate(residualDiamonds, policy.ResidualDiamondToUSDRate) + if err != nil { + return false, err + } + residualDelta = positiveDelta(residualTarget, progress.ResidualUSDMinor) + } + + hostSalaryDelta := positiveDelta(level.HostSalaryUSDMinor, progress.SettledHostSalaryUSDMinor) + hostCoinDelta := positiveDelta(level.HostCoinReward, progress.SettledHostCoinReward) + agencyEntitlementDelta := positiveDelta(level.AgencySalaryUSDMinor, progress.SettledAgencySalaryUSDMinor) + agencyPaidDelta := agencyEntitlementDelta + if account.AgencyOwnerUserID <= 0 { + // 没有代理收款人时只把该等级代理权益标记为已处理,不在未来组织绑定后补发历史月份工资。 + agencyPaidDelta = 0 + } + + nextProgress := hostSalaryProgress{ + SettledLevelNo: maxInt(progress.SettledLevelNo, level.LevelNo), + SettledHostSalaryUSDMinor: maxInt64(progress.SettledHostSalaryUSDMinor, level.HostSalaryUSDMinor), + SettledHostCoinReward: maxInt64(progress.SettledHostCoinReward, level.HostCoinReward), + SettledAgencySalaryUSDMinor: maxInt64(progress.SettledAgencySalaryUSDMinor, level.AgencySalaryUSDMinor), + LastSettledTotalDiamonds: account.TotalDiamonds, + ResidualUSDMinor: maxInt64(progress.ResidualUSDMinor, residualTarget), + MonthEndClearedAtMS: progress.MonthEndClearedAtMS, + LastPolicyID: policy.PolicyID, + Version: progress.Version, + } + if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { + nextProgress.MonthEndClearedAtMS = command.NowMs + } + + paidHostUSDDelta, err := checkedAdd(hostSalaryDelta, residualDelta) + if err != nil { + return false, err + } + metadata := hostSalarySettlementMetadata{ + AppCode: appcode.FromContext(ctx), + SettlementType: command.SettlementType, + HostUserID: account.UserID, + AgencyOwnerUserID: account.AgencyOwnerUserID, + CycleKey: account.CycleKey, + RegionID: account.RegionID, + PolicyID: policy.PolicyID, + PolicyName: policy.Name, + LevelNo: level.LevelNo, + RequiredDiamonds: level.RequiredDiamonds, + TotalDiamonds: account.TotalDiamonds, + HostSalaryUSDMinorDelta: hostSalaryDelta, + HostCoinRewardDelta: hostCoinDelta, + AgencySalaryUSDMinorDelta: agencyPaidDelta, + ResidualUSDMinorDelta: residualDelta, + SettledLevelNo: nextProgress.SettledLevelNo, + SettledHostSalaryUSDMinor: nextProgress.SettledHostSalaryUSDMinor, + SettledHostCoinReward: nextProgress.SettledHostCoinReward, + SettledAgencySalaryUSDMinor: nextProgress.SettledAgencySalaryUSDMinor, + ResidualUSDMinor: nextProgress.ResidualUSDMinor, + MonthEndClearedAtMS: nextProgress.MonthEndClearedAtMS, + ProcessedAtMS: command.NowMs, + } + + if paidHostUSDDelta > 0 || hostCoinDelta > 0 || agencyPaidDelta > 0 { + // settlement commandID 带上已结算等级和当前钻石,确保同一增量重复执行会命中交易幂等键。 + commandID := hostSalarySettlementCommandID(command, account, nextProgress) + transactionID := transactionID(appcode.FromContext(ctx), commandID) + if err := r.insertTransaction(ctx, tx, transactionID, commandID, bizTypeHostSalarySettlement, hostSalarySettlementRequestHash(metadata), fmt.Sprintf("%s:%d", account.CycleKey, account.UserID), metadata, command.NowMs); err != nil { + return false, err + } + events, err := r.applyHostSalaryCredits(ctx, tx, transactionID, commandID, metadata, paidHostUSDDelta, hostCoinDelta, agencyPaidDelta, command.NowMs) + if err != nil { + return false, err + } + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { + return false, err + } + if err := r.insertHostSalarySettlementRecord(ctx, tx, transactionID, commandID, metadata, command.NowMs); err != nil { + return false, err + } + } + if err := r.updateHostSalaryProgress(ctx, tx, account.UserID, account.CycleKey, nextProgress, command.NowMs); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} + +func (r *Repository) lockHostSalaryCandidate(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (hostSalaryCandidate, bool, error) { + // 锁定周期账户后再读取总钻石,防止送礼入账与结算批次并发时结算到半更新状态。 + row := tx.QueryRowContext(ctx, ` + SELECT user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total + FROM host_period_diamond_accounts + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, appcode.FromContext(ctx), userID, cycleKey) + var account hostSalaryCandidate + if err := row.Scan(&account.UserID, &account.CycleKey, &account.RegionID, &account.AgencyOwnerUserID, &account.TotalDiamonds, &account.GiftDiamondTotal); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return hostSalaryCandidate{}, false, nil + } + return hostSalaryCandidate{}, false, err + } + return account, true, nil +} + +func (r *Repository) lockHostSalaryProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, nowMs int64) (hostSalaryProgress, error) { + // INSERT IGNORE 让第一次结算自动创建进度行;随后 FOR UPDATE 串行化同主播同周期的所有结算请求。 + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO host_salary_settlement_progress ( + app_code, user_id, cycle_key, settled_level_no, settled_host_salary_usd_minor, + settled_host_coin_reward, settled_agency_salary_usd_minor, last_settled_total_diamonds, + residual_usd_minor, month_end_cleared_at_ms, last_policy_id, version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 1, ?, ?)`, + appcode.FromContext(ctx), userID, cycleKey, nowMs, nowMs, + ); err != nil { + return hostSalaryProgress{}, err + } + row := tx.QueryRowContext(ctx, ` + SELECT settled_level_no, settled_host_salary_usd_minor, settled_host_coin_reward, + settled_agency_salary_usd_minor, last_settled_total_diamonds, residual_usd_minor, + month_end_cleared_at_ms, last_policy_id, version + FROM host_salary_settlement_progress + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, appcode.FromContext(ctx), userID, cycleKey) + var progress hostSalaryProgress + if err := row.Scan(&progress.SettledLevelNo, &progress.SettledHostSalaryUSDMinor, &progress.SettledHostCoinReward, &progress.SettledAgencySalaryUSDMinor, &progress.LastSettledTotalDiamonds, &progress.ResidualUSDMinor, &progress.MonthEndClearedAtMS, &progress.LastPolicyID, &progress.Version); err != nil { + return hostSalaryProgress{}, err + } + return progress, nil +} + +func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, regionID int64, settlementType string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { + modeClause := "" + args := []any{appcode.FromContext(ctx), regionID, nowMs, nowMs, triggerMode} + if settlementType != ledger.HostSalarySettlementTypeMonthEnd { + // 人工/自动触发模式也参与政策匹配,避免自动任务误结算只允许手动处理的政策。 + modeClause = "AND settlement_mode = ?" + args = append(args, settlementType) + } + query := fmt.Sprintf(` + SELECT policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, + CAST(gift_coin_to_diamond_ratio AS CHAR), + CAST(residual_diamond_to_usd_rate AS CHAR), + effective_from_ms, effective_to_ms + FROM host_agency_salary_policies + WHERE app_code = ? + AND region_id = ? + AND status = 'active' + AND effective_from_ms <= ? + AND (effective_to_ms = 0 OR effective_to_ms > ?) + AND settlement_trigger_mode = ? + %s + ORDER BY effective_from_ms DESC, policy_id DESC + LIMIT 1`, modeClause) + var policy ledger.HostSalaryPolicy + err := tx.QueryRowContext(ctx, query, args...).Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.Status, &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.EffectiveFromMs, &policy.EffectiveToMs) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ledger.HostSalaryPolicy{}, false, nil + } + return ledger.HostSalaryPolicy{}, false, err + } + levels, err := r.listHostSalaryPolicyLevels(ctx, tx, policy.PolicyID) + if err != nil { + return ledger.HostSalaryPolicy{}, false, err + } + policy.Levels = levels + return policy, true, nil +} + +func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, tx *sql.Tx, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) { + // 等级必须按 required_diamonds 升序返回,highestHostSalaryLevel 依赖这个顺序找到最高已达档位。 + rows, err := tx.QueryContext(ctx, ` + SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, + agency_salary_usd_minor, status, sort_order + FROM host_agency_salary_policy_levels + WHERE app_code = ? AND policy_id = ? AND status = 'active' + ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID) + if err != nil { + return nil, err + } + defer rows.Close() + var levels []ledger.HostSalaryPolicyLevel + for rows.Next() { + var level ledger.HostSalaryPolicyLevel + if err := rows.Scan(&level.LevelNo, &level.RequiredDiamonds, &level.HostSalaryUSDMinor, &level.HostCoinReward, &level.AgencySalaryUSDMinor, &level.Status, &level.SortOrder); err != nil { + return nil, err + } + levels = append(levels, level) + } + return levels, rows.Err() +} + +func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata hostSalarySettlementMetadata, hostUSDDelta int64, hostCoinDelta int64, agencyUSDDelta int64, nowMs int64) ([]walletOutboxEvent, error) { + // 主播美元、主播金币、代理美元共用同一 transaction_id,确保账务上可视为一次工资结算。 + events := make([]walletOutboxEvent, 0, 3) + if hostUSDDelta > 0 { + balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetUSDBalance, hostUSDDelta, 0, nowMs) + if err != nil { + return nil, err + } + metadata.HostUSDBalanceAfter = balanceAfter + events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetUSDBalance, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs)) + } + if hostCoinDelta > 0 { + balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, nowMs) + if err != nil { + return nil, err + } + metadata.HostCoinBalanceAfter = balanceAfter + events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, balanceAfter, 0, version, metadata, nowMs)) + } + if agencyUSDDelta > 0 { + balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.AgencyOwnerUserID, ledger.AssetUSDBalance, agencyUSDDelta, metadata.HostUserID, nowMs) + if err != nil { + return nil, err + } + metadata.AgencyUSDBalanceAfter = balanceAfter + events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetUSDBalance, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs)) + } + return events, nil +} + +func (r *Repository) creditSettlementAsset(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string, amount int64, counterpartyUserID int64, nowMs int64) (int64, int64, error) { + // 工资入账只增加可用余额,不走冻结;counterparty 仅用于代理工资反查来源主播。 + account, err := r.lockAccount(ctx, tx, userID, assetType, true, nowMs) + if err != nil { + return 0, 0, err + } + if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil { + return 0, 0, err + } + after := account.AvailableAmount + amount + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: userID, + AssetType: assetType, + AvailableDelta: amount, + FrozenDelta: 0, + AvailableAfter: after, + FrozenAfter: account.FrozenAmount, + CounterpartyUserID: counterpartyUserID, + CreatedAtMS: nowMs, + }); err != nil { + return 0, 0, err + } + return after, account.Version + 1, nil +} + +func (r *Repository) insertHostSalarySettlementRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata hostSalarySettlementMetadata, nowMs int64) error { + // 结算记录保存每次实际发放的增量;BD 结算后续从这里汇总主播工资和剩余钻石折美元。 + _, err := tx.ExecContext(ctx, ` + INSERT INTO host_salary_settlement_records ( + app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, + agency_owner_user_id, cycle_key, policy_id, level_no, total_diamonds, + host_salary_usd_minor_delta, host_coin_reward_delta, agency_salary_usd_minor_delta, + residual_usd_minor_delta, status, reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', '', ?)`, + appcode.FromContext(ctx), + hostSalarySettlementID(metadata.AppCode, commandID), + commandID, + transactionID, + metadata.SettlementType, + metadata.HostUserID, + metadata.AgencyOwnerUserID, + metadata.CycleKey, + metadata.PolicyID, + metadata.LevelNo, + metadata.TotalDiamonds, + metadata.HostSalaryUSDMinorDelta, + metadata.HostCoinRewardDelta, + metadata.AgencySalaryUSDMinorDelta, + metadata.ResidualUSDMinorDelta, + nowMs, + ) + return err +} + +func (r *Repository) updateHostSalaryProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, progress hostSalaryProgress, nowMs int64) error { + // version 乐观锁补充 FOR UPDATE 之外的防线:异常重入不会悄悄覆盖已推进的进度。 + result, err := tx.ExecContext(ctx, ` + UPDATE host_salary_settlement_progress + SET settled_level_no = ?, + settled_host_salary_usd_minor = ?, + settled_host_coin_reward = ?, + settled_agency_salary_usd_minor = ?, + last_settled_total_diamonds = ?, + residual_usd_minor = ?, + month_end_cleared_at_ms = ?, + last_policy_id = ?, + version = version + 1, + updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND cycle_key = ? AND version = ?`, + progress.SettledLevelNo, + progress.SettledHostSalaryUSDMinor, + progress.SettledHostCoinReward, + progress.SettledAgencySalaryUSDMinor, + progress.LastSettledTotalDiamonds, + progress.ResidualUSDMinor, + progress.MonthEndClearedAtMS, + progress.LastPolicyID, + nowMs, + appcode.FromContext(ctx), + userID, + cycleKey, + progress.Version, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return xerr.New(xerr.LedgerConflict, "host salary settlement progress version conflict") + } + return nil +} + +func highestHostSalaryLevel(levels []ledger.HostSalaryPolicyLevel, totalDiamonds int64) ledger.HostSalaryPolicyLevel { + // 未达到任何等级时返回零值,后续 delta 都为 0;仍可在月底用于只折算剩余钻石。 + var selected ledger.HostSalaryPolicyLevel + for _, level := range levels { + if level.RequiredDiamonds <= totalDiamonds { + selected = level + } + } + return selected +} + +func residualDiamondRemainder(totalDiamonds int64, level ledger.HostSalaryPolicyLevel) int64 { + // 剩余钻石只计算最高已达等级以上的部分,避免把已获得等级工资的门槛钻石重复折美元。 + if level.LevelNo <= 0 || totalDiamonds <= level.RequiredDiamonds { + return 0 + } + return totalDiamonds - level.RequiredDiamonds +} + +func usdMinorFromDiamondRate(diamonds int64, rate string) (int64, error) { + // 使用 big.Rat 做精确十进制换算,避免 residual_diamond_to_usd_rate 走 float 后出现分位误差。 + if diamonds <= 0 || strings.TrimSpace(rate) == "" { + return 0, nil + } + rat, ok := new(big.Rat).SetString(strings.TrimSpace(rate)) + if !ok || rat.Sign() < 0 { + return 0, xerr.New(xerr.InvalidArgument, "residual diamond rate is invalid") + } + value := new(big.Rat).Mul(new(big.Rat).SetInt64(diamonds), rat) + value.Mul(value, big.NewRat(100, 1)) + result := new(big.Int).Quo(value.Num(), value.Denom()) + if !result.IsInt64() || result.Int64() > math.MaxInt64 { + return 0, xerr.New(xerr.InvalidArgument, "residual diamond amount overflow") + } + return result.Int64(), nil +} + +func normalizeHostSalarySettlementType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case ledger.HostSalarySettlementModeDaily: + return ledger.HostSalarySettlementModeDaily + case ledger.HostSalarySettlementModeHalfMonth: + return ledger.HostSalarySettlementModeHalfMonth + case ledger.HostSalarySettlementTypeMonthEnd: + return ledger.HostSalarySettlementTypeMonthEnd + default: + return "" + } +} + +func normalizeHostSalarySettlementTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", ledger.HostSalarySettlementTriggerAutomatic: + // 空值按自动处理,保证已有 cron 调用不需要同步改所有入参。 + return ledger.HostSalarySettlementTriggerAutomatic + case ledger.HostSalarySettlementTriggerManual: + return ledger.HostSalarySettlementTriggerManual + default: + return "" + } +} + +func hostSalarySettlementCommandID(command ledger.HostSalarySettlementBatchCommand, account hostSalaryCandidate, progress hostSalaryProgress) string { + // commandID 包含“已推进后的等级 + 当前钻石”,同一状态重放命中同一个交易,不同增量生成新交易。 + return fmt.Sprintf("host_salary:%s:%s:%d:%d:%d", command.SettlementType, account.CycleKey, account.UserID, progress.SettledLevelNo, account.TotalDiamonds) +} + +func hostSalarySettlementID(appCode string, commandID string) string { + // settlement_id 对外稳定且不暴露原始 command_id,方便后台查账和防重复记录。 + return fmt.Sprintf("hsset_%s_%s", appcode.Normalize(appCode), stableHash(commandID)) +} + +func hostSalarySettlementRequestHash(metadata hostSalarySettlementMetadata) string { + // 请求哈希覆盖金额、等级和周期口径;同 commandID 但 payload 改变会被交易层识别为冲突。 + return stableHash(fmt.Sprintf("host_salary|%s|%s|%d|%d|%s|%d|%d|%d|%d|%d|%d|%d", + appcode.Normalize(metadata.AppCode), + metadata.SettlementType, + metadata.HostUserID, + metadata.AgencyOwnerUserID, + metadata.CycleKey, + metadata.PolicyID, + metadata.LevelNo, + metadata.TotalDiamonds, + metadata.HostSalaryUSDMinorDelta, + metadata.HostCoinRewardDelta, + metadata.AgencySalaryUSDMinorDelta, + metadata.ResidualUSDMinorDelta, + )) +} + +func positiveDelta(target int64, current int64) int64 { + if target <= current { + return 0 + } + return target - current +} + +func maxInt(left int, right int) int { + if right > left { + return right + } + return left +} + +func maxInt64(left int64, right int64) int64 { + if right > left { + return right + } + return left +} diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 1edd591a..5b9bfcfe 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -34,6 +34,7 @@ const ( bizTypeGameCredit = "game_credit" bizTypeGameRefund = "game_refund" bizTypeGameReverse = "game_reverse" + bizTypeHostSalarySettlement = "host_salary_settlement" outboxStatusPending = "pending" outboxStatusDelivering = "delivering" outboxStatusDelivered = "delivered" @@ -104,7 +105,7 @@ func (r *Repository) Ping(ctx context.Context) error { return r.db.PingContext(ctx) } -// DebitGift 在一个 MySQL 事务内完成 sender COIN 扣减、target GIFT_POINT 入账、分录和 outbox。 +// DebitGift 在一个 MySQL 事务内完成 sender 扣费、target GIFT_POINT 入账、主播周期钻石入账、分录和 outbox。 func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { if r == nil || r.db == nil { return ledger.Receipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -154,6 +155,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if err != nil { return ledger.Receipt{}, err } + hostPeriodDiamondAdded := int64(0) + hostPeriodCycleKey := "" + if command.TargetIsHost { + // Phase 2 固化“用户礼物金币价值 -> 主播周期钻石”的默认 1:1 口径;后续政策配置只替换这里的换算快照。 + // 非主播不会进入该分支,因此普通用户收礼只增加 GIFT_POINT,不产生工资周期积分。 + hostPeriodDiamondAdded = chargeAmount + hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + } sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, chargeAmount == 0, nowMs) if err != nil { @@ -169,29 +178,34 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm transactionID := transactionID(command.AppCode, command.CommandID) metadata := giftMetadata{ - AppCode: command.AppCode, - GiftID: command.GiftID, - GiftName: giftConfig.Name, - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: price.ChargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - GiftPointAdded: giftPointAdded, - HeatValue: heatValue, - BalanceAfter: sender.AvailableAmount - chargeAmount, - BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), - SenderUserID: command.SenderUserID, - TargetUserID: command.TargetUserID, - RoomID: command.RoomID, - CoinPrice: price.CoinPrice, - GiftPointAmount: price.GiftPointAmount, - HeatUnitValue: price.HeatValue, + AppCode: command.AppCode, + GiftID: command.GiftID, + GiftName: giftConfig.Name, + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: price.ChargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + GiftPointAdded: giftPointAdded, + HeatValue: heatValue, + BalanceAfter: sender.AvailableAmount - chargeAmount, + BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), + SenderUserID: command.SenderUserID, + TargetUserID: command.TargetUserID, + TargetIsHost: command.TargetIsHost, + TargetHostRegionID: command.TargetHostRegionID, + TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + CoinPrice: price.CoinPrice, + GiftPointAmount: price.GiftPointAmount, + HeatUnitValue: price.HeatValue, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { return ledger.Receipt{}, err @@ -232,11 +246,25 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm }); err != nil { return ledger.Receipt{}, err } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + if hostPeriodDiamondAdded > 0 { + // 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币、主播礼物积分和工资积分会一起回滚。 + hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter + metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + } + + events := []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs), balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs), - }); err != nil { + } + if hostPeriodDiamondAdded > 0 { + events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) + } + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { return ledger.Receipt{}, err } if err := tx.Commit(); err != nil { @@ -565,17 +593,18 @@ func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.L transactionID := transactionID(command.AppCode, command.CommandID) balanceAfter := account.AvailableAmount + command.Amount metadata := luckyGiftRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - DrawID: command.DrawID, - RoomID: command.RoomID, - GiftID: command.GiftID, - PoolID: command.PoolID, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + DrawID: command.DrawID, + RoomID: command.RoomID, + VisibleRegionID: command.VisibleRegionID, + GiftID: command.GiftID, + PoolID: command.PoolID, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeLuckyGiftReward, requestHash, command.DrawID, metadata, nowMs); err != nil { return ledger.LuckyGiftRewardReceipt{}, err @@ -1325,6 +1354,141 @@ func (r *Repository) insertEntry(ctx context.Context, tx *sql.Tx, entry walletEn return err } +func (r *Repository) creditHostPeriodDiamonds(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, nowMs int64) (int64, int64, error) { + // host_period_diamond_accounts 是工资引擎读取的投影表,不是可提现钱包资产;只有主播身份收礼会写入。 + if metadata.HostPeriodDiamondAdded <= 0 { + return 0, 0, nil + } + after, version, exists, err := r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) + if err != nil { + return 0, 0, err + } + if !exists { + // 周期账户只在主播首次收礼时创建;工资结算任务按 cycle_key 扫描,不依赖通用 wallet_accounts。 + // 首笔送礼写入当时区域和代理;同周期后续收礼会刷新快照,结算以周期账户最终快照为准。 + after = metadata.HostPeriodDiamondAdded + version = 1 + _, err = tx.ExecContext(ctx, + `INSERT INTO host_period_diamond_accounts ( + app_code, user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total, + version, first_gift_at_ms, last_gift_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + after, + metadata.HostPeriodDiamondAdded, + version, + nowMs, + nowMs, + nowMs, + nowMs, + ) + if err != nil && isMySQLDuplicateError(err) { + // 并发首笔送礼可能同时发现账户不存在;退回锁读更新路径,保证不丢钻石。 + after, version, exists, err = r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) + } + if err != nil { + return 0, 0, err + } + if !exists { + if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { + return 0, 0, err + } + return after, version, nil + } + } + + after, err = checkedAdd(after, metadata.HostPeriodDiamondAdded) + if err != nil { + return 0, 0, err + } + version++ + // 已存在账户时更新累计钻石,同时刷新区域/代理快照为最近一次收礼状态;这符合“当月当前归属”口径。 + result, err := tx.ExecContext(ctx, + `UPDATE host_period_diamond_accounts + SET region_id = ?, agency_owner_user_id = ?, total_diamonds = ?, gift_diamond_total = gift_diamond_total + ?, + version = ?, last_gift_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND cycle_key = ?`, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + after, + metadata.HostPeriodDiamondAdded, + version, + nowMs, + nowMs, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + ) + if err != nil { + return 0, 0, err + } + rows, err := result.RowsAffected() + if err != nil { + return 0, 0, err + } + if rows != 1 { + return 0, 0, xerr.New(xerr.LedgerConflict, "host period diamond account version conflict") + } + if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { + return 0, 0, err + } + return after, version, nil +} + +func (r *Repository) lockHostPeriodDiamondAccount(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (int64, int64, bool, error) { + // FOR UPDATE 串行化同主播同周期的并发送礼,保证 total_diamonds 不会被覆盖丢失。 + row := tx.QueryRowContext(ctx, + `SELECT total_diamonds, version + FROM host_period_diamond_accounts + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, + appcode.FromContext(ctx), + userID, + cycleKey, + ) + var totalDiamonds int64 + var version int64 + if err := row.Scan(&totalDiamonds, &version); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, false, nil + } + return 0, 0, false, err + } + return totalDiamonds, version, true, nil +} + +func (r *Repository) insertHostPeriodDiamondEntry(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, diamondAfter int64, nowMs int64) error { + // 明细表记录每一笔送礼带来的工资钻石,后续核对周期账户累计值时不需要解析钱包 metadata。 + _, err := tx.ExecContext(ctx, + `INSERT INTO host_period_diamond_entries ( + app_code, transaction_id, command_id, user_id, cycle_key, region_id, agency_owner_user_id, + diamond_delta, diamond_after, gift_charge_asset_type, gift_charge_amount, + gift_id, gift_count, sender_user_id, room_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + transactionID, + commandID, + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + metadata.HostPeriodDiamondAdded, + diamondAfter, + metadata.ChargeAssetType, + metadata.ChargeAmount, + metadata.GiftID, + metadata.GiftCount, + metadata.SenderUserID, + metadata.RoomID, + nowMs, + ) + return err +} + type walletOutboxEvent struct { AppCode string EventID string @@ -1390,34 +1554,106 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli() } + appCode := appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } - rows, err := tx.QueryContext(ctx, ` + defer func() { _ = tx.Rollback() }() + + records := make([]WalletOutboxRecord, 0, limit) + claimBranches := []struct { + query string + args []any + }{ + { + query: ` SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0) - FROM wallet_outbox - WHERE status IN (?, ?) - AND (next_retry_at_ms IS NULL OR next_retry_at_ms <= ?) - AND (lock_until_ms IS NULL OR lock_until_ms <= ?) + FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending) + WHERE app_code = ? AND status = ? + AND next_retry_at_ms IS NULL + AND lock_until_ms IS NULL ORDER BY created_at_ms ASC, event_id ASC LIMIT ? - FOR UPDATE`, - outboxStatusPending, - outboxStatusRetryable, - nowMS, - nowMS, - limit, - ) - if err != nil { - _ = tx.Rollback() - return nil, err + FOR UPDATE SKIP LOCKED`, + args: []any{appCode, outboxStatusPending}, + }, + { + query: ` + SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, + retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0) + FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending) + 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, outboxStatusRetryable, nowMS}, + }, + { + query: ` + SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, + retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0) + FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_claim) + 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, outboxStatusDelivering, nowMS}, + }, + } + for _, branch := range claimBranches { + remaining := limit - len(records) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchRecords, err := queryWalletOutboxRecords(ctx, tx, branch.query, args...) + if err != nil { + return nil, err + } + records = append(records, branchRecords...) } - records := make([]WalletOutboxRecord, 0, limit) - eventIDs := make([]string, 0, limit) + for _, record := range records { + if _, err := tx.ExecContext(ctx, ` + UPDATE wallet_outbox + SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ?`, + outboxStatusDelivering, + workerID, + lockUntilMS, + nowMS, + record.AppCode, + record.EventID, + ); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + for index := range records { + records[index].WorkerID = workerID + records[index].LockUntilMS = lockUntilMS + } + return records, nil +} + +func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + records := make([]WalletOutboxRecord, 0) for rows.Next() { var record WalletOutboxRecord if err := rows.Scan( @@ -1437,45 +1673,13 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri &record.WorkerID, &record.LockUntilMS, ); err != nil { - _ = rows.Close() - _ = tx.Rollback() return nil, err } records = append(records, record) - eventIDs = append(eventIDs, record.EventID) - } - if err := rows.Close(); err != nil { - _ = tx.Rollback() - return nil, err } if err := rows.Err(); err != nil { - _ = tx.Rollback() return nil, err } - - for index, eventID := range eventIDs { - if _, err := tx.ExecContext(ctx, ` - UPDATE wallet_outbox - SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ? - WHERE app_code = ? AND event_id = ?`, - outboxStatusDelivering, - workerID, - lockUntilMS, - nowMS, - records[index].AppCode, - eventID, - ); err != nil { - _ = tx.Rollback() - return nil, err - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - for index := range records { - records[index].WorkerID = workerID - records[index].LockUntilMS = lockUntilMS - } return records, nil } @@ -1575,29 +1779,36 @@ func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, tr } type giftMetadata struct { - AppCode string `json:"app_code"` - GiftID string `json:"gift_id"` - GiftName string `json:"gift_name"` - ResourceID int64 `json:"resource_id"` - ResourceSnapshot string `json:"resource_snapshot_json"` - GiftTypeCode string `json:"gift_type_code"` - PresentationJSON string `json:"presentation_json"` - SortOrder int32 `json:"sort_order"` - GiftCount int32 `json:"gift_count"` - PriceVersion string `json:"price_version"` - CoinPrice int64 `json:"coin_price"` - GiftPointAmount int64 `json:"gift_point_amount"` - HeatUnitValue int64 `json:"heat_unit_value"` - ChargeAssetType string `json:"charge_asset_type"` - ChargeAmount int64 `json:"charge_amount"` - CoinSpent int64 `json:"coin_spent"` - GiftPointAdded int64 `json:"gift_point_added"` - HeatValue int64 `json:"heat_value"` - BalanceAfter int64 `json:"balance_after"` - BillingReceipt string `json:"billing_receipt_id"` - SenderUserID int64 `json:"sender_user_id"` - TargetUserID int64 `json:"target_user_id"` - RoomID string `json:"room_id"` + AppCode string `json:"app_code"` + GiftID string `json:"gift_id"` + GiftName string `json:"gift_name"` + ResourceID int64 `json:"resource_id"` + ResourceSnapshot string `json:"resource_snapshot_json"` + GiftTypeCode string `json:"gift_type_code"` + PresentationJSON string `json:"presentation_json"` + SortOrder int32 `json:"sort_order"` + GiftCount int32 `json:"gift_count"` + PriceVersion string `json:"price_version"` + CoinPrice int64 `json:"coin_price"` + GiftPointAmount int64 `json:"gift_point_amount"` + HeatUnitValue int64 `json:"heat_unit_value"` + ChargeAssetType string `json:"charge_asset_type"` + ChargeAmount int64 `json:"charge_amount"` + CoinSpent int64 `json:"coin_spent"` + GiftPointAdded int64 `json:"gift_point_added"` + HeatValue int64 `json:"heat_value"` + BalanceAfter int64 `json:"balance_after"` + BillingReceipt string `json:"billing_receipt_id"` + SenderUserID int64 `json:"sender_user_id"` + TargetUserID int64 `json:"target_user_id"` + TargetIsHost bool `json:"target_is_host"` + TargetHostRegionID int64 `json:"target_host_region_id"` + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` + HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` + HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` + HostPeriodCycleKey string `json:"host_period_cycle_key"` + RoomID string `json:"room_id"` } type adminCreditMetadata struct { @@ -1624,17 +1835,18 @@ type taskRewardMetadata struct { } type luckyGiftRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - DrawID string `json:"draw_id"` - RoomID string `json:"room_id"` - GiftID string `json:"gift_id"` - PoolID string `json:"pool_id"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + DrawID string `json:"draw_id"` + RoomID string `json:"room_id"` + VisibleRegionID int64 `json:"visible_region_id"` + GiftID string `json:"gift_id"` + PoolID string `json:"pool_id"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` } type gameCoinMetadata struct { @@ -1698,16 +1910,18 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger chargeAmount = metadata.CoinSpent } return ledger.Receipt{ - BillingReceiptID: metadata.BillingReceipt, - TransactionID: transactionID, - CoinSpent: metadata.CoinSpent, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - GiftPointAdded: metadata.GiftPointAdded, - HeatValue: metadata.HeatValue, - GiftTypeCode: metadata.GiftTypeCode, - PriceVersion: metadata.PriceVersion, - BalanceAfter: metadata.BalanceAfter, + BillingReceiptID: metadata.BillingReceipt, + TransactionID: transactionID, + CoinSpent: metadata.CoinSpent, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + GiftPointAdded: metadata.GiftPointAdded, + HeatValue: metadata.HeatValue, + GiftTypeCode: metadata.GiftTypeCode, + PriceVersion: metadata.PriceVersion, + BalanceAfter: metadata.BalanceAfter, + HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded, + HostPeriodCycleKey: metadata.HostPeriodCycleKey, } } @@ -1968,16 +2182,18 @@ func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metada AvailableDelta: metadata.Amount, FrozenDelta: 0, Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "draw_id": metadata.DrawID, - "room_id": metadata.RoomID, - "gift_id": metadata.GiftID, - "pool_id": metadata.PoolID, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "draw_id": metadata.DrawID, + "room_id": metadata.RoomID, + "visible_region_id": metadata.VisibleRegionID, + "region_id": metadata.VisibleRegionID, + "gift_id": metadata.GiftID, + "pool_id": metadata.PoolID, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, }, CreatedAtMS: nowMs, } @@ -2028,6 +2244,38 @@ func giftDebitedEvent(transactionID string, commandID string, userID int64, asse } } +func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond), + EventType: "HostPeriodDiamondCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetHostPeriodDiamond, + AvailableDelta: metadata.HostPeriodDiamondAdded, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "cycle_key": metadata.HostPeriodCycleKey, + "region_id": metadata.TargetHostRegionID, + "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, + "diamond_delta": metadata.HostPeriodDiamondAdded, + "diamond_after": metadata.HostPeriodDiamondAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "sender_user_id": metadata.SenderUserID, + "room_id": metadata.RoomID, + "conversion_ratio_snapshot": "1", + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + func normalizeAssetTypes(assetTypes []string) []string { seen := make(map[string]bool, len(assetTypes)) result := make([]string, 0, len(assetTypes)) @@ -2075,9 +2323,14 @@ func checkedAdd(left int64, right int64) (int64, error) { return left + right, nil } +func hostPeriodCycleKeyFromMS(ms int64) string { + // 工资周期用 UTC 月份锚定,避免服务节点本地时区差异导致月底礼物落到不同周期。 + return time.UnixMilli(ms).UTC().Format("2006-01") +} + func debitRequestHash(command ledger.DebitGiftCommand) string { // 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。 - return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s", + return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%t|%d|%d", appcode.Normalize(command.AppCode), command.RoomID, command.SenderUserID, @@ -2085,6 +2338,10 @@ func debitRequestHash(command ledger.DebitGiftCommand) string { command.GiftID, command.GiftCount, strings.TrimSpace(command.PriceVersion), + command.RegionID, + command.TargetIsHost, + command.TargetHostRegionID, + command.TargetAgencyOwnerUserID, )) } diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index ca15047b..8f4cecb8 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -3,11 +3,13 @@ package mysqltest import ( "context" + "database/sql" "fmt" "testing" "time" "hyapp/internal/testutil/mysqlschema" + "hyapp/services/wallet-service/internal/domain/ledger" mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" ) @@ -34,6 +36,112 @@ func (r *Repository) CountRows(table string, where string, args ...any) int { return count } +// HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order. +func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string { + r.t.Helper() + + rows, err := r.schema.DB.QueryContext(context.Background(), ` + SELECT settlement_id + FROM host_salary_settlement_records + WHERE user_id = ? AND cycle_key = ? + ORDER BY created_at_ms ASC, settlement_id ASC + `, userID, cycleKey) + if err != nil { + r.t.Fatalf("query host salary settlement ids failed: %v", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + r.t.Fatalf("scan host salary settlement id failed: %v", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + r.t.Fatalf("iterate host salary settlement ids failed: %v", err) + } + return ids +} + +// MoveHostPeriodCycle merges a real gift-created current-cycle account into another cycle for month-boundary tests. +func (r *Repository) MoveHostPeriodCycle(userID int64, fromCycle string, toCycle string) { + r.t.Helper() + if fromCycle == "" || toCycle == "" || fromCycle == toCycle { + return + } + + tx, err := r.schema.DB.BeginTx(context.Background(), nil) + if err != nil { + r.t.Fatalf("begin move host period cycle failed: %v", err) + } + defer func() { _ = tx.Rollback() }() + + var sourceTotal int64 + var sourceGiftTotal int64 + var sourceLastGiftAt int64 + var sourceUpdatedAt int64 + err = tx.QueryRowContext(context.Background(), ` + SELECT total_diamonds, gift_diamond_total, last_gift_at_ms, updated_at_ms + FROM host_period_diamond_accounts + WHERE user_id = ? AND cycle_key = ? + FOR UPDATE + `, userID, fromCycle).Scan(&sourceTotal, &sourceGiftTotal, &sourceLastGiftAt, &sourceUpdatedAt) + if err != nil { + r.t.Fatalf("read source host period cycle failed: %v", err) + } + + var targetTotal int64 + err = tx.QueryRowContext(context.Background(), ` + SELECT total_diamonds + FROM host_period_diamond_accounts + WHERE user_id = ? AND cycle_key = ? + FOR UPDATE + `, userID, toCycle).Scan(&targetTotal) + if err != nil { + if err == sql.ErrNoRows { + if _, err := tx.ExecContext(context.Background(), ` + UPDATE host_period_diamond_accounts + SET cycle_key = ? + WHERE user_id = ? AND cycle_key = ? + `, toCycle, userID, fromCycle); err != nil { + r.t.Fatalf("move first host period account failed: %v", err) + } + } else { + r.t.Fatalf("read target host period cycle failed: %v", err) + } + } else { + if _, err := tx.ExecContext(context.Background(), ` + UPDATE host_period_diamond_accounts + SET total_diamonds = total_diamonds + ?, + gift_diamond_total = gift_diamond_total + ?, + version = version + 1, + last_gift_at_ms = ?, + updated_at_ms = ? + WHERE user_id = ? AND cycle_key = ? + `, sourceTotal, sourceGiftTotal, sourceLastGiftAt, sourceUpdatedAt, userID, toCycle); err != nil { + r.t.Fatalf("merge target host period account failed: %v", err) + } + if _, err := tx.ExecContext(context.Background(), ` + DELETE FROM host_period_diamond_accounts + WHERE user_id = ? AND cycle_key = ? + `, userID, fromCycle); err != nil { + r.t.Fatalf("delete merged source host period account failed: %v", err) + } + } + if _, err := tx.ExecContext(context.Background(), ` + UPDATE host_period_diamond_entries + SET cycle_key = ? + WHERE user_id = ? AND cycle_key = ? + `, toCycle, userID, fromCycle); err != nil { + r.t.Fatalf("move host period entries failed: %v", err) + } + if err := tx.Commit(); err != nil { + r.t.Fatalf("commit move host period cycle failed: %v", err) + } +} + // InsertRawGiftPrice allows tests to seed inactive or future prices without service helpers. func (r *Repository) InsertRawGiftPrice(giftID string, priceVersion string, status string, effectiveAtMs int64) { r.t.Helper() @@ -107,7 +215,52 @@ func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice r.t.Helper() nowMs := time.Now().UnixMilli() - _, err := r.schema.DB.ExecContext(context.Background(), ` + // DebitGift 真实链路先校验 active gift_config,再读取 wallet_gift_prices;测试 seed 必须同时补齐资源、礼物配置和区域关系。 + result, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO resources ( + resource_code, resource_type, name, status, grantable, manager_grant_enabled, + grant_strategy, price_type, coin_price, gift_point_amount, usage_scope_json, + created_at_ms, updated_at_ms + ) VALUES (?, 'gift', ?, 'active', TRUE, TRUE, 'new_entitlement', 'coin', ?, ?, '[]', ?, ?) + ON DUPLICATE KEY UPDATE + resource_id = LAST_INSERT_ID(resource_id), + status = VALUES(status), + coin_price = VALUES(coin_price), + gift_point_amount = VALUES(gift_point_amount), + updated_at_ms = VALUES(updated_at_ms) + `, "test-gift-"+giftID, giftID, coinPrice, giftPointAmount, nowMs, nowMs) + if err != nil { + r.t.Fatalf("seed gift resource failed: %v", err) + } + resourceID, err := result.LastInsertId() + if err != nil { + r.t.Fatalf("read gift resource id failed: %v", err) + } + if _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO gift_configs ( + gift_id, resource_id, status, name, sort_order, presentation_json, + gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'active', ?, 0, JSON_OBJECT(), 'normal', 0, 0, JSON_ARRAY(), 0, 0, ?, ?) + ON DUPLICATE KEY UPDATE + resource_id = VALUES(resource_id), + status = VALUES(status), + name = VALUES(name), + gift_type_code = VALUES(gift_type_code), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms) + `, giftID, resourceID, giftID, nowMs, nowMs); err != nil { + r.t.Fatalf("seed gift config failed: %v", err) + } + if _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO gift_config_regions (gift_id, region_id, created_at_ms, updated_at_ms) + VALUES (?, 0, ?, ?) + ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) + `, giftID, nowMs, nowMs); err != nil { + r.t.Fatalf("seed gift config region failed: %v", err) + } + _, err = r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_gift_prices ( gift_id, price_version, status, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms @@ -165,6 +318,70 @@ func (r *Repository) SetRechargeStats(userID int64, totalCoinAmount int64) { } } +// SetHostSalaryPolicy seeds the wallet runtime policy snapshot used by salary settlement tests. +func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) { + r.t.Helper() + + nowMs := time.Now().UnixMilli() + if policy.PolicyID == 0 { + policy.PolicyID = 1 + } + if policy.Status == "" { + policy.Status = "active" + } + if policy.SettlementMode == "" { + policy.SettlementMode = ledger.HostSalarySettlementModeDaily + } + if policy.SettlementTriggerMode == "" { + // 测试默认覆盖生产 cron 路径;需要验证人工政策时再显式传 manual。 + policy.SettlementTriggerMode = ledger.HostSalarySettlementTriggerAutomatic + } + if policy.GiftCoinToDiamondRatio == "" { + policy.GiftCoinToDiamondRatio = "1" + } + if policy.ResidualDiamondToUSDRate == "" { + policy.ResidualDiamondToUSDRate = "0" + } + _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO host_agency_salary_policies ( + policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, gift_coin_to_diamond_ratio, + residual_diamond_to_usd_rate, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + region_id = VALUES(region_id), + status = VALUES(status), + settlement_mode = VALUES(settlement_mode), + settlement_trigger_mode = VALUES(settlement_trigger_mode), + gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio), + residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms) + `, policy.PolicyID, policy.Name, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, policy.ResidualDiamondToUSDRate, policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs) + if err != nil { + r.t.Fatalf("seed host salary policy failed: %v", err) + } + if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE policy_id = ?`, policy.PolicyID); err != nil { + r.t.Fatalf("clear host salary policy levels failed: %v", err) + } + for _, level := range policy.Levels { + if level.Status == "" { + level.Status = "active" + } + if _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO host_agency_salary_policy_levels ( + policy_id, level_no, required_diamonds, host_salary_usd_minor, + host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor, + level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs, + ); err != nil { + r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err) + } + } +} + func initDBPath(t testing.TB) string { t.Helper() @@ -177,6 +394,8 @@ func initDBPath(t testing.TB) string { func validateTableName(table string) string { switch table { case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", + "host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies", + "host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records", "coin_seller_stock_records", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests", "vip_levels", "user_vip_memberships", "vip_purchase_orders", "user_vip_history", "resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events", diff --git a/services/wallet-service/internal/transport/grpc/cron_server.go b/services/wallet-service/internal/transport/grpc/cron_server.go new file mode 100644 index 00000000..6137fda8 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/cron_server.go @@ -0,0 +1,61 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + walletservice "hyapp/services/wallet-service/internal/service/wallet" +) + +// CronServer exposes wallet-service owned settlement batches to cron-service. +type CronServer struct { + walletv1.UnimplementedWalletCronServiceServer + + svc *walletservice.Service +} + +// NewCronServer 创建只给 cron-service 调用的批处理适配器;真实账务事务仍在 wallet repository 内完成。 +func NewCronServer(svc *walletservice.Service) *CronServer { + return &CronServer{svc: svc} +} + +func (s *CronServer) ProcessHostSalaryDailySettlementBatch(ctx context.Context, req *walletv1.CronBatchRequest) (*walletv1.CronBatchResponse, error) { + return s.processHostSalarySettlementBatch(ctx, req, ledger.HostSalarySettlementModeDaily) +} + +func (s *CronServer) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req *walletv1.CronBatchRequest) (*walletv1.CronBatchResponse, error) { + return s.processHostSalarySettlementBatch(ctx, req, ledger.HostSalarySettlementModeHalfMonth) +} + +func (s *CronServer) ProcessHostSalaryMonthEndBatch(ctx context.Context, req *walletv1.CronBatchRequest) (*walletv1.CronBatchResponse, error) { + return s.processHostSalarySettlementBatch(ctx, req, ledger.HostSalarySettlementTypeMonthEnd) +} + +func (s *CronServer) processHostSalarySettlementBatch(ctx context.Context, req *walletv1.CronBatchRequest, settlementType string) (*walletv1.CronBatchResponse, error) { + if s.svc == nil { + return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "wallet service is not configured")) + } + appCode := appcode.Normalize(req.GetAppCode()) + ctx = appcode.WithContext(ctx, appCode) + // cron-service 只决定触发哪类批次;周期、当前时间、幂等交易和余额入账全部由 wallet-service 统一裁决。 + result, err := s.svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: appCode, + RunID: req.GetRunId(), + WorkerID: req.GetWorkerId(), + BatchSize: int(req.GetBatchSize()), + SettlementType: settlementType, + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.CronBatchResponse{ + ClaimedCount: int32(result.ClaimedCount), + ProcessedCount: int32(result.ProcessedCount), + SuccessCount: int32(result.SuccessCount), + FailureCount: int32(result.FailureCount), + HasMore: result.HasMore, + }, nil +} diff --git a/services/wallet-service/internal/transport/grpc/server.go b/services/wallet-service/internal/transport/grpc/server.go index 5b382c89..3c5dfc32 100644 --- a/services/wallet-service/internal/transport/grpc/server.go +++ b/services/wallet-service/internal/transport/grpc/server.go @@ -36,22 +36,28 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) GiftCount: req.GetGiftCount(), PriceVersion: req.GetPriceVersion(), RegionID: req.GetRegionId(), + TargetIsHost: req.GetTargetIsHost(), + // 工资政策区域来自 user-service host profile,不能用房间 visible_region_id 兜底。 + TargetHostRegionID: req.GetTargetHostRegionId(), + TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &walletv1.DebitGiftResponse{ - BillingReceiptId: receipt.BillingReceiptID, - BalanceAfter: receipt.BalanceAfter, - TransactionId: receipt.TransactionID, - CoinSpent: receipt.CoinSpent, - ChargeAssetType: receipt.ChargeAssetType, - ChargeAmount: receipt.ChargeAmount, - GiftPointAdded: receipt.GiftPointAdded, - HeatValue: receipt.HeatValue, - GiftTypeCode: receipt.GiftTypeCode, - PriceVersion: receipt.PriceVersion, + BillingReceiptId: receipt.BillingReceiptID, + BalanceAfter: receipt.BalanceAfter, + TransactionId: receipt.TransactionID, + CoinSpent: receipt.CoinSpent, + ChargeAssetType: receipt.ChargeAssetType, + ChargeAmount: receipt.ChargeAmount, + GiftPointAdded: receipt.GiftPointAdded, + HeatValue: receipt.HeatValue, + GiftTypeCode: receipt.GiftTypeCode, + PriceVersion: receipt.PriceVersion, + HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded, + HostPeriodCycleKey: receipt.HostPeriodCycleKey, }, nil } @@ -655,15 +661,16 @@ func (s *Server) CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskR func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest) (*walletv1.CreditLuckyGiftRewardResponse, error) { ctx = appcode.WithContext(ctx, req.GetAppCode()) receipt, err := s.svc.CreditLuckyGiftReward(ctx, ledger.LuckyGiftRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - DrawID: req.GetDrawId(), - RoomID: req.GetRoomId(), - GiftID: req.GetGiftId(), - PoolID: req.GetPoolId(), - Reason: req.GetReason(), + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + DrawID: req.GetDrawId(), + RoomID: req.GetRoomId(), + VisibleRegionID: req.GetVisibleRegionId(), + GiftID: req.GetGiftId(), + PoolID: req.GetPoolId(), + Reason: req.GetReason(), }) if err != nil { return nil, xerr.ToGRPCError(err)