Merge branch 'main' into test

This commit is contained in:
zhx 2026-06-01 15:40:28 +08:00
commit fd215eadb6
139 changed files with 12278 additions and 1177 deletions

View File

@ -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

View File

@ -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" +

View File

@ -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

View File

@ -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" +

View File

@ -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

View File

@ -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,

View File

@ -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);

View File

@ -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"

View File

@ -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 {

View File

@ -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`

View File

@ -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;

View File

@ -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)

View File

@ -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"

View File

@ -18,7 +18,7 @@ migrations:
# 本地开发阶段允许历史 migration 文件被修改后修复 checksumstaging/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"

View File

@ -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",

View File

@ -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 {

View File

@ -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"`

View File

@ -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,

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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 ""
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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 ""
}

View File

@ -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"`
}

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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 ""
}

View File

@ -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)
}

File diff suppressed because it is too large Load Diff

View File

@ -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, "`", "``") + "`"
}

View File

@ -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

View File

@ -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
}

View File

@ -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{},

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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 (

View File

@ -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,

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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}

View File

@ -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

View File

@ -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()
}
})
}

View File

@ -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",

View File

@ -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)
}
}
}

View File

@ -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 同时作为请求幂等标识传给 walletwallet 内部会继续按主播和周期生成账务幂等键。
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(),
}
}

View File

@ -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)
}
})
}
}

View File

@ -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

View File

@ -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 表';

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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"}},

View File

@ -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,

View File

@ -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) {

View File

@ -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{
{

View File

@ -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,
}

View File

@ -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 {

View File

@ -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,

View File

@ -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(),

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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='通知服务外部通知投递位点和死信表';

View File

@ -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,

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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),
))

View File

@ -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, `

View File

@ -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,

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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 (

View File

@ -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")

View File

@ -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

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