Compare commits
No commits in common. "fd215eadb67b921a9b2fd90f26dc649da5625970" and "335e9718bb654146ac1c67da98232d2466c3f3eb" have entirely different histories.
fd215eadb6
...
335e9718bb
2
Makefile
2
Makefile
@ -43,7 +43,7 @@ build:
|
|||||||
go build ./...
|
go build ./...
|
||||||
go build ./server/admin/...
|
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` 只跑子集。
|
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
|
||||||
run:
|
run:
|
||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
|
|||||||
@ -6510,12 +6510,6 @@ type SendGiftRequest struct {
|
|||||||
TargetType string `protobuf:"bytes,5,opt,name=target_type,json=targetType,proto3" json:"target_type,omitempty"`
|
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"`
|
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"`
|
PoolId string `protobuf:"bytes,7,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||||
// 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -6599,27 +6593,6 @@ func (x *SendGiftRequest) GetPoolId() string {
|
|||||||
return ""
|
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 返回扣费成功并落到房间后的状态结果。
|
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||||
type SendGiftResponse struct {
|
type SendGiftResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -9372,7 +9345,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" +
|
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" +
|
"rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" +
|
||||||
"\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x98\x03\n" +
|
"\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x81\x02\n" +
|
||||||
"\x0fSendGiftRequest\x12.\n" +
|
"\x0fSendGiftRequest\x12.\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
||||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
||||||
@ -9382,11 +9355,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"\vtarget_type\x18\x05 \x01(\tR\n" +
|
"\vtarget_type\x18\x05 \x01(\tR\n" +
|
||||||
"targetType\x12&\n" +
|
"targetType\x12&\n" +
|
||||||
"\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\x12\x17\n" +
|
"\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\x12\x17\n" +
|
||||||
"\apool_id\x18\a \x01(\tR\x06poolId\x12$\n" +
|
"\apool_id\x18\a \x01(\tR\x06poolId\"\xfb\x02\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" +
|
"\x10SendGiftResponse\x124\n" +
|
||||||
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\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" +
|
"\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" +
|
||||||
|
|||||||
@ -776,12 +776,6 @@ message SendGiftRequest {
|
|||||||
string target_type = 5;
|
string target_type = 5;
|
||||||
repeated int64 target_user_ids = 6;
|
repeated int64 target_user_ids = 6;
|
||||||
string pool_id = 7;
|
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 返回扣费成功并落到房间后的状态结果。
|
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||||
|
|||||||
@ -33,8 +33,6 @@ 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"`
|
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"`
|
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"`
|
UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||||
// 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -132,13 +130,6 @@ func (x *HostProfile) GetUpdatedAtMs() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *HostProfile) GetCurrentAgencyOwnerUserId() int64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.CurrentAgencyOwnerUserId
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。
|
// Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。
|
||||||
type Agency struct {
|
type Agency struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -153,8 +144,6 @@ type Agency struct {
|
|||||||
CreatedByUserId int64 `protobuf:"varint,9,opt,name=created_by_user_id,json=createdByUserId,proto3" json:"created_by_user_id,omitempty"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -266,20 +255,6 @@ func (x *Agency) GetUpdatedAtMs() int64 {
|
|||||||
return 0
|
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。
|
// BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。
|
||||||
type BDProfile struct {
|
type BDProfile struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -3928,7 +3903,7 @@ var File_proto_user_v1_host_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
const file_proto_user_v1_host_proto_rawDesc = "" +
|
const file_proto_user_v1_host_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\x91\x03\n" +
|
"\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\xd1\x02\n" +
|
||||||
"\vHostProfile\x12\x17\n" +
|
"\vHostProfile\x12\x17\n" +
|
||||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x16\n" +
|
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x16\n" +
|
||||||
"\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" +
|
"\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" +
|
||||||
@ -3938,9 +3913,7 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
|
|||||||
"\x06source\x18\x06 \x01(\tR\x06source\x124\n" +
|
"\x06source\x18\x06 \x01(\tR\x06source\x124\n" +
|
||||||
"\x17first_became_host_at_ms\x18\a \x01(\x03R\x13firstBecameHostAtMs\x12\"\n" +
|
"\x17first_became_host_at_ms\x18\a \x01(\x03R\x13firstBecameHostAtMs\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
"\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||||
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12>\n" +
|
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xf2\x02\n" +
|
||||||
"\x1ccurrent_agency_owner_user_id\x18\n" +
|
|
||||||
" \x01(\x03R\x18currentAgencyOwnerUserId\"\xa9\x03\n" +
|
|
||||||
"\x06Agency\x12\x1b\n" +
|
"\x06Agency\x12\x1b\n" +
|
||||||
"\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" +
|
"\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" +
|
||||||
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x1b\n" +
|
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x1b\n" +
|
||||||
@ -3953,10 +3926,7 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
|
|||||||
"\x12created_by_user_id\x18\t \x01(\x03R\x0fcreatedByUserId\x12\"\n" +
|
"\x12created_by_user_id\x18\t \x01(\x03R\x0fcreatedByUserId\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18\n" +
|
"\rcreated_at_ms\x18\n" +
|
||||||
" \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
" \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||||
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\x12\x16\n" +
|
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\x95\x02\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" +
|
"\tBDProfile\x12\x17\n" +
|
||||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" +
|
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" +
|
||||||
"\x04role\x18\x02 \x01(\tR\x04role\x12\x1b\n" +
|
"\x04role\x18\x02 \x01(\tR\x04role\x12\x1b\n" +
|
||||||
|
|||||||
@ -17,8 +17,6 @@ message HostProfile {
|
|||||||
int64 first_became_host_at_ms = 7;
|
int64 first_became_host_at_ms = 7;
|
||||||
int64 created_at_ms = 8;
|
int64 created_at_ms = 8;
|
||||||
int64 updated_at_ms = 9;
|
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。
|
// Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。
|
||||||
@ -34,8 +32,6 @@ message Agency {
|
|||||||
int64 created_by_user_id = 9;
|
int64 created_by_user_id = 9;
|
||||||
int64 created_at_ms = 10;
|
int64 created_at_ms = 10;
|
||||||
int64 updated_at_ms = 11;
|
int64 updated_at_ms = 11;
|
||||||
string avatar = 12;
|
|
||||||
int32 host_count = 13;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。
|
// BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。
|
||||||
|
|||||||
@ -35,12 +35,6 @@ type DebitGiftRequest struct {
|
|||||||
AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||||
// region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。
|
// region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。
|
||||||
RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -138,27 +132,6 @@ func (x *DebitGiftRequest) GetRegionId() int64 {
|
|||||||
return 0
|
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 账后余额。
|
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||||
type DebitGiftResponse struct {
|
type DebitGiftResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -173,10 +146,6 @@ type DebitGiftResponse struct {
|
|||||||
ChargeAssetType string `protobuf:"bytes,8,opt,name=charge_asset_type,json=chargeAssetType,proto3" json:"charge_asset_type,omitempty"`
|
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"`
|
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"`
|
GiftTypeCode string `protobuf:"bytes,10,opt,name=gift_type_code,json=giftTypeCode,proto3" json:"gift_type_code,omitempty"`
|
||||||
// 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -281,20 +250,6 @@ func (x *DebitGiftResponse) GetGiftTypeCode() string {
|
|||||||
return ""
|
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 是用户某类资产的余额投影。
|
// AssetBalance 是用户某类资产的余额投影。
|
||||||
type AssetBalance struct {
|
type AssetBalance struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -10349,7 +10304,6 @@ type CreditLuckyGiftRewardRequest struct {
|
|||||||
GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -10447,13 +10401,6 @@ func (x *CreditLuckyGiftRewardRequest) GetReason() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CreditLuckyGiftRewardRequest) GetVisibleRegionId() int64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.VisibleRegionId
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。
|
// CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。
|
||||||
type CreditLuckyGiftRewardResponse struct {
|
type CreditLuckyGiftRewardResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -12329,173 +12276,11 @@ func (x *RetryRedPacketRefundResponse) GetServerTimeMs() int64 {
|
|||||||
return 0
|
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
|
var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xc2\x03\n" +
|
"\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xab\x02\n" +
|
||||||
"\x10DebitGiftRequest\x12\x1d\n" +
|
"\x10DebitGiftRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
|
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
|
||||||
@ -12507,11 +12292,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" +
|
"gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" +
|
||||||
"\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" +
|
"\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" +
|
||||||
"\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" +
|
"\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" +
|
||||||
"\tregion_id\x18\t \x01(\x03R\bregionId\x12$\n" +
|
"\tregion_id\x18\t \x01(\x03R\bregionId\"\x91\x03\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" +
|
"\x11DebitGiftResponse\x12,\n" +
|
||||||
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
||||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
||||||
@ -12525,9 +12306,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\x12#\n" +
|
"\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\x12#\n" +
|
||||||
"\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\x12$\n" +
|
"\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\x12$\n" +
|
||||||
"\x0egift_type_code\x18\n" +
|
"\x0egift_type_code\x18\n" +
|
||||||
" \x01(\tR\fgiftTypeCode\x129\n" +
|
" \x01(\tR\fgiftTypeCode\"\xb2\x01\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" +
|
"\fAssetBalance\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" +
|
"asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" +
|
||||||
@ -13574,7 +13353,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
||||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" +
|
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" +
|
||||||
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12\"\n" +
|
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12\"\n" +
|
||||||
"\rgranted_at_ms\x18\x04 \x01(\x03R\vgrantedAtMs\"\xbe\x02\n" +
|
"\rgranted_at_ms\x18\x04 \x01(\x03R\vgrantedAtMs\"\x92\x02\n" +
|
||||||
"\x1cCreditLuckyGiftRewardRequest\x12\x1d\n" +
|
"\x1cCreditLuckyGiftRewardRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" +
|
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" +
|
||||||
@ -13585,9 +13364,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\aroom_id\x18\x06 \x01(\tR\x06roomId\x12\x17\n" +
|
"\aroom_id\x18\x06 \x01(\tR\x06roomId\x12\x17\n" +
|
||||||
"\agift_id\x18\a \x01(\tR\x06giftId\x12\x17\n" +
|
"\agift_id\x18\a \x01(\tR\x06giftId\x12\x17\n" +
|
||||||
"\apool_id\x18\b \x01(\tR\x06poolId\x12\x16\n" +
|
"\apool_id\x18\b \x01(\tR\x06poolId\x12\x16\n" +
|
||||||
"\x06reason\x18\t \x01(\tR\x06reason\x12*\n" +
|
"\x06reason\x18\t \x01(\tR\x06reason\"\xbb\x01\n" +
|
||||||
"\x11visible_region_id\x18\n" +
|
|
||||||
" \x01(\x03R\x0fvisibleRegionId\"\xbb\x01\n" +
|
|
||||||
"\x1dCreditLuckyGiftRewardResponse\x12%\n" +
|
"\x1dCreditLuckyGiftRewardResponse\x12%\n" +
|
||||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
||||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" +
|
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" +
|
||||||
@ -13772,26 +13549,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\x1cRetryRedPacketRefundResponse\x122\n" +
|
"\x1cRetryRedPacketRefundResponse\x122\n" +
|
||||||
"\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12'\n" +
|
"\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12'\n" +
|
||||||
"\x0frefunded_amount\x18\x02 \x01(\x03R\x0erefundedAmount\x12$\n" +
|
"\x0frefunded_amount\x18\x02 \x01(\x03R\x0erefundedAmount\x12$\n" +
|
||||||
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xbf\x01\n" +
|
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs2\xe52\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" +
|
"\rWalletService\x12R\n" +
|
||||||
"\tDebitGift\x12!.hyapp.wallet.v1.DebitGiftRequest\x1a\".hyapp.wallet.v1.DebitGiftResponse\x12X\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" +
|
"\vGetBalances\x12#.hyapp.wallet.v1.GetBalancesRequest\x1a$.hyapp.wallet.v1.GetBalancesResponse\x12g\n" +
|
||||||
@ -13866,7 +13624,7 @@ func file_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte {
|
|||||||
return file_proto_wallet_v1_wallet_proto_rawDescData
|
return file_proto_wallet_v1_wallet_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 141)
|
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 139)
|
||||||
var file_proto_wallet_v1_wallet_proto_goTypes = []any{
|
var file_proto_wallet_v1_wallet_proto_goTypes = []any{
|
||||||
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
|
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
|
||||||
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
|
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
|
||||||
@ -14007,8 +13765,6 @@ var file_proto_wallet_v1_wallet_proto_goTypes = []any{
|
|||||||
(*ExpireRedPacketsResponse)(nil), // 136: hyapp.wallet.v1.ExpireRedPacketsResponse
|
(*ExpireRedPacketsResponse)(nil), // 136: hyapp.wallet.v1.ExpireRedPacketsResponse
|
||||||
(*RetryRedPacketRefundRequest)(nil), // 137: hyapp.wallet.v1.RetryRedPacketRefundRequest
|
(*RetryRedPacketRefundRequest)(nil), // 137: hyapp.wallet.v1.RetryRedPacketRefundRequest
|
||||||
(*RetryRedPacketRefundResponse)(nil), // 138: hyapp.wallet.v1.RetryRedPacketRefundResponse
|
(*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{
|
var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
|
||||||
2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance
|
2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
@ -14078,134 +13834,128 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
|
|||||||
122, // 64: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket
|
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, // 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
|
122, // 66: hyapp.wallet.v1.RetryRedPacketRefundResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
||||||
139, // 67: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
0, // 67: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
|
||||||
139, // 68: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
3, // 68: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
|
||||||
139, // 69: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
5, // 69: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
|
||||||
0, // 70: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
|
7, // 70: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
|
||||||
3, // 71: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
|
9, // 71: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
|
||||||
5, // 72: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
|
21, // 72: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
|
||||||
7, // 73: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
|
23, // 73: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
|
||||||
9, // 74: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
|
25, // 74: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
|
||||||
21, // 75: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
|
26, // 75: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
|
||||||
23, // 76: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
|
27, // 76: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
|
||||||
25, // 77: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
|
29, // 77: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
|
||||||
26, // 78: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
|
31, // 78: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
|
||||||
27, // 79: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
|
33, // 79: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
|
||||||
29, // 80: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
|
34, // 80: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
|
||||||
31, // 81: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
|
35, // 81: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
|
||||||
33, // 82: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
|
37, // 82: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
|
||||||
34, // 83: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
|
39, // 83: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
|
||||||
35, // 84: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
|
43, // 84: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
|
||||||
37, // 85: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
|
44, // 85: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
|
||||||
39, // 86: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
|
45, // 86: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
|
||||||
43, // 87: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
|
41, // 87: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
|
||||||
44, // 88: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
|
47, // 88: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
|
||||||
45, // 89: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
|
48, // 89: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
|
||||||
41, // 90: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
|
50, // 90: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
|
||||||
47, // 91: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
|
52, // 91: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
|
||||||
48, // 92: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
|
54, // 92: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
|
||||||
50, // 93: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
|
57, // 93: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
|
||||||
52, // 94: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
|
59, // 94: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
|
||||||
54, // 95: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
|
61, // 95: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
|
||||||
57, // 96: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
|
63, // 96: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
|
||||||
59, // 97: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
|
66, // 97: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
|
||||||
61, // 98: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
|
69, // 98: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
|
||||||
63, // 99: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
|
72, // 99: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
|
||||||
66, // 100: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
|
75, // 100: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
|
||||||
69, // 101: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
|
78, // 101: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
|
||||||
72, // 102: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
|
80, // 102: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
|
||||||
75, // 103: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
|
82, // 103: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
|
||||||
78, // 104: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
|
84, // 104: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
|
||||||
80, // 105: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
|
85, // 105: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
|
||||||
82, // 106: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
|
86, // 106: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
|
||||||
84, // 107: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
|
90, // 107: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
|
||||||
85, // 108: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
|
93, // 108: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
|
||||||
86, // 109: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
|
96, // 109: hyapp.wallet.v1.WalletService.ApplyWithdrawal:input_type -> hyapp.wallet.v1.ApplyWithdrawalRequest
|
||||||
90, // 110: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
|
101, // 110: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
|
||||||
93, // 111: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
|
103, // 111: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
|
||||||
96, // 112: hyapp.wallet.v1.WalletService.ApplyWithdrawal:input_type -> hyapp.wallet.v1.ApplyWithdrawalRequest
|
105, // 112: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
|
||||||
101, // 113: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
|
107, // 113: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
|
||||||
103, // 114: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
|
110, // 114: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
|
||||||
105, // 115: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
|
112, // 115: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
|
||||||
107, // 116: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
|
114, // 116: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
|
||||||
110, // 117: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
|
116, // 117: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
|
||||||
112, // 118: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
|
118, // 118: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
|
||||||
114, // 119: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
|
123, // 119: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
|
||||||
116, // 120: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
|
125, // 120: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
|
||||||
118, // 121: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
|
127, // 121: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
|
||||||
123, // 122: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
|
129, // 122: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
|
||||||
125, // 123: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
|
131, // 123: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
|
||||||
127, // 124: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
|
133, // 124: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
|
||||||
129, // 125: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
|
135, // 125: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
|
||||||
131, // 126: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
|
137, // 126: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest
|
||||||
133, // 127: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
|
1, // 127: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
||||||
135, // 128: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
|
4, // 128: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
|
||||||
137, // 129: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest
|
6, // 129: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
|
||||||
140, // 130: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
8, // 130: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
|
||||||
140, // 131: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
10, // 131: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
|
||||||
140, // 132: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
22, // 132: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
|
||||||
1, // 133: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
24, // 133: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
|
||||||
4, // 134: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
|
28, // 134: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
6, // 135: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
|
28, // 135: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
8, // 136: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
|
28, // 136: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
10, // 137: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
|
30, // 137: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
|
||||||
22, // 138: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
|
32, // 138: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
|
||||||
24, // 139: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
|
36, // 139: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
||||||
28, // 140: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
36, // 140: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
||||||
28, // 141: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
36, // 141: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
||||||
28, // 142: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
|
38, // 142: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
|
||||||
30, // 143: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
|
40, // 143: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
|
||||||
32, // 144: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
|
46, // 144: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
36, // 145: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
46, // 145: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
36, // 146: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
46, // 146: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
36, // 147: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
42, // 147: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
|
||||||
38, // 148: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
|
49, // 148: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
||||||
40, // 149: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
|
49, // 149: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
||||||
46, // 150: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
51, // 150: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
|
||||||
46, // 151: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
53, // 151: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
|
||||||
46, // 152: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
55, // 152: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
|
||||||
42, // 153: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
|
58, // 153: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
|
||||||
49, // 154: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
60, // 154: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
|
||||||
49, // 155: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
62, // 155: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
|
||||||
51, // 156: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
|
64, // 156: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
|
||||||
53, // 157: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
|
67, // 157: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
|
||||||
55, // 158: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
|
70, // 158: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
|
||||||
58, // 159: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
|
73, // 159: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
|
||||||
60, // 160: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
|
76, // 160: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
|
||||||
62, // 161: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
|
79, // 161: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
|
||||||
64, // 162: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
|
81, // 162: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
|
||||||
67, // 163: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
|
83, // 163: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
|
||||||
70, // 164: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
|
87, // 164: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
||||||
73, // 165: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
|
87, // 165: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
||||||
76, // 166: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
|
88, // 166: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
|
||||||
79, // 167: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
|
91, // 167: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
|
||||||
81, // 168: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
|
94, // 168: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
|
||||||
83, // 169: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
|
97, // 169: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse
|
||||||
87, // 170: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
102, // 170: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
|
||||||
87, // 171: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
104, // 171: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
|
||||||
88, // 172: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
|
106, // 172: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
|
||||||
91, // 173: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
|
108, // 173: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
|
||||||
94, // 174: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
|
111, // 174: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
|
||||||
97, // 175: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse
|
113, // 175: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
|
||||||
102, // 176: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
|
115, // 176: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
|
||||||
104, // 177: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
|
117, // 177: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
|
||||||
106, // 178: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
|
119, // 178: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
|
||||||
108, // 179: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
|
124, // 179: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
|
||||||
111, // 180: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
|
126, // 180: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
|
||||||
113, // 181: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
|
128, // 181: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
|
||||||
115, // 182: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
|
130, // 182: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
|
||||||
117, // 183: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
|
132, // 183: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse
|
||||||
119, // 184: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
|
134, // 184: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse
|
||||||
124, // 185: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
|
136, // 185: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse
|
||||||
126, // 186: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
|
138, // 186: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse
|
||||||
128, // 187: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
|
127, // [127:187] is the sub-list for method output_type
|
||||||
130, // 188: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
|
67, // [67:127] is the sub-list for method input_type
|
||||||
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 type_name
|
||||||
67, // [67:67] is the sub-list for extension extendee
|
67, // [67:67] is the sub-list for extension extendee
|
||||||
0, // [0:67] is the sub-list for field type_name
|
0, // [0:67] is the sub-list for field type_name
|
||||||
@ -14224,9 +13974,9 @@ func file_proto_wallet_v1_wallet_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
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)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_wallet_v1_wallet_proto_rawDesc), len(file_proto_wallet_v1_wallet_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 141,
|
NumMessages: 139,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 2,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
GoTypes: file_proto_wallet_v1_wallet_proto_goTypes,
|
GoTypes: file_proto_wallet_v1_wallet_proto_goTypes,
|
||||||
DependencyIndexes: file_proto_wallet_v1_wallet_proto_depIdxs,
|
DependencyIndexes: file_proto_wallet_v1_wallet_proto_depIdxs,
|
||||||
|
|||||||
@ -17,12 +17,6 @@ message DebitGiftRequest {
|
|||||||
string app_code = 8;
|
string app_code = 8;
|
||||||
// region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。
|
// region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。
|
||||||
int64 region_id = 9;
|
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 账后余额。
|
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||||
@ -38,10 +32,6 @@ message DebitGiftResponse {
|
|||||||
string charge_asset_type = 8;
|
string charge_asset_type = 8;
|
||||||
int64 charge_amount = 9;
|
int64 charge_amount = 9;
|
||||||
string gift_type_code = 10;
|
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 是用户某类资产的余额投影。
|
// AssetBalance 是用户某类资产的余额投影。
|
||||||
@ -1155,7 +1145,6 @@ message CreditLuckyGiftRewardRequest {
|
|||||||
string gift_id = 7;
|
string gift_id = 7;
|
||||||
string pool_id = 8;
|
string pool_id = 8;
|
||||||
string reason = 9;
|
string reason = 9;
|
||||||
int64 visible_region_id = 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。
|
// CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。
|
||||||
@ -1362,32 +1351,6 @@ message RetryRedPacketRefundResponse {
|
|||||||
int64 server_time_ms = 3;
|
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 承载。
|
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||||
service WalletService {
|
service WalletService {
|
||||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||||
|
|||||||
@ -18,188 +18,6 @@ import (
|
|||||||
// Requires gRPC-Go v1.64.0 or later.
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
const _ = grpc.SupportPackageIsVersion9
|
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 (
|
const (
|
||||||
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
||||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Package main provides the repository-local go run launcher behind `make run`.
|
// Package main provides the repository-local hot reload runner behind `make run`.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -20,12 +20,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const stopTimeout = 8 * time.Second
|
const (
|
||||||
|
scanInterval = 800 * time.Millisecond
|
||||||
|
restartDebounce = 350 * time.Millisecond
|
||||||
|
stopTimeout = 8 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type serviceSpec struct {
|
type serviceSpec struct {
|
||||||
Name string
|
Name string
|
||||||
Dir string
|
Dir string
|
||||||
Args []string
|
Args []string
|
||||||
|
WatchRoot []string
|
||||||
Ports []int
|
Ports []int
|
||||||
Color string
|
Color string
|
||||||
}
|
}
|
||||||
@ -61,6 +66,7 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
runners := make(map[string]*serviceRunner, len(specs))
|
runners := make(map[string]*serviceRunner, len(specs))
|
||||||
|
watchers := make([]*watchState, 0, len(specs))
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
runner := &serviceRunner{spec: spec}
|
runner := &serviceRunner{spec: spec}
|
||||||
runners[spec.Name] = runner
|
runners[spec.Name] = runner
|
||||||
@ -68,13 +74,43 @@ func main() {
|
|||||||
if err := runner.start(root); err != nil {
|
if err := runner.start(root); err != nil {
|
||||||
logf(os.Stderr, spec, "start failed: %v", err)
|
logf(os.Stderr, spec, "start failed: %v", err)
|
||||||
}
|
}
|
||||||
|
watchers = append(watchers, newWatchState(root, spec))
|
||||||
}
|
}
|
||||||
|
|
||||||
printPlain(os.Stdout, "dev-run started %d service(s): %s", len(specs), serviceNames(specs))
|
printPlain(os.Stdout, "dev-run watching %d service(s): %s", len(specs), serviceNames(specs))
|
||||||
printPlain(os.Stdout, "press Ctrl-C to stop all services")
|
printPlain(os.Stdout, "press Ctrl-C to stop all services")
|
||||||
|
|
||||||
<-ctx.Done()
|
ticker := time.NewTicker(scanInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
pending := map[string]time.Time{}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
stopAll(runners)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allServices() []serviceSpec {
|
func allServices() []serviceSpec {
|
||||||
@ -93,6 +129,10 @@ func allServices() []serviceSpec {
|
|||||||
Name: "admin",
|
Name: "admin",
|
||||||
Dir: "server/admin",
|
Dir: "server/admin",
|
||||||
Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"},
|
Args: []string{"run", "./cmd/server", "-config", "configs/config.yaml"},
|
||||||
|
WatchRoot: []string{
|
||||||
|
"server/admin",
|
||||||
|
"api",
|
||||||
|
},
|
||||||
Ports: []int{13100},
|
Ports: []int{13100},
|
||||||
Color: "91",
|
Color: "91",
|
||||||
},
|
},
|
||||||
@ -104,6 +144,11 @@ func appService(name string, ports []int, color string) serviceSpec {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Dir: ".",
|
Dir: ".",
|
||||||
Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"},
|
Args: []string{"run", "./services/" + name + "/cmd/server", "-config", "services/" + name + "/configs/config.yaml"},
|
||||||
|
WatchRoot: []string{
|
||||||
|
"services/" + name,
|
||||||
|
"pkg",
|
||||||
|
"api",
|
||||||
|
},
|
||||||
Ports: ports,
|
Ports: ports,
|
||||||
Color: color,
|
Color: color,
|
||||||
}
|
}
|
||||||
@ -193,6 +238,14 @@ func (r *serviceRunner) start(repoRoot string) error {
|
|||||||
return nil
|
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() {
|
func (r *serviceRunner) stop() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
cmd := r.cmd
|
cmd := r.cmd
|
||||||
@ -253,6 +306,114 @@ func stopAll(runners map[string]*serviceRunner) {
|
|||||||
wg.Wait()
|
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) {
|
func killPortOwners(spec serviceSpec) {
|
||||||
seen := map[int]bool{}
|
seen := map[int]bool{}
|
||||||
for _, port := range spec.Ports {
|
for _, port := range spec.Ports {
|
||||||
|
|||||||
@ -1,308 +0,0 @@
|
|||||||
# 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`。
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -37,9 +37,7 @@ import (
|
|||||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
|
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
|
||||||
healthmodule "hyapp-admin-server/internal/modules/health"
|
healthmodule "hyapp-admin-server/internal/modules/health"
|
||||||
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
|
|
||||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||||
hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
|
|
||||||
jobmodule "hyapp-admin-server/internal/modules/job"
|
jobmodule "hyapp-admin-server/internal/modules/job"
|
||||||
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
|
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
|
||||||
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
|
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
|
||||||
@ -55,8 +53,6 @@ import (
|
|||||||
roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure"
|
roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure"
|
||||||
searchmodule "hyapp-admin-server/internal/modules/search"
|
searchmodule "hyapp-admin-server/internal/modules/search"
|
||||||
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
|
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"
|
uploadmodule "hyapp-admin-server/internal/modules/upload"
|
||||||
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
||||||
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
||||||
@ -195,12 +191,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auditHandler := auditmodule.New(store)
|
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
|
var objectUploader uploadmodule.ObjectUploader
|
||||||
if cfg.TencentCOS.Enabled {
|
if cfg.TencentCOS.Enabled {
|
||||||
uploader, err := tencentcos.NewUploader(tencentcos.Config{
|
uploader, err := tencentcos.NewUploader(tencentcos.Config{
|
||||||
@ -230,12 +220,10 @@ func main() {
|
|||||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
||||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
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),
|
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||||
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB),
|
|
||||||
Job: jobmodule.New(store, cfg, auditHandler),
|
Job: jobmodule.New(store, cfg, auditHandler),
|
||||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
|
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
Menu: menumodule.New(store, auditHandler),
|
Menu: menumodule.New(store, auditHandler),
|
||||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
RBAC: rbacmodule.New(store, auditHandler),
|
RBAC: rbacmodule.New(store, auditHandler),
|
||||||
@ -248,8 +236,6 @@ func main() {
|
|||||||
RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler),
|
RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler),
|
||||||
Search: searchmodule.New(store),
|
Search: searchmodule.New(store),
|
||||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
|
||||||
TeamSalarySettlement: teamSalarySettlementHandler,
|
|
||||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
|
|||||||
@ -18,7 +18,7 @@ migrations:
|
|||||||
dir: "/opt/hyapp/admin-server/migrations"
|
dir: "/opt/hyapp/admin-server/migrations"
|
||||||
allow_checksum_repair: false
|
allow_checksum_repair: false
|
||||||
jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS"
|
jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS"
|
||||||
access_token_ttl: "12h"
|
access_token_ttl: "30m"
|
||||||
refresh_token_ttl: "168h"
|
refresh_token_ttl: "168h"
|
||||||
cors_allowed_origins:
|
cors_allowed_origins:
|
||||||
- "http://43.128.56.40"
|
- "http://43.128.56.40"
|
||||||
|
|||||||
@ -18,7 +18,7 @@ migrations:
|
|||||||
# 本地开发阶段允许历史 migration 文件被修改后修复 checksum;staging/prod 必须关闭。
|
# 本地开发阶段允许历史 migration 文件被修改后修复 checksum;staging/prod 必须关闭。
|
||||||
allow_checksum_repair: true
|
allow_checksum_repair: true
|
||||||
jwt_secret: "dev-shared-secret"
|
jwt_secret: "dev-shared-secret"
|
||||||
access_token_ttl: "12h"
|
access_token_ttl: "30m"
|
||||||
refresh_token_ttl: "168h"
|
refresh_token_ttl: "168h"
|
||||||
cors_allowed_origins:
|
cors_allowed_origins:
|
||||||
- "http://localhost:7001"
|
- "http://localhost:7001"
|
||||||
|
|||||||
@ -129,7 +129,7 @@ func Default() Config {
|
|||||||
AllowChecksumRepair: true,
|
AllowChecksumRepair: true,
|
||||||
},
|
},
|
||||||
JWTSecret: "dev-shared-secret",
|
JWTSecret: "dev-shared-secret",
|
||||||
AccessTokenTTL: 12 * time.Hour,
|
AccessTokenTTL: 30 * time.Minute,
|
||||||
RefreshTokenTTL: 7 * 24 * time.Hour,
|
RefreshTokenTTL: 7 * 24 * time.Hour,
|
||||||
CORSAllowedOrigins: []string{
|
CORSAllowedOrigins: []string{
|
||||||
"http://localhost:7001",
|
"http://localhost:7001",
|
||||||
|
|||||||
@ -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" {
|
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)
|
t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN)
|
||||||
}
|
}
|
||||||
if cfg.AccessTokenTTL != 12*time.Hour {
|
if cfg.AccessTokenTTL != 30*time.Minute {
|
||||||
t.Fatalf("AccessTokenTTL = %s", cfg.AccessTokenTTL)
|
t.Fatalf("AccessTokenTTL = %s", cfg.AccessTokenTTL)
|
||||||
}
|
}
|
||||||
if cfg.RefreshTokenTTL != 7*24*time.Hour {
|
if cfg.RefreshTokenTTL != 7*24*time.Hour {
|
||||||
|
|||||||
@ -147,108 +147,6 @@ func (AppExploreTab) TableName() string {
|
|||||||
return "admin_app_explore_tabs"
|
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 {
|
type RefreshToken struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index;not null" json:"userId"`
|
UserID uint `gorm:"index;not null" json:"userId"`
|
||||||
|
|||||||
@ -65,16 +65,12 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.service.Refresh(token, LoginInput{
|
result, err := h.service.Refresh(token)
|
||||||
IP: c.ClientIP(),
|
|
||||||
UserAgent: c.Request.UserAgent(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "刷新会话已失效")
|
response.Unauthorized(c, "刷新会话已失效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds()))
|
|
||||||
response.OK(c, gin.H{
|
response.OK(c, gin.H{
|
||||||
"accessToken": result.AccessToken,
|
"accessToken": result.AccessToken,
|
||||||
"expiresAtMs": result.ExpiresAtMS,
|
"expiresAtMs": result.ExpiresAtMS,
|
||||||
|
|||||||
@ -14,23 +14,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuthFlowService struct {
|
type AuthFlowService struct {
|
||||||
store authStore
|
store *repository.Store
|
||||||
auth *authcore.AuthService
|
auth *authcore.AuthService
|
||||||
cfg config.Config
|
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 {
|
type LoginInput struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
@ -46,7 +34,7 @@ type LoginResult struct {
|
|||||||
Permissions []string
|
Permissions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store authStore, auth *authcore.AuthService, cfg config.Config) *AuthFlowService {
|
func NewService(store *repository.Store, auth *authcore.AuthService, cfg config.Config) *AuthFlowService {
|
||||||
return &AuthFlowService{store: store, auth: auth, cfg: cfg}
|
return &AuthFlowService{store: store, auth: auth, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,11 +54,17 @@ func (s *AuthFlowService) Login(input LoginInput) (LoginResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
refreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent)
|
refreshToken, refreshHash, err := authcore.NewRefreshToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
if err := s.store.CreateRefreshToken(refreshRecord); err != nil {
|
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 {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
_ = s.store.TouchUserLogin(user.ID)
|
_ = s.store.TouchUserLogin(user.ID)
|
||||||
@ -85,7 +79,7 @@ func (s *AuthFlowService) Logout(refreshToken string) error {
|
|||||||
return s.store.RevokeRefreshToken(authcore.HashToken(refreshToken))
|
return s.store.RevokeRefreshToken(authcore.HashToken(refreshToken))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthFlowService) Refresh(refreshToken string, input LoginInput) (LoginResult, error) {
|
func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) {
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
return LoginResult{}, errors.New("刷新会话不存在")
|
return LoginResult{}, errors.New("刷新会话不存在")
|
||||||
}
|
}
|
||||||
@ -102,28 +96,7 @@ func (s *AuthFlowService) Refresh(refreshToken string, input LoginInput) (LoginR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
newRefreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent)
|
return LoginResult{AccessToken: accessToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil
|
||||||
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) {
|
func (s *AuthFlowService) Me(actor shared.Actor) (*model.User, []string, error) {
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,614 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
package luckygift
|
package luckygift
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -18,15 +17,11 @@ import (
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
activity activityclient.Client
|
activity activityclient.Client
|
||||||
requestTimeout time.Duration
|
|
||||||
audit shared.OperationLogger
|
audit shared.OperationLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(activity activityclient.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
|
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler {
|
||||||
if requestTimeout <= 0 {
|
return &Handler{activity: activity, audit: audit}
|
||||||
requestTimeout = 3 * time.Second
|
|
||||||
}
|
|
||||||
return &Handler{activity: activity, requestTimeout: requestTimeout, audit: audit}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type configRequest struct {
|
type configRequest struct {
|
||||||
@ -113,9 +108,7 @@ type drawSummaryDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
||||||
ctx, cancel := h.activityContext(c)
|
resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{
|
||||||
defer cancel()
|
|
||||||
resp, err := h.activity.GetLuckyGiftConfig(ctx, &activityv1.GetLuckyGiftConfigRequest{
|
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||||
})
|
})
|
||||||
@ -135,9 +128,7 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
|||||||
if req.PoolID == "" {
|
if req.PoolID == "" {
|
||||||
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
|
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
|
||||||
}
|
}
|
||||||
ctx, cancel := h.activityContext(c)
|
resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{
|
||||||
defer cancel()
|
|
||||||
resp, err := h.activity.UpsertLuckyGiftConfig(ctx, &activityv1.UpsertLuckyGiftConfigRequest{
|
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
Config: configToProto(req),
|
Config: configToProto(req),
|
||||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
@ -152,9 +143,7 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
|
func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
|
||||||
ctx, cancel := h.activityContext(c)
|
resp, err := h.activity.ListLuckyGiftConfigs(c.Request.Context(), &activityv1.ListLuckyGiftConfigsRequest{
|
||||||
defer cancel()
|
|
||||||
resp, err := h.activity.ListLuckyGiftConfigs(ctx, &activityv1.ListLuckyGiftConfigsRequest{
|
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -170,9 +159,7 @@ func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
|
func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
ctx, cancel := h.activityContext(c)
|
resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{
|
||||||
defer cancel()
|
|
||||||
resp, err := h.activity.ListLuckyGiftDraws(ctx, &activityv1.ListLuckyGiftDrawsRequest{
|
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||||
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
||||||
@ -194,9 +181,7 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) {
|
func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) {
|
||||||
ctx, cancel := h.activityContext(c)
|
resp, err := h.activity.GetLuckyGiftDrawSummary(c.Request.Context(), &activityv1.GetLuckyGiftDrawSummaryRequest{
|
||||||
defer cancel()
|
|
||||||
resp, err := h.activity.GetLuckyGiftDrawSummary(ctx, &activityv1.GetLuckyGiftDrawSummaryRequest{
|
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||||
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
||||||
@ -220,10 +205,6 @@ 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 {
|
func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
|
||||||
stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages))
|
stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages))
|
||||||
for _, stage := range req.Stages {
|
for _, stage := range req.Stages {
|
||||||
|
|||||||
@ -1,121 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,406 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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
@ -1,381 +0,0 @@
|
|||||||
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, "`", "``") + "`"
|
|
||||||
}
|
|
||||||
@ -3,8 +3,6 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"hyapp-admin-server/internal/model"
|
"hyapp-admin-server/internal/model"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) FindUserByUsername(username string) (*model.User, error) {
|
func (s *Store) FindUserByUsername(username string) (*model.User, error) {
|
||||||
@ -55,18 +53,6 @@ func (s *Store) CreateRefreshToken(token model.RefreshToken) error {
|
|||||||
return s.db.Create(&token).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) {
|
func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) {
|
||||||
var token model.RefreshToken
|
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
|
err := s.db.Where("token_hash = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?", hash, time.Now().UTC().UnixMilli()).First(&token).Error
|
||||||
|
|||||||
@ -1,154 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -66,10 +66,6 @@ func (s *Store) AutoMigrate() error {
|
|||||||
&model.AppBanner{},
|
&model.AppBanner{},
|
||||||
&model.AppVersion{},
|
&model.AppVersion{},
|
||||||
&model.AppExploreTab{},
|
&model.AppExploreTab{},
|
||||||
&model.HostAgencySalaryPolicy{},
|
|
||||||
&model.HostAgencySalaryLevel{},
|
|
||||||
&model.TeamSalaryPolicy{},
|
|
||||||
&model.TeamSalaryLevel{},
|
|
||||||
&model.RefreshToken{},
|
&model.RefreshToken{},
|
||||||
&model.LoginLog{},
|
&model.LoginLog{},
|
||||||
&model.OperationLog{},
|
&model.OperationLog{},
|
||||||
|
|||||||
@ -63,17 +63,6 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "区域更新", Code: "region:update", Kind: "button"},
|
{Name: "区域更新", Code: "region:update", Kind: "button"},
|
||||||
{Name: "区域状态", Code: "region:status", Kind: "button"},
|
{Name: "区域状态", Code: "region:status", Kind: "button"},
|
||||||
{Name: "主播查看", Code: "host:view", Kind: "menu"},
|
{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:view", Kind: "menu"},
|
||||||
{Name: "Agency 创建", Code: "agency:create", Kind: "button"},
|
{Name: "Agency 创建", Code: "agency:create", Kind: "button"},
|
||||||
{Name: "Agency 状态", Code: "agency:status", Kind: "button"},
|
{Name: "Agency 状态", Code: "agency:status", Kind: "button"},
|
||||||
@ -268,9 +257,7 @@ 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 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: "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: "Host 列表", Code: "host-org-hosts", Path: "/host/hosts", Icon: "users", PermissionCode: "host:view", Sort: 84, 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: "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-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 {
|
for _, menu := range children {
|
||||||
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
|
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
|
||||||
@ -493,7 +480,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"emoji-pack:view", "emoji-pack:create",
|
"emoji-pack:view", "emoji-pack:create",
|
||||||
"country:view", "country:create", "country:update", "country:status",
|
"country:view", "country:create", "country:update", "country:status",
|
||||||
"region:view", "region:create", "region:update", "region:status",
|
"region:view", "region:create", "region:update", "region:status",
|
||||||
"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",
|
"host:view",
|
||||||
"agency:view", "agency:create", "agency:status",
|
"agency:view", "agency:create", "agency:status",
|
||||||
"bd:view", "bd:create", "bd:update",
|
"bd:view", "bd:create", "bd:update",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
||||||
@ -511,7 +498,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
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", "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"}
|
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"}
|
||||||
case "readonly":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -533,9 +520,6 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"country:view",
|
"country:view",
|
||||||
"region:view",
|
"region:view",
|
||||||
"host:view",
|
"host:view",
|
||||||
"host-agency-policy:view",
|
|
||||||
"team-salary-policy:view",
|
|
||||||
"host-salary-settlement:view",
|
|
||||||
"agency:view",
|
"agency:view",
|
||||||
"bd:view",
|
"bd:view",
|
||||||
"coin-seller:view",
|
"coin-seller:view",
|
||||||
@ -582,7 +566,6 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"upload:create",
|
"upload:create",
|
||||||
"country:view", "country:create", "country:update", "country:status",
|
"country:view", "country:create", "country:update", "country:status",
|
||||||
"region:view", "region:create", "region:update", "region: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-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",
|
"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",
|
"lucky-gift:view", "lucky-gift:update",
|
||||||
@ -595,7 +578,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"vip-config:view", "vip-config:update", "vip-config:grant",
|
"vip-config:view", "vip-config:update", "vip-config:grant",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
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", "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"}
|
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"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -17,9 +17,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
|
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
|
||||||
"hyapp-admin-server/internal/modules/health"
|
"hyapp-admin-server/internal/modules/health"
|
||||||
"hyapp-admin-server/internal/modules/hostagencypolicy"
|
|
||||||
"hyapp-admin-server/internal/modules/hostorg"
|
"hyapp-admin-server/internal/modules/hostorg"
|
||||||
"hyapp-admin-server/internal/modules/hostsalarysettlement"
|
|
||||||
"hyapp-admin-server/internal/modules/job"
|
"hyapp-admin-server/internal/modules/job"
|
||||||
"hyapp-admin-server/internal/modules/levelconfig"
|
"hyapp-admin-server/internal/modules/levelconfig"
|
||||||
"hyapp-admin-server/internal/modules/luckygift"
|
"hyapp-admin-server/internal/modules/luckygift"
|
||||||
@ -35,8 +33,6 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/roomtreasure"
|
"hyapp-admin-server/internal/modules/roomtreasure"
|
||||||
"hyapp-admin-server/internal/modules/search"
|
"hyapp-admin-server/internal/modules/search"
|
||||||
"hyapp-admin-server/internal/modules/sevendaycheckin"
|
"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/upload"
|
||||||
"hyapp-admin-server/internal/modules/userleaderboard"
|
"hyapp-admin-server/internal/modules/userleaderboard"
|
||||||
"hyapp-admin-server/internal/modules/vipconfig"
|
"hyapp-admin-server/internal/modules/vipconfig"
|
||||||
@ -61,9 +57,7 @@ type Handlers struct {
|
|||||||
FirstRechargeReward *firstrechargereward.Handler
|
FirstRechargeReward *firstrechargereward.Handler
|
||||||
Game *gamemanagement.Handler
|
Game *gamemanagement.Handler
|
||||||
Health *health.Handler
|
Health *health.Handler
|
||||||
HostAgencyPolicy *hostagencypolicy.Handler
|
|
||||||
HostOrg *hostorg.Handler
|
HostOrg *hostorg.Handler
|
||||||
HostSalarySettlement *hostsalarysettlement.Handler
|
|
||||||
Job *job.Handler
|
Job *job.Handler
|
||||||
LevelConfig *levelconfig.Handler
|
LevelConfig *levelconfig.Handler
|
||||||
LuckyGift *luckygift.Handler
|
LuckyGift *luckygift.Handler
|
||||||
@ -79,8 +73,6 @@ type Handlers struct {
|
|||||||
RoomTreasure *roomtreasure.Handler
|
RoomTreasure *roomtreasure.Handler
|
||||||
Search *search.Handler
|
Search *search.Handler
|
||||||
SevenDayCheckIn *sevendaycheckin.Handler
|
SevenDayCheckIn *sevendaycheckin.Handler
|
||||||
TeamSalaryPolicy *teamsalarypolicy.Handler
|
|
||||||
TeamSalarySettlement *teamsalarysettlement.Handler
|
|
||||||
Upload *upload.Handler
|
Upload *upload.Handler
|
||||||
UserLeaderboard *userleaderboard.Handler
|
UserLeaderboard *userleaderboard.Handler
|
||||||
VIPConfig *vipconfig.Handler
|
VIPConfig *vipconfig.Handler
|
||||||
@ -118,8 +110,6 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
||||||
roomtreasure.RegisterRoutes(protected, h.RoomTreasure)
|
roomtreasure.RegisterRoutes(protected, h.RoomTreasure)
|
||||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||||
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
|
||||||
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
|
||||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||||
audit.RegisterRoutes(protected, h.Audit)
|
audit.RegisterRoutes(protected, h.Audit)
|
||||||
payment.RegisterRoutes(protected, h.Payment)
|
payment.RegisterRoutes(protected, h.Payment)
|
||||||
@ -129,8 +119,6 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
upload.RegisterRoutes(protected, h.Upload)
|
upload.RegisterRoutes(protected, h.Upload)
|
||||||
search.RegisterRoutes(protected, h.Search)
|
search.RegisterRoutes(protected, h.Search)
|
||||||
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
|
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
|
||||||
teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy)
|
|
||||||
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
|
|
||||||
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
|
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
|
||||||
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
||||||
|
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
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';
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
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';
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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';
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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';
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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';
|
|
||||||
@ -21,7 +21,6 @@ lucky_gift_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
worker_poll_interval: "1s"
|
worker_poll_interval: "1s"
|
||||||
worker_batch_size: 100
|
worker_batch_size: 100
|
||||||
worker_concurrency: 4
|
|
||||||
worker_lock_ttl: "30s"
|
worker_lock_ttl: "30s"
|
||||||
worker_max_retry: 8
|
worker_max_retry: 8
|
||||||
publish_timeout: "5s"
|
publish_timeout: "5s"
|
||||||
|
|||||||
@ -19,9 +19,8 @@ red_packet_broadcast_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
lucky_gift_worker:
|
lucky_gift_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
worker_poll_interval: "60s"
|
worker_poll_interval: "1s"
|
||||||
worker_batch_size: 10
|
worker_batch_size: 100
|
||||||
worker_concurrency: 4
|
|
||||||
worker_lock_ttl: "30s"
|
worker_lock_ttl: "30s"
|
||||||
worker_max_retry: 8
|
worker_max_retry: 8
|
||||||
publish_timeout: "5s"
|
publish_timeout: "5s"
|
||||||
|
|||||||
@ -21,7 +21,6 @@ lucky_gift_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
worker_poll_interval: "1s"
|
worker_poll_interval: "1s"
|
||||||
worker_batch_size: 100
|
worker_batch_size: 100
|
||||||
worker_concurrency: 4
|
|
||||||
worker_lock_ttl: "30s"
|
worker_lock_ttl: "30s"
|
||||||
worker_max_retry: 8
|
worker_max_retry: 8
|
||||||
publish_timeout: "5s"
|
publish_timeout: "5s"
|
||||||
|
|||||||
@ -44,10 +44,7 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
|
|||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动事件 outbox 表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lucky_gift_rules (
|
CREATE TABLE IF NOT EXISTS lucky_gift_rules (
|
||||||
@ -259,44 +256,6 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
|
|||||||
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms)
|
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',
|
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',
|
||||||
@ -313,9 +272,8 @@ CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
|
|||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
PRIMARY KEY (app_code, event_id),
|
||||||
KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, 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, event_id),
|
KEY idx_im_broadcast_outbox_lock (app_code, status, locked_until_ms, created_at_ms)
|
||||||
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 广播发件箱表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM 广播发件箱表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_definitions (
|
CREATE TABLE IF NOT EXISTS task_definitions (
|
||||||
|
|||||||
@ -581,7 +581,6 @@ func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luc
|
|||||||
WorkerID: nodeID + "-lucky-gift",
|
WorkerID: nodeID + "-lucky-gift",
|
||||||
PollInterval: cfg.WorkerPollInterval,
|
PollInterval: cfg.WorkerPollInterval,
|
||||||
BatchSize: cfg.WorkerBatchSize,
|
BatchSize: cfg.WorkerBatchSize,
|
||||||
Concurrency: cfg.WorkerConcurrency,
|
|
||||||
LockTTL: cfg.WorkerLockTTL,
|
LockTTL: cfg.WorkerLockTTL,
|
||||||
MaxRetry: cfg.WorkerMaxRetry,
|
MaxRetry: cfg.WorkerMaxRetry,
|
||||||
PublishTimeout: cfg.PublishTimeout,
|
PublishTimeout: cfg.PublishTimeout,
|
||||||
|
|||||||
@ -72,7 +72,6 @@ type LuckyGiftWorkerConfig struct {
|
|||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
|
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
|
||||||
WorkerBatchSize int `yaml:"worker_batch_size"`
|
WorkerBatchSize int `yaml:"worker_batch_size"`
|
||||||
WorkerConcurrency int `yaml:"worker_concurrency"`
|
|
||||||
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
|
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
|
||||||
WorkerMaxRetry int `yaml:"worker_max_retry"`
|
WorkerMaxRetry int `yaml:"worker_max_retry"`
|
||||||
PublishTimeout time.Duration `yaml:"publish_timeout"`
|
PublishTimeout time.Duration `yaml:"publish_timeout"`
|
||||||
@ -170,7 +169,6 @@ func Default() Config {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
WorkerPollInterval: time.Second,
|
WorkerPollInterval: time.Second,
|
||||||
WorkerBatchSize: 100,
|
WorkerBatchSize: 100,
|
||||||
WorkerConcurrency: 4,
|
|
||||||
WorkerLockTTL: 30 * time.Second,
|
WorkerLockTTL: 30 * time.Second,
|
||||||
WorkerMaxRetry: 8,
|
WorkerMaxRetry: 8,
|
||||||
PublishTimeout: 5 * time.Second,
|
PublishTimeout: 5 * time.Second,
|
||||||
@ -299,12 +297,6 @@ func Load(path string) (Config, error) {
|
|||||||
if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 {
|
if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 {
|
||||||
cfg.LuckyGiftWorker.WorkerBatchSize = 100
|
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 {
|
if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 {
|
||||||
cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second
|
cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -143,7 +142,6 @@ type WorkerOptions struct {
|
|||||||
WorkerID string
|
WorkerID string
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
BatchSize int
|
BatchSize int
|
||||||
Concurrency int
|
|
||||||
LockTTL time.Duration
|
LockTTL time.Duration
|
||||||
MaxRetry int
|
MaxRetry int
|
||||||
PublishTimeout time.Duration
|
PublishTimeout time.Duration
|
||||||
@ -176,54 +174,15 @@ func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOp
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
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 {
|
|
||||||
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
|
processed := 0
|
||||||
var firstErr error
|
for _, event := range events {
|
||||||
for err := range errs {
|
// 单条失败立即返回,让外层记录批次错误;已处理成功的事件已经各自落 delivered,不会被回滚。
|
||||||
if err != nil {
|
if err := s.processDrawOutbox(ctx, event, options); err != nil {
|
||||||
if firstErr == nil {
|
return processed, err
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
processed++
|
processed++
|
||||||
}
|
}
|
||||||
return processed, firstErr
|
return processed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
|
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
|
||||||
@ -250,7 +209,6 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
|
|||||||
GiftId: payload.GiftID,
|
GiftId: payload.GiftID,
|
||||||
PoolId: payload.PoolID,
|
PoolId: payload.PoolID,
|
||||||
Reason: "lucky_gift_reward",
|
Reason: "lucky_gift_reward",
|
||||||
VisibleRegionId: payload.VisibleRegionID,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.markOutboxFailed(eventCtx, event, options, err, false)
|
return s.markOutboxFailed(eventCtx, event, options, err, false)
|
||||||
@ -258,12 +216,6 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
|
|||||||
walletTransactionID = resp.GetTransactionId()
|
walletTransactionID = resp.GetTransactionId()
|
||||||
credited = true
|
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 {
|
if s.publisher == nil {
|
||||||
// 已返奖但缺少房间发布依赖时不能把 draw 标记 failed,否则会误导账务;只让 outbox 保持可重试。
|
// 已返奖但缺少房间发布依赖时不能把 draw 标记 failed,否则会误导账务;只让 outbox 保持可重试。
|
||||||
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
|
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
|
||||||
@ -281,16 +233,16 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
|
|||||||
return s.markOutboxFailed(eventCtx, event, options, err, credited)
|
return s.markOutboxFailed(eventCtx, event, options, err, credited)
|
||||||
}
|
}
|
||||||
nowMS := s.now().UTC().UnixMilli()
|
nowMS := s.now().UTC().UnixMilli()
|
||||||
if !credited {
|
// 所有外部副作用都成功后再把整批 draw 从 pending 收敛到 granted。
|
||||||
|
// 这一步必须晚于钱包和 IM,否则客户端可能看到“已发放”但余额或房间表现还没落地。
|
||||||
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
|
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
|
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||||||
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -448,36 +400,6 @@ func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
|
|||||||
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= 1_000_000
|
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 {
|
func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int64) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"event_type": "lucky_gift_drawn",
|
"event_type": "lucky_gift_drawn",
|
||||||
@ -568,12 +490,6 @@ func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
|
|||||||
if options.BatchSize > 500 {
|
if options.BatchSize > 500 {
|
||||||
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 {
|
if options.LockTTL <= 0 {
|
||||||
options.LockTTL = 30 * time.Second
|
options.LockTTL = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,6 @@ package luckygift
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -126,7 +124,6 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
|||||||
"room_id": "room-1",
|
"room_id": "room-1",
|
||||||
"gift_id": "rose",
|
"gift_id": "rose",
|
||||||
"gift_count": 3,
|
"gift_count": 3,
|
||||||
"visible_region_id": 210,
|
|
||||||
"coin_spent": 300,
|
"coin_spent": 300,
|
||||||
"effective_reward_coins": 800,
|
"effective_reward_coins": 800,
|
||||||
"multiplier_ppm": 8000000,
|
"multiplier_ppm": 8000000,
|
||||||
@ -142,8 +139,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
|||||||
}
|
}
|
||||||
wallet := &fakeLuckyGiftWallet{}
|
wallet := &fakeLuckyGiftWallet{}
|
||||||
publisher := &fakeLuckyGiftPublisher{}
|
publisher := &fakeLuckyGiftPublisher{}
|
||||||
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher))
|
||||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
|
|
||||||
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
||||||
|
|
||||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||||
@ -153,7 +149,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
|||||||
if processed != 1 {
|
if processed != 1 {
|
||||||
t.Fatalf("processed count mismatch: %d", processed)
|
t.Fatalf("processed count mismatch: %d", processed)
|
||||||
}
|
}
|
||||||
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetVisibleRegionId() != 210 {
|
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" {
|
||||||
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
|
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" {
|
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
|
||||||
@ -161,106 +157,6 @@ 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 {
|
type fakeLuckyGiftRepository struct {
|
||||||
getConfigCalls int
|
getConfigCalls int
|
||||||
upserted domain.RuleConfig
|
upserted domain.RuleConfig
|
||||||
@ -269,7 +165,6 @@ type fakeLuckyGiftRepository struct {
|
|||||||
grantedDrawID string
|
grantedDrawID string
|
||||||
grantedDrawIDs []string
|
grantedDrawIDs []string
|
||||||
deliveredOutboxID string
|
deliveredOutboxID string
|
||||||
retryableOutboxID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
|
func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
|
||||||
@ -311,8 +206,7 @@ func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxDelivered(_ context.Context
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error {
|
func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(context.Context, domain.DrawOutbox, int, int64, string, int64) error {
|
||||||
r.retryableOutboxID = event.OutboxID
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,12 +246,11 @@ func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *wall
|
|||||||
|
|
||||||
type fakeLuckyGiftPublisher struct {
|
type fakeLuckyGiftPublisher struct {
|
||||||
last tencentim.CustomGroupMessage
|
last tencentim.CustomGroupMessage
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
|
func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
|
||||||
p.last = message
|
p.last = message
|
||||||
return p.err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeLuckyGiftRegionBroadcaster struct {
|
type fakeLuckyGiftRegionBroadcaster struct {
|
||||||
|
|||||||
@ -93,55 +93,50 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
appCode := appcode.FromContext(ctx)
|
rows, err := tx.QueryContext(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),
|
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,
|
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
||||||
created_at_ms, updated_at_ms
|
created_at_ms, updated_at_ms
|
||||||
FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_pending)
|
FROM im_broadcast_outbox
|
||||||
WHERE app_code = ?
|
WHERE app_code = ?
|
||||||
AND attempt_count < ?
|
AND attempt_count < ?
|
||||||
AND status IN (?, ?)
|
AND (
|
||||||
AND next_retry_at_ms <= ?
|
(status IN (?, ?) AND next_retry_at_ms <= ?)
|
||||||
|
OR (status = ? AND locked_until_ms <= ?)
|
||||||
|
)
|
||||||
ORDER BY created_at_ms ASC
|
ORDER BY created_at_ms ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
FOR UPDATE SKIP LOCKED`,
|
FOR UPDATE SKIP LOCKED`,
|
||||||
args: []any{appCode, maxRetry, broadcastdomain.StatusPending, broadcastdomain.StatusRetryable, nowMS},
|
appcode.FromContext(ctx),
|
||||||
},
|
maxRetry,
|
||||||
{
|
broadcastdomain.StatusPending,
|
||||||
query: `
|
broadcastdomain.StatusRetryable,
|
||||||
SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR),
|
nowMS,
|
||||||
status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms,
|
broadcastdomain.StatusDelivering,
|
||||||
created_at_ms, updated_at_ms
|
nowMS,
|
||||||
FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_lock)
|
limit,
|
||||||
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},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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 {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, branchRecords...)
|
records := make([]broadcastdomain.OutboxRecord, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
record, err := scanBroadcastOutbox(rows)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
// 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。
|
// 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。
|
||||||
@ -171,27 +166,6 @@ func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID s
|
|||||||
return records, nil
|
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。
|
// MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。
|
||||||
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
|
// delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。
|
||||||
func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {
|
func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {
|
||||||
|
|||||||
@ -576,9 +576,6 @@ func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appC
|
|||||||
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
|
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.incrementLuckyDrawPoolStats(ctx, tx, state.Records, nowMS); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if state.NeedsOutbox {
|
if state.NeedsOutbox {
|
||||||
// 只有本批存在钱包/IM/反馈副作用时才产生 outbox;纯 0 倍且无反馈的批次直接终态 granted。
|
// 只有本批存在钱包/IM/反馈副作用时才产生 outbox;纯 0 倍且无反馈的批次直接终态 granted。
|
||||||
return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS)
|
return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS)
|
||||||
@ -656,143 +653,6 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec
|
|||||||
return nil
|
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 语义漂移。
|
// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。
|
||||||
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
|
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
|
||||||
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
|
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
|
||||||
@ -1272,27 +1132,6 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
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) {
|
if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
|
||||||
// outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。
|
// outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。
|
||||||
payload, _ := json.Marshal(map[string]any{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
@ -1359,35 +1198,31 @@ func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID s
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
appCode := appcode.FromContext(ctx)
|
rows, err := tx.QueryContext(ctx, `
|
||||||
events, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
|
|
||||||
SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
|
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_retry)
|
FROM activity_outbox
|
||||||
WHERE app_code = ?
|
WHERE event_type = 'LuckyGiftDrawn'
|
||||||
AND event_type = 'LuckyGiftDrawn'
|
AND (
|
||||||
AND status IN ('pending', 'retryable')
|
(status IN ('pending', 'retryable') AND next_retry_at_ms <= ?)
|
||||||
AND next_retry_at_ms <= ?
|
OR (status = 'delivering' AND lock_until_ms <= ?)
|
||||||
|
)
|
||||||
ORDER BY created_at_ms ASC, outbox_id ASC
|
ORDER BY created_at_ms ASC, outbox_id ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
FOR UPDATE`, batchSize, appCode, nowMS, batchSize)
|
FOR UPDATE`, nowMS, nowMS, batchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if remaining := batchSize - len(events); remaining > 0 {
|
defer rows.Close()
|
||||||
retryEvents, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
|
events := make([]domain.DrawOutbox, 0, batchSize)
|
||||||
SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
|
for rows.Next() {
|
||||||
FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_lock)
|
var event domain.DrawOutbox
|
||||||
WHERE app_code = ?
|
if err := rows.Scan(&event.AppCode, &event.OutboxID, &event.EventType, &event.PayloadJSON, &event.RetryCount, &event.CreatedAtMS); err != nil {
|
||||||
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
|
return nil, err
|
||||||
}
|
}
|
||||||
events = append(events, retryEvents...)
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
lockUntilMS := nowMS + lockTTL.Milliseconds()
|
lockUntilMS := nowMS + lockTTL.Milliseconds()
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
@ -1406,23 +1241,6 @@ func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID s
|
|||||||
return events, nil
|
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 {
|
func (r *Repository) MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -1508,15 +1326,6 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
|
|||||||
end = len(drawIDs)
|
end = len(drawIDs)
|
||||||
}
|
}
|
||||||
chunk := drawIDs[start:end]
|
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}
|
args := []any{status, walletTransactionID, failureReason, nowMS, appCode}
|
||||||
for _, drawID := range chunk {
|
for _, drawID := range chunk {
|
||||||
args = append(args, drawID)
|
args = append(args, drawID)
|
||||||
@ -1524,117 +1333,18 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
|
|||||||
query := `
|
query := `
|
||||||
UPDATE lucky_draw_records
|
UPDATE lucky_draw_records
|
||||||
SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ?
|
SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ?
|
||||||
WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `) AND reward_status <> ?`
|
WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `)`
|
||||||
args = append(args, status)
|
|
||||||
if onlyPending {
|
if onlyPending {
|
||||||
query += ` AND reward_status = ?`
|
query += ` AND reward_status = ?`
|
||||||
args = append(args, domain.StatusPending)
|
args = append(args, domain.StatusPending)
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
if _, err := r.db.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 err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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。
|
// normalizeLuckyDrawIDs 去掉空值和重复值;outbox payload 是补偿输入,必须先清洗再拼 SQL IN。
|
||||||
func normalizeLuckyDrawIDs(drawIDs []string) []string {
|
func normalizeLuckyDrawIDs(drawIDs []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@ -1711,13 +1421,6 @@ func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.D
|
|||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
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)
|
whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query)
|
||||||
var summary domain.DrawSummary
|
var summary domain.DrawSummary
|
||||||
summary.PoolID = query.PoolID
|
summary.PoolID = query.PoolID
|
||||||
@ -1756,45 +1459,6 @@ func (r *Repository) getLuckyGiftDrawSummaryFromRecords(ctx context.Context, que
|
|||||||
return summary, nil
|
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) {
|
func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) {
|
||||||
where := []string{"app_code = ?"}
|
where := []string{"app_code = ?"}
|
||||||
args := []any{appCode}
|
args := []any{appCode}
|
||||||
|
|||||||
@ -48,18 +48,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil {
|
if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil {
|
||||||
return err
|
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 {
|
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.ensureLuckyDrawPoolStatsTables(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
|
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -72,146 +63,6 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (r *Repository) ensureLuckyUserStateFlowColumns(ctx context.Context) error {
|
||||||
// 用户阶段改按金币流水折算等价抽数,状态表必须持久保存累计流水和连续未中奖次数,不能再只依赖 paid_draws。
|
// 用户阶段改按金币流水折算等价抽数,状态表必须持久保存累计流水和连续未中奖次数,不能再只依赖 paid_draws。
|
||||||
additions := []struct {
|
additions := []struct {
|
||||||
@ -305,51 +156,6 @@ func (r *Repository) ensureLuckyGiftOutboxColumns(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context) error {
|
||||||
additions := []struct {
|
additions := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import (
|
|||||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
gamev1 "hyapp.local/api/proto/game/v1"
|
gamev1 "hyapp.local/api/proto/game/v1"
|
||||||
userv1 "hyapp.local/api/proto/user/v1"
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
||||||
"hyapp/pkg/grpchealth"
|
"hyapp/pkg/grpchealth"
|
||||||
"hyapp/pkg/grpcshutdown"
|
"hyapp/pkg/grpcshutdown"
|
||||||
"hyapp/pkg/healthhttp"
|
"hyapp/pkg/healthhttp"
|
||||||
@ -36,7 +35,6 @@ type App struct {
|
|||||||
userConn *grpc.ClientConn
|
userConn *grpc.ClientConn
|
||||||
activityConn *grpc.ClientConn
|
activityConn *grpc.ClientConn
|
||||||
gameConn *grpc.ClientConn
|
gameConn *grpc.ClientConn
|
||||||
walletConn *grpc.ClientConn
|
|
||||||
workerCtx context.Context
|
workerCtx context.Context
|
||||||
workerCancel context.CancelFunc
|
workerCancel context.CancelFunc
|
||||||
workerWG sync.WaitGroup
|
workerWG sync.WaitGroup
|
||||||
@ -80,15 +78,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
_ = repository.Close()
|
_ = repository.Close()
|
||||||
return nil, err
|
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")))
|
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service")))
|
||||||
health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{
|
health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{
|
||||||
@ -98,7 +87,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
|
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
|
||||||
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
|
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = gameConn.Close()
|
_ = gameConn.Close()
|
||||||
_ = activityConn.Close()
|
_ = activityConn.Close()
|
||||||
_ = userConn.Close()
|
_ = userConn.Close()
|
||||||
@ -111,7 +99,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
|
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
|
||||||
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
|
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
|
||||||
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
|
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
|
||||||
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
|
|
||||||
taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{
|
taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{
|
||||||
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
|
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
|
||||||
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
|
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
|
||||||
@ -120,9 +107,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
|
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
|
||||||
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
|
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
|
||||||
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
|
"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{
|
return &App{
|
||||||
@ -135,7 +119,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
userConn: userConn,
|
userConn: userConn,
|
||||||
activityConn: activityConn,
|
activityConn: activityConn,
|
||||||
gameConn: gameConn,
|
gameConn: gameConn,
|
||||||
walletConn: walletConn,
|
|
||||||
workerCtx: workerCtx,
|
workerCtx: workerCtx,
|
||||||
workerCancel: workerCancel,
|
workerCancel: workerCancel,
|
||||||
}, nil
|
}, nil
|
||||||
@ -187,9 +170,6 @@ func (a *App) Close() {
|
|||||||
if a.gameConn != nil {
|
if a.gameConn != nil {
|
||||||
_ = a.gameConn.Close()
|
_ = a.gameConn.Close()
|
||||||
}
|
}
|
||||||
if a.walletConn != nil {
|
|
||||||
_ = a.walletConn.Close()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -172,30 +172,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
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": {
|
"mic_open_session_compensation": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Interval: "60s",
|
Interval: "60s",
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
package integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
||||||
"hyapp/services/cron-service/internal/scheduler"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WalletCronClient delegates wallet-owned salary settlement batches to wallet-service.
|
|
||||||
type WalletCronClient struct {
|
|
||||||
client walletv1.WalletCronServiceClient
|
|
||||||
}
|
|
||||||
|
|
||||||
var walletCronNowUTC = func() time.Time {
|
|
||||||
return time.Now().UTC()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWalletCronClient creates scheduler handlers backed by wallet-service gRPC.
|
|
||||||
func NewWalletCronClient(client walletv1.WalletCronServiceClient) *WalletCronClient {
|
|
||||||
return &WalletCronClient{client: client}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WalletCronClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
|
|
||||||
resp, err := c.client.ProcessHostSalaryDailySettlementBatch(ctx, walletCronBatchRequest(req))
|
|
||||||
return walletCronBatchResult(resp), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WalletCronClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
|
|
||||||
if !isHostSalaryHalfMonthDue(walletCronNowUTC()) {
|
|
||||||
// 半月结是否适用于某个主播由后台工资政策 settlement_mode 决定;cron 这里只负责在月中打开一次全局触发窗口。
|
|
||||||
return scheduler.BatchResult{}, nil
|
|
||||||
}
|
|
||||||
resp, err := c.client.ProcessHostSalaryHalfMonthSettlementBatch(ctx, walletCronBatchRequest(req))
|
|
||||||
return walletCronBatchResult(resp), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WalletCronClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
|
|
||||||
if !isHostSalaryMonthEndDue(walletCronNowUTC()) {
|
|
||||||
// 月末批次负责最终等级差额、剩余钻石折美元和周期关闭;非月末触发只记录一次空跑,不进入 wallet 账务。
|
|
||||||
return scheduler.BatchResult{}, nil
|
|
||||||
}
|
|
||||||
resp, err := c.client.ProcessHostSalaryMonthEndBatch(ctx, walletCronBatchRequest(req))
|
|
||||||
return walletCronBatchResult(resp), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHostSalaryHalfMonthDue(now time.Time) bool {
|
|
||||||
return now.UTC().Day() == 15
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHostSalaryMonthEndDue(now time.Time) bool {
|
|
||||||
utc := now.UTC()
|
|
||||||
return utc.AddDate(0, 0, 1).Day() == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func walletCronBatchRequest(req scheduler.BatchRequest) *walletv1.CronBatchRequest {
|
|
||||||
// scheduler 的 RunID 同时作为请求幂等标识传给 wallet;wallet 内部会继续按主播和周期生成账务幂等键。
|
|
||||||
return &walletv1.CronBatchRequest{
|
|
||||||
RequestId: req.RunID,
|
|
||||||
AppCode: req.AppCode,
|
|
||||||
RunId: req.RunID,
|
|
||||||
WorkerId: req.WorkerID,
|
|
||||||
BatchSize: int32(req.BatchSize),
|
|
||||||
LockTtlMs: req.LockTTL.Milliseconds(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func walletCronBatchResult(resp *walletv1.CronBatchResponse) scheduler.BatchResult {
|
|
||||||
if resp == nil {
|
|
||||||
return scheduler.BatchResult{}
|
|
||||||
}
|
|
||||||
return scheduler.BatchResult{
|
|
||||||
ClaimedCount: int(resp.GetClaimedCount()),
|
|
||||||
ProcessedCount: int(resp.GetProcessedCount()),
|
|
||||||
SuccessCount: int(resp.GetSuccessCount()),
|
|
||||||
FailureCount: int(resp.GetFailureCount()),
|
|
||||||
HasMore: resp.GetHasMore(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,8 +23,8 @@ rocketmq:
|
|||||||
producer_group: "hyapp-game-outbox-producer"
|
producer_group: "hyapp-game-outbox-producer"
|
||||||
outbox_worker:
|
outbox_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "60s"
|
poll_interval: "1s"
|
||||||
batch_size: 10
|
batch_size: 100
|
||||||
publish_timeout: "3s"
|
publish_timeout: "3s"
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
@ -67,7 +67,6 @@ CREATE TABLE IF NOT EXISTS game_launch_sessions (
|
|||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID',
|
display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID',
|
||||||
room_id VARCHAR(96) NOT NULL DEFAULT '' 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 '场景',
|
scene VARCHAR(64) NOT NULL COMMENT '场景',
|
||||||
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码',
|
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码',
|
||||||
game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID',
|
game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID',
|
||||||
@ -92,7 +91,6 @@ CREATE TABLE IF NOT EXISTS game_orders (
|
|||||||
provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID',
|
provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
room_id VARCHAR(96) NOT NULL DEFAULT '' 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类型',
|
op_type VARCHAR(32) NOT NULL COMMENT 'op类型',
|
||||||
coin_amount BIGINT NOT NULL COMMENT '金币数量',
|
coin_amount BIGINT NOT NULL COMMENT '金币数量',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||||
@ -167,8 +165,6 @@ CREATE TABLE IF NOT EXISTS game_level_event_outbox (
|
|||||||
PRIMARY KEY(app_code, event_id),
|
PRIMARY KEY(app_code, event_id),
|
||||||
UNIQUE KEY uk_game_level_event_order(app_code, order_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_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)
|
KEY idx_game_level_event_user(app_code, user_id, created_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏等级事件 outbox 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏等级事件 outbox 表';
|
||||||
|
|
||||||
@ -184,17 +180,10 @@ CREATE TABLE IF NOT EXISTS game_outbox (
|
|||||||
coin_amount BIGINT NOT NULL COMMENT '金币数量',
|
coin_amount BIGINT NOT NULL COMMENT '金币数量',
|
||||||
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
|
payload_json JSON NOT NULL COMMENT '业务负载 JSON 快照',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
PRIMARY KEY(app_code, event_id),
|
||||||
KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id),
|
KEY idx_game_outbox_status_created(app_code, status, created_at_ms),
|
||||||
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)
|
KEY idx_game_outbox_order(app_code, order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表';
|
||||||
|
|
||||||
|
|||||||
@ -184,8 +184,7 @@ func (a *App) runOutboxWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) processOutboxBatch() (int, error) {
|
func (a *App) processOutboxBatch() (int, error) {
|
||||||
workerID := "game-outbox-" + a.cfg.NodeID
|
records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, a.cfg.OutboxWorker.BatchSize)
|
||||||
records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, workerID, a.cfg.OutboxWorker.BatchSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@ -194,8 +193,7 @@ func (a *App) processOutboxBatch() (int, error) {
|
|||||||
err := a.publishGameOutboxRecord(publishCtx, record)
|
err := a.publishGameOutboxRecord(publishCtx, record)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli()
|
if markErr := a.repo.MarkGameOutboxPending(context.Background(), record.AppCode, record.EventID); markErr != nil {
|
||||||
if markErr := a.repo.MarkGameOutboxRetryable(context.Background(), record.AppCode, record.EventID, err.Error(), nextRetryAtMS); markErr != nil {
|
|
||||||
return 0, markErr
|
return 0, markErr
|
||||||
}
|
}
|
||||||
return len(records), err
|
return len(records), err
|
||||||
|
|||||||
@ -112,7 +112,6 @@ type LaunchSession struct {
|
|||||||
// DisplayUserID 用于对外 uid_mode,避免直接暴露内部数字 ID。
|
// DisplayUserID 用于对外 uid_mode,避免直接暴露内部数字 ID。
|
||||||
DisplayUserID string
|
DisplayUserID string
|
||||||
RoomID string
|
RoomID string
|
||||||
RegionID int64
|
|
||||||
Scene string
|
Scene string
|
||||||
PlatformCode string
|
PlatformCode string
|
||||||
GameID string
|
GameID string
|
||||||
@ -138,7 +137,6 @@ type GameOrder struct {
|
|||||||
ProviderGameID string
|
ProviderGameID string
|
||||||
UserID int64
|
UserID int64
|
||||||
RoomID string
|
RoomID string
|
||||||
RegionID int64
|
|
||||||
// OpType 只允许钱包语义 debit/credit,厂商枚举在适配器层转换。
|
// OpType 只允许钱包语义 debit/credit,厂商枚举在适配器层转换。
|
||||||
OpType string
|
OpType string
|
||||||
CoinAmount int64
|
CoinAmount int64
|
||||||
|
|||||||
@ -341,13 +341,6 @@ func (s *Service) validBaishunSession(ctx context.Context, app string, token str
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
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 {
|
if session, err := s.validYomiSession(ctx, app, token); err == nil {
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,7 +107,6 @@ type LaunchCommand struct {
|
|||||||
GameID string
|
GameID string
|
||||||
Scene string
|
Scene string
|
||||||
RoomID string
|
RoomID string
|
||||||
RegionID int64
|
|
||||||
AccessToken string
|
AccessToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,14 +188,6 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
|||||||
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
|
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
|
||||||
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
|
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()
|
now := s.now()
|
||||||
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
||||||
@ -225,7 +216,6 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
|||||||
UserID: command.UserID,
|
UserID: command.UserID,
|
||||||
DisplayUserID: command.DisplayUserID,
|
DisplayUserID: command.DisplayUserID,
|
||||||
RoomID: strings.TrimSpace(command.RoomID),
|
RoomID: strings.TrimSpace(command.RoomID),
|
||||||
RegionID: regionID,
|
|
||||||
Scene: command.Scene,
|
Scene: command.Scene,
|
||||||
PlatformCode: game.PlatformCode,
|
PlatformCode: game.PlatformCode,
|
||||||
GameID: game.GameID,
|
GameID: game.GameID,
|
||||||
@ -419,25 +409,6 @@ func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *game
|
|||||||
}, nil
|
}, 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) {
|
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
|
||||||
if s.wallet == nil {
|
if s.wallet == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||||
@ -468,10 +439,6 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
|
|||||||
}
|
}
|
||||||
body.ProviderGameID = game.ProviderGameID
|
body.ProviderGameID = game.ProviderGameID
|
||||||
}
|
}
|
||||||
regionID, err := s.resolveUserRegion(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
order := gamedomain.GameOrder{
|
order := gamedomain.GameOrder{
|
||||||
AppCode: app,
|
AppCode: app,
|
||||||
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
|
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
|
||||||
@ -482,7 +449,6 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
|
|||||||
ProviderGameID: body.ProviderGameID,
|
ProviderGameID: body.ProviderGameID,
|
||||||
UserID: body.UserID,
|
UserID: body.UserID,
|
||||||
RoomID: strings.TrimSpace(body.RoomID),
|
RoomID: strings.TrimSpace(body.RoomID),
|
||||||
RegionID: regionID,
|
|
||||||
OpType: body.OpType,
|
OpType: body.OpType,
|
||||||
CoinAmount: body.CoinAmount,
|
CoinAmount: body.CoinAmount,
|
||||||
Status: gamedomain.OrderStatusWalletApplying,
|
Status: gamedomain.OrderStatusWalletApplying,
|
||||||
|
|||||||
@ -57,7 +57,7 @@ func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) {
|
|||||||
if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") {
|
if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") {
|
||||||
t.Fatalf("launch result mismatch: %+v", result)
|
t.Fatalf("launch result mismatch: %+v", result)
|
||||||
}
|
}
|
||||||
if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.RegionID != 100 || repo.session.LaunchTokenHash == "" {
|
if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.LaunchTokenHash == "" {
|
||||||
t.Fatalf("launch session was not persisted: %+v", repo.session)
|
t.Fatalf("launch session was not persisted: %+v", repo.session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1001,54 +1001,6 @@ 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) {
|
func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
|
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
|
||||||
|
|||||||
@ -308,7 +308,6 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
|
|||||||
ProviderGameID: session.ProviderGameID,
|
ProviderGameID: session.ProviderGameID,
|
||||||
UserID: session.UserID,
|
UserID: session.UserID,
|
||||||
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
|
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
|
||||||
RegionID: session.RegionID,
|
|
||||||
OpType: opType,
|
OpType: opType,
|
||||||
CoinAmount: amount,
|
CoinAmount: amount,
|
||||||
Status: gamedomain.OrderStatusWalletApplying,
|
Status: gamedomain.OrderStatusWalletApplying,
|
||||||
|
|||||||
@ -118,7 +118,6 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
display_user_id VARCHAR(32) NOT NULL,
|
display_user_id VARCHAR(32) NOT NULL,
|
||||||
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
region_id BIGINT NOT NULL DEFAULT 0,
|
|
||||||
scene VARCHAR(64) NOT NULL,
|
scene VARCHAR(64) NOT NULL,
|
||||||
platform_code VARCHAR(64) NOT NULL,
|
platform_code VARCHAR(64) NOT NULL,
|
||||||
game_id VARCHAR(96) NOT NULL,
|
game_id VARCHAR(96) NOT NULL,
|
||||||
@ -142,7 +141,6 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
provider_game_id VARCHAR(128) NOT NULL,
|
provider_game_id VARCHAR(128) NOT NULL,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
region_id BIGINT NOT NULL DEFAULT 0,
|
|
||||||
op_type VARCHAR(32) NOT NULL,
|
op_type VARCHAR(32) NOT NULL,
|
||||||
coin_amount BIGINT NOT NULL,
|
coin_amount BIGINT NOT NULL,
|
||||||
status VARCHAR(32) NOT NULL,
|
status VARCHAR(32) NOT NULL,
|
||||||
@ -214,8 +212,6 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
PRIMARY KEY(app_code, event_id),
|
PRIMARY KEY(app_code, event_id),
|
||||||
UNIQUE KEY uk_game_level_event_order(app_code, order_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_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)
|
KEY idx_game_level_event_user(app_code, user_id, created_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS game_outbox (
|
`CREATE TABLE IF NOT EXISTS game_outbox (
|
||||||
@ -230,17 +226,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
coin_amount BIGINT NOT NULL,
|
coin_amount BIGINT NOT NULL,
|
||||||
payload_json JSON NOT NULL,
|
payload_json JSON NOT NULL,
|
||||||
status VARCHAR(32) 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,
|
created_at_ms BIGINT NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY(app_code, event_id),
|
PRIMARY KEY(app_code, event_id),
|
||||||
KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id),
|
KEY idx_game_outbox_status_created(app_code, status, created_at_ms),
|
||||||
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)
|
KEY idx_game_outbox_order(app_code, order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
}
|
}
|
||||||
@ -252,39 +241,6 @@ 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 {
|
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
|
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)
|
return r.seedDefaults(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,53 +261,6 @@ func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnN
|
|||||||
return err
|
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 {
|
func (r *Repository) seedDefaults(ctx context.Context) error {
|
||||||
if _, err := r.db.ExecContext(ctx,
|
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)
|
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
|
||||||
@ -471,11 +380,11 @@ func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCo
|
|||||||
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
|
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`INSERT INTO game_launch_sessions (
|
`INSERT INTO game_launch_sessions (
|
||||||
app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
|
app_code, session_id, user_id, display_user_id, room_id, scene, platform_code, game_id,
|
||||||
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
|
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID,
|
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.Scene,
|
||||||
session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
|
session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
|
||||||
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
|
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@ -483,14 +392,14 @@ func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain
|
|||||||
|
|
||||||
func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
|
func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
|
||||||
row := r.db.QueryRowContext(ctx,
|
row := r.db.QueryRowContext(ctx,
|
||||||
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
|
`SELECT app_code, session_id, user_id, display_user_id, room_id, scene, platform_code, game_id,
|
||||||
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
|
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
|
||||||
FROM game_launch_sessions
|
FROM game_launch_sessions
|
||||||
WHERE app_code = ? AND launch_token_hash = ?`,
|
WHERE app_code = ? AND launch_token_hash = ?`,
|
||||||
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
|
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
|
||||||
)
|
)
|
||||||
var item gamedomain.LaunchSession
|
var item gamedomain.LaunchSession
|
||||||
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
|
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
|
||||||
}
|
}
|
||||||
@ -545,10 +454,10 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
|
|||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO game_orders (
|
`INSERT INTO game_orders (
|
||||||
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
||||||
user_id, room_id, region_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
|
user_id, room_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
|
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
|
||||||
order.UserID, order.RoomID, order.RegionID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
|
order.UserID, order.RoomID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return gamedomain.GameOrder{}, false, err
|
return gamedomain.GameOrder{}, false, err
|
||||||
}
|
}
|
||||||
@ -561,7 +470,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) {
|
func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
|
||||||
row := tx.QueryRowContext(ctx,
|
row := tx.QueryRowContext(ctx,
|
||||||
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
||||||
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
|
user_id, room_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
|
||||||
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
|
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
|
||||||
FROM game_orders
|
FROM game_orders
|
||||||
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
|
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
|
||||||
@ -569,7 +478,7 @@ func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platfo
|
|||||||
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
|
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
|
||||||
)
|
)
|
||||||
var order gamedomain.GameOrder
|
var order gamedomain.GameOrder
|
||||||
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
|
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return gamedomain.GameOrder{}, false, nil
|
return gamedomain.GameOrder{}, false, nil
|
||||||
}
|
}
|
||||||
@ -667,7 +576,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) {
|
func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
|
||||||
row := tx.QueryRowContext(ctx,
|
row := tx.QueryRowContext(ctx,
|
||||||
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
||||||
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
|
user_id, room_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
|
||||||
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
|
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
|
||||||
FROM game_orders
|
FROM game_orders
|
||||||
WHERE app_code = ? AND order_id = ?
|
WHERE app_code = ? AND order_id = ?
|
||||||
@ -675,7 +584,7 @@ func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, or
|
|||||||
appcode.Normalize(appCode), strings.TrimSpace(orderID),
|
appcode.Normalize(appCode), strings.TrimSpace(orderID),
|
||||||
)
|
)
|
||||||
var order gamedomain.GameOrder
|
var order gamedomain.GameOrder
|
||||||
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
|
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return gamedomain.GameOrder{}, false, nil
|
return gamedomain.GameOrder{}, false, nil
|
||||||
}
|
}
|
||||||
@ -723,8 +632,6 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
|
|||||||
'provider_round_id', ?,
|
'provider_round_id', ?,
|
||||||
'user_id', ?,
|
'user_id', ?,
|
||||||
'room_id', ?,
|
'room_id', ?,
|
||||||
'visible_region_id', ?,
|
|
||||||
'region_id', ?,
|
|
||||||
'op_type', ?,
|
'op_type', ?,
|
||||||
'coin_amount', ?,
|
'coin_amount', ?,
|
||||||
'wallet_transaction_id', ?,
|
'wallet_transaction_id', ?,
|
||||||
@ -732,7 +639,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
|
|||||||
), 'pending', ?, ?)`,
|
), 'pending', ?, ?)`,
|
||||||
order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
|
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.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
|
||||||
order.UserID, order.RoomID, order.RegionID, order.RegionID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
|
order.UserID, order.RoomID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
|
||||||
nowMs, nowMs,
|
nowMs, nowMs,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@ -751,138 +658,75 @@ type GameOutboxRecord struct {
|
|||||||
CoinAmount int64
|
CoinAmount int64
|
||||||
PayloadJSON string
|
PayloadJSON string
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
RetryCount int
|
|
||||||
LastError string
|
|
||||||
WorkerID string
|
|
||||||
LockUntilMS int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimPendingGameOutbox locks pending game facts so one worker publishes them.
|
// ClaimPendingGameOutbox locks pending game facts so one worker publishes them.
|
||||||
func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string, batchSize int) ([]GameOutboxRecord, error) {
|
func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, batchSize int) ([]GameOutboxRecord, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
if batchSize <= 0 {
|
if batchSize <= 0 {
|
||||||
batchSize = 100
|
batchSize = 100
|
||||||
}
|
}
|
||||||
workerID = strings.TrimSpace(workerID)
|
|
||||||
if workerID == "" {
|
|
||||||
workerID = "game-outbox-worker"
|
|
||||||
}
|
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
nowMS := time.Now().UTC().UnixMilli()
|
rows, err := tx.QueryContext(ctx, `
|
||||||
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
|
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
|
||||||
appCode := appcode.FromContext(ctx)
|
coin_amount, CAST(payload_json AS CHAR), created_at_ms
|
||||||
|
FROM game_outbox
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
|
LIMIT ? FOR UPDATE SKIP LOCKED
|
||||||
|
`, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
records := make([]GameOutboxRecord, 0, batchSize)
|
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 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
|
|
||||||
`,
|
|
||||||
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},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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, branchRecords...)
|
|
||||||
}
|
|
||||||
for _, record := range records {
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
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() {
|
for rows.Next() {
|
||||||
var record GameOutboxRecord
|
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 {
|
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()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
}
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
|
func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE game_outbox
|
UPDATE game_outbox SET status = 'delivered', updated_at_ms = ?
|
||||||
SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
|
|
||||||
WHERE app_code = ? AND event_id = ?
|
WHERE app_code = ? AND event_id = ?
|
||||||
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
|
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) MarkGameOutboxRetryable(ctx context.Context, appCode string, eventID string, lastErr string, nextRetryAtMS int64) error {
|
func (r *Repository) MarkGameOutboxPending(ctx context.Context, appCode string, eventID string) error {
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE game_outbox
|
UPDATE game_outbox SET status = 'pending', updated_at_ms = ?
|
||||||
SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
|
|
||||||
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
|
|
||||||
WHERE app_code = ? AND event_id = ? AND status = 'running'
|
WHERE app_code = ? AND event_id = ? AND status = 'running'
|
||||||
`, nextRetryAtMS, truncate(lastErr, 512), time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
|
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -895,29 +739,21 @@ func (r *Repository) ClaimPendingLevelEvents(ctx context.Context, workerID strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
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 <= ?
|
WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ?
|
||||||
AND locked_until_ms = 0
|
AND (locked_until_ms = 0 OR locked_until_ms <= ?)
|
||||||
ORDER BY created_at_ms ASC, event_id ASC
|
ORDER BY created_at_ms ASC, event_id ASC
|
||||||
LIMIT ? FOR UPDATE SKIP LOCKED`,
|
LIMIT ? FOR UPDATE SKIP LOCKED`,
|
||||||
appCode, nowMS, batchSize)
|
appcode.FromContext(ctx), nowMS, nowMS, batchSize,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if remaining := batchSize - len(events); remaining > 0 {
|
events, err := scanLevelEventOutboxRows(rows)
|
||||||
retryEvents, err := r.queryPendingLevelEvents(ctx, tx, `
|
_ = rows.Close()
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
events = append(events, retryEvents...)
|
|
||||||
}
|
|
||||||
lockUntil := nowMS + lockTTL.Milliseconds()
|
lockUntil := nowMS + lockTTL.Milliseconds()
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
@ -935,15 +771,6 @@ func (r *Repository) ClaimPendingLevelEvents(ctx context.Context, workerID strin
|
|||||||
return events, nil
|
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 {
|
func (r *Repository) MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -1215,7 +1042,7 @@ func levelEventOutboxSelectSQL() string {
|
|||||||
provider_round_id, coin_amount, wallet_transaction_id,
|
provider_round_id, coin_amount, wallet_transaction_id,
|
||||||
COALESCE(CAST(payload_json AS CHAR), '{}'), status, attempt_count,
|
COALESCE(CAST(payload_json AS CHAR), '{}'), status, attempt_count,
|
||||||
failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
|
failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
|
||||||
FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim) `
|
FROM game_level_event_outbox `
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
|
func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
|
||||||
|
|||||||
@ -1753,49 +1753,6 @@ 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) {
|
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
|
||||||
previousNow := roomapi.TimeNow
|
previousNow := roomapi.TimeNow
|
||||||
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||||
@ -2903,8 +2860,6 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
|||||||
OwnerUserId: 901,
|
OwnerUserId: 901,
|
||||||
RegionId: 30,
|
RegionId: 30,
|
||||||
Name: "Admin Seed Agency",
|
Name: "Admin Seed Agency",
|
||||||
Avatar: "https://cdn.example/agency.png",
|
|
||||||
HostCount: 3,
|
|
||||||
Status: "active",
|
Status: "active",
|
||||||
JoinEnabled: true,
|
JoinEnabled: true,
|
||||||
MaxHosts: 100,
|
MaxHosts: 100,
|
||||||
@ -2915,7 +2870,7 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
|||||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
handler.SetUserHostClient(hostClient)
|
handler.SetUserHostClient(hostClient)
|
||||||
router := handler.Routes(auth.NewVerifier("secret"))
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=150", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=99", nil)
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
request.Header.Set("X-Request-ID", "req-host-agency-search")
|
request.Header.Set("X-Request-ID", "req-host-agency-search")
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@ -2925,7 +2880,7 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
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() != 100 {
|
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 20 {
|
||||||
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
|
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
|
||||||
}
|
}
|
||||||
var response httpkit.ResponseEnvelope
|
var response httpkit.ResponseEnvelope
|
||||||
@ -2935,31 +2890,11 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
|||||||
data := response.Data.(map[string]any)
|
data := response.Data.(map[string]any)
|
||||||
items := data["items"].([]any)
|
items := data["items"].([]any)
|
||||||
first := items[0].(map[string]any)
|
first := items[0].(map[string]any)
|
||||||
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) {
|
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || data["page_size"] != float64(20) {
|
||||||
t.Fatalf("host agency search response mismatch: %+v", response)
|
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) {
|
func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
||||||
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
|
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
|
||||||
{
|
{
|
||||||
|
|||||||
@ -16,7 +16,6 @@ type Handler struct {
|
|||||||
roomQueryClient client.RoomQueryClient
|
roomQueryClient client.RoomQueryClient
|
||||||
userProfileClient client.UserProfileClient
|
userProfileClient client.UserProfileClient
|
||||||
userSocialClient client.UserSocialClient
|
userSocialClient client.UserSocialClient
|
||||||
userHostClient client.UserHostClient
|
|
||||||
walletClient client.WalletClient
|
walletClient client.WalletClient
|
||||||
rtcTokenConfig tencentrtc.TokenConfig
|
rtcTokenConfig tencentrtc.TokenConfig
|
||||||
}
|
}
|
||||||
@ -27,7 +26,6 @@ type Config struct {
|
|||||||
RoomQueryClient client.RoomQueryClient
|
RoomQueryClient client.RoomQueryClient
|
||||||
UserProfileClient client.UserProfileClient
|
UserProfileClient client.UserProfileClient
|
||||||
UserSocialClient client.UserSocialClient
|
UserSocialClient client.UserSocialClient
|
||||||
UserHostClient client.UserHostClient
|
|
||||||
WalletClient client.WalletClient
|
WalletClient client.WalletClient
|
||||||
RTCTokenConfig tencentrtc.TokenConfig
|
RTCTokenConfig tencentrtc.TokenConfig
|
||||||
}
|
}
|
||||||
@ -39,7 +37,6 @@ func New(config Config) *Handler {
|
|||||||
roomQueryClient: config.RoomQueryClient,
|
roomQueryClient: config.RoomQueryClient,
|
||||||
userProfileClient: config.UserProfileClient,
|
userProfileClient: config.UserProfileClient,
|
||||||
userSocialClient: config.UserSocialClient,
|
userSocialClient: config.UserSocialClient,
|
||||||
userHostClient: config.UserHostClient,
|
|
||||||
walletClient: config.WalletClient,
|
walletClient: config.WalletClient,
|
||||||
rtcTokenConfig: config.RTCTokenConfig,
|
rtcTokenConfig: config.RTCTokenConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1259,11 +1259,6 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
|||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
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{
|
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
|
||||||
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
||||||
@ -1273,36 +1268,10 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
|||||||
GiftId: body.GiftID,
|
GiftId: body.GiftID,
|
||||||
GiftCount: body.GiftCount,
|
GiftCount: body.GiftCount,
|
||||||
PoolId: body.PoolID,
|
PoolId: body.PoolID,
|
||||||
TargetIsHost: targetIsHost,
|
|
||||||
TargetHostRegionId: targetHostRegionID,
|
|
||||||
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
|
|
||||||
})
|
})
|
||||||
httpkit.Write(writer, request, resp, err)
|
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 返回房间和公屏专用展示资料。
|
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
|
||||||
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userProfileClient == nil {
|
if h.userProfileClient == nil {
|
||||||
|
|||||||
@ -27,7 +27,6 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
RoomQueryClient: h.roomQueryClient,
|
RoomQueryClient: h.roomQueryClient,
|
||||||
UserProfileClient: h.userProfileClient,
|
UserProfileClient: h.userProfileClient,
|
||||||
UserSocialClient: h.userSocialClient,
|
UserSocialClient: h.userSocialClient,
|
||||||
UserHostClient: h.userHostClient,
|
|
||||||
WalletClient: h.walletClient,
|
WalletClient: h.walletClient,
|
||||||
RTCTokenConfig: tencentrtc.TokenConfig{
|
RTCTokenConfig: tencentrtc.TokenConfig{
|
||||||
Enabled: h.tencentRTC.Enabled,
|
Enabled: h.tencentRTC.Enabled,
|
||||||
|
|||||||
@ -17,8 +17,6 @@ type hostAgencyData struct {
|
|||||||
RegionID int64 `json:"region_id"`
|
RegionID int64 `json:"region_id"`
|
||||||
ParentBDUserID string `json:"parent_bd_user_id,omitempty"`
|
ParentBDUserID string `json:"parent_bd_user_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar string `json:"avatar,omitempty"`
|
|
||||||
HostCount int32 `json:"host_count"`
|
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
JoinEnabled bool `json:"join_enabled"`
|
JoinEnabled bool `json:"join_enabled"`
|
||||||
MaxHosts int32 `json:"max_hosts"`
|
MaxHosts int32 `json:"max_hosts"`
|
||||||
@ -50,13 +48,17 @@ func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.R
|
|||||||
if keyword == "" {
|
if keyword == "" {
|
||||||
keyword = strings.TrimSpace(request.URL.Query().Get("keyword"))
|
keyword = strings.TrimSpace(request.URL.Query().Get("keyword"))
|
||||||
}
|
}
|
||||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 100)
|
if keyword == "" {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||||||
if !ok {
|
if !ok {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if pageSize > 100 {
|
if pageSize > 20 {
|
||||||
pageSize = 100
|
pageSize = 20
|
||||||
}
|
}
|
||||||
resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{
|
resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{
|
||||||
Meta: httpkit.UserMeta(request, ""),
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
@ -121,8 +123,6 @@ func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData {
|
|||||||
RegionID: agency.GetRegionId(),
|
RegionID: agency.GetRegionId(),
|
||||||
ParentBDUserID: int64String(agency.GetParentBdUserId()),
|
ParentBDUserID: int64String(agency.GetParentBdUserId()),
|
||||||
Name: agency.GetName(),
|
Name: agency.GetName(),
|
||||||
Avatar: agency.GetAvatar(),
|
|
||||||
HostCount: agency.GetHostCount(),
|
|
||||||
Status: agency.GetStatus(),
|
Status: agency.GetStatus(),
|
||||||
JoinEnabled: agency.GetJoinEnabled(),
|
JoinEnabled: agency.GetJoinEnabled(),
|
||||||
MaxHosts: agency.GetMaxHosts(),
|
MaxHosts: agency.GetMaxHosts(),
|
||||||
|
|||||||
@ -28,7 +28,7 @@ wallet_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
room_notice_worker:
|
room_notice_worker:
|
||||||
enabled: false
|
enabled: true
|
||||||
poll_interval: 1s
|
poll_interval: 1s
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
@ -38,18 +38,13 @@ room_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
rocketmq:
|
rocketmq:
|
||||||
# Docker 本地默认关闭;接入外部 RocketMQ 后只消费 owner outbox 私有通知事实。
|
# Docker 本地默认关闭;接入外部 RocketMQ 后可消费 room_outbox 私有通知事实。
|
||||||
enabled: false
|
enabled: false
|
||||||
name_servers: []
|
name_servers: []
|
||||||
name_server_domain: ""
|
name_server_domain: ""
|
||||||
access_key: ""
|
access_key: ""
|
||||||
secret_key: ""
|
secret_key: ""
|
||||||
namespace: ""
|
namespace: ""
|
||||||
wallet_outbox:
|
|
||||||
enabled: false
|
|
||||||
topic: "hyapp_wallet_outbox"
|
|
||||||
consumer_group: "hyapp-notice-wallet-outbox"
|
|
||||||
consumer_max_reconsume_times: 16
|
|
||||||
room_outbox:
|
room_outbox:
|
||||||
enabled: false
|
enabled: false
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
|
|||||||
@ -18,8 +18,8 @@ tencent_im:
|
|||||||
request_timeout: 5s
|
request_timeout: 5s
|
||||||
|
|
||||||
wallet_notice_worker:
|
wallet_notice_worker:
|
||||||
enabled: false
|
enabled: true
|
||||||
poll_interval: 1s
|
poll_interval: 500ms
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
publish_timeout: 3s
|
publish_timeout: 3s
|
||||||
@ -28,8 +28,8 @@ wallet_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
room_notice_worker:
|
room_notice_worker:
|
||||||
enabled: false
|
enabled: true
|
||||||
poll_interval: 1s
|
poll_interval: 500ms
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
publish_timeout: 3s
|
publish_timeout: 3s
|
||||||
@ -45,11 +45,6 @@ rocketmq:
|
|||||||
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
||||||
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
||||||
namespace: "hyapp-prod"
|
namespace: "hyapp-prod"
|
||||||
wallet_outbox:
|
|
||||||
enabled: true
|
|
||||||
topic: "hyapp_wallet_outbox"
|
|
||||||
consumer_group: "hyapp-notice-wallet-outbox"
|
|
||||||
consumer_max_reconsume_times: 16
|
|
||||||
room_outbox:
|
room_outbox:
|
||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
|
|||||||
@ -28,7 +28,7 @@ wallet_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
room_notice_worker:
|
room_notice_worker:
|
||||||
enabled: false
|
enabled: true
|
||||||
poll_interval: 1s
|
poll_interval: 1s
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
lock_ttl: 30s
|
lock_ttl: 30s
|
||||||
@ -38,18 +38,13 @@ room_notice_worker:
|
|||||||
max_backoff: 5m
|
max_backoff: 5m
|
||||||
|
|
||||||
rocketmq:
|
rocketmq:
|
||||||
# 本地默认关闭;开启后 notice 只消费 owner outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
# 本地默认关闭;开启后 notice 直接消费 room_outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
||||||
enabled: false
|
enabled: false
|
||||||
name_servers: []
|
name_servers: []
|
||||||
name_server_domain: ""
|
name_server_domain: ""
|
||||||
access_key: ""
|
access_key: ""
|
||||||
secret_key: ""
|
secret_key: ""
|
||||||
namespace: ""
|
namespace: ""
|
||||||
wallet_outbox:
|
|
||||||
enabled: false
|
|
||||||
topic: "hyapp_wallet_outbox"
|
|
||||||
consumer_group: "hyapp-notice-wallet-outbox"
|
|
||||||
consumer_max_reconsume_times: 16
|
|
||||||
room_outbox:
|
room_outbox:
|
||||||
enabled: false
|
enabled: false
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
|
|||||||
@ -26,9 +26,7 @@ CREATE TABLE IF NOT EXISTS notice_delivery_events (
|
|||||||
created_at_ms BIGINT NOT NULL COMMENT '通知位点创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '通知位点创建时间,UTC epoch ms',
|
||||||
updated_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),
|
PRIMARY KEY (source_name, app_code, source_event_id, channel),
|
||||||
KEY idx_notice_delivery_retry (source_name, app_code, channel, status, next_retry_at_ms, updated_at_ms),
|
KEY idx_notice_delivery_pending (status, next_retry_at_ms, lock_until_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)
|
KEY idx_notice_delivery_target (app_code, target_user_id, updated_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='通知服务外部通知投递位点和死信表';
|
COMMENT='通知服务外部通知投递位点和死信表';
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import (
|
|||||||
"hyapp/pkg/rocketmqx"
|
"hyapp/pkg/rocketmqx"
|
||||||
"hyapp/pkg/roommq"
|
"hyapp/pkg/roommq"
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/pkg/walletmq"
|
|
||||||
"hyapp/services/notice-service/internal/config"
|
"hyapp/services/notice-service/internal/config"
|
||||||
"hyapp/services/notice-service/internal/modules/roomnotice"
|
"hyapp/services/notice-service/internal/modules/roomnotice"
|
||||||
"hyapp/services/notice-service/internal/modules/walletnotice"
|
"hyapp/services/notice-service/internal/modules/walletnotice"
|
||||||
@ -93,11 +92,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
return nil, errors.New("room notice worker requires tencent_im.enabled")
|
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 {
|
if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
@ -120,33 +114,10 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
|
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
|
||||||
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
|
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
|
||||||
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
||||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 2)
|
mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
|
||||||
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 {
|
if cfg.RocketMQ.RoomOutbox.Enabled {
|
||||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes))
|
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -161,7 +132,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
mqConsumers = append(mqConsumers, consumer)
|
||||||
@ -297,13 +267,7 @@ func (a *App) shutdownMQ() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
|
func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||||
for _, consumer := range consumers {
|
|
||||||
_ = consumer.Shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig {
|
|
||||||
return rocketmqx.ConsumerConfig{
|
return rocketmqx.ConsumerConfig{
|
||||||
EndpointConfig: rocketmqx.EndpointConfig{
|
EndpointConfig: rocketmqx.EndpointConfig{
|
||||||
NameServers: cfg.NameServers,
|
NameServers: cfg.NameServers,
|
||||||
@ -314,8 +278,8 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsum
|
|||||||
Namespace: cfg.Namespace,
|
Namespace: cfg.Namespace,
|
||||||
VIPChannel: cfg.VIPChannel,
|
VIPChannel: cfg.VIPChannel,
|
||||||
},
|
},
|
||||||
GroupName: group,
|
GroupName: cfg.RoomOutbox.ConsumerGroup,
|
||||||
MaxReconsumeTimes: maxReconsumeTimes,
|
MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes,
|
||||||
ConsumeRetryDelay: time.Second,
|
ConsumeRetryDelay: time.Second,
|
||||||
ConsumePullBatch: 32,
|
ConsumePullBatch: 32,
|
||||||
ConsumePullTimeout: 15 * time.Minute,
|
ConsumePullTimeout: 15 * time.Minute,
|
||||||
|
|||||||
@ -21,9 +21,9 @@ type Config struct {
|
|||||||
HealthHTTPAddr string `yaml:"health_http_addr"`
|
HealthHTTPAddr string `yaml:"health_http_addr"`
|
||||||
// MySQLDSN 是 notice-service 自己的投递位点和死信状态库。
|
// MySQLDSN 是 notice-service 自己的投递位点和死信状态库。
|
||||||
MySQLDSN string `yaml:"mysql_dsn"`
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
// WalletDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 wallet_outbox。
|
// WalletDatabase 是 wallet_outbox 所在库名;notice 只把它当 append-only 事实源读取。
|
||||||
WalletDatabase string `yaml:"wallet_database"`
|
WalletDatabase string `yaml:"wallet_database"`
|
||||||
// RoomDatabase 仅保留给历史离线修复工具使用;运行态 notice 不再直扫 room_outbox。
|
// RoomDatabase 是 room_outbox 所在库名;notice 只读取需要私有通知的房间事实。
|
||||||
RoomDatabase string `yaml:"room_database"`
|
RoomDatabase string `yaml:"room_database"`
|
||||||
// TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。
|
// TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。
|
||||||
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
||||||
@ -31,7 +31,7 @@ type Config struct {
|
|||||||
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
|
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
|
||||||
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
|
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
|
||||||
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
||||||
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
|
// RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
|
||||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
Log logx.Config `yaml:"log"`
|
Log logx.Config `yaml:"log"`
|
||||||
@ -76,7 +76,7 @@ type WalletNoticeWorkerConfig struct {
|
|||||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
MaxBackoff time.Duration `yaml:"max_backoff"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
|
// RocketMQConfig 描述 notice-service 消费房间事实的 MQ 连接。
|
||||||
type RocketMQConfig struct {
|
type RocketMQConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
NameServers []string `yaml:"name_servers"`
|
NameServers []string `yaml:"name_servers"`
|
||||||
@ -86,7 +86,6 @@ type RocketMQConfig struct {
|
|||||||
SecurityToken string `yaml:"security_token"`
|
SecurityToken string `yaml:"security_token"`
|
||||||
Namespace string `yaml:"namespace"`
|
Namespace string `yaml:"namespace"`
|
||||||
VIPChannel bool `yaml:"vip_channel"`
|
VIPChannel bool `yaml:"vip_channel"`
|
||||||
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
|
|
||||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,12 +148,6 @@ func Default() Config {
|
|||||||
func defaultRocketMQConfig() RocketMQConfig {
|
func defaultRocketMQConfig() RocketMQConfig {
|
||||||
return RocketMQConfig{
|
return RocketMQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WalletOutbox: RoomOutboxMQConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Topic: "hyapp_wallet_outbox",
|
|
||||||
ConsumerGroup: "hyapp-notice-wallet-outbox",
|
|
||||||
ConsumerMaxReconsumeTimes: 16,
|
|
||||||
},
|
|
||||||
RoomOutbox: RoomOutboxMQConfig{
|
RoomOutbox: RoomOutboxMQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Topic: "hyapp_room_outbox",
|
Topic: "hyapp_room_outbox",
|
||||||
@ -208,12 +201,6 @@ func Load(path string) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
cfg.RocketMQ = rocketMQ
|
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) == "" {
|
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
|
||||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
cfg.TencentIM.AdminIdentifier = "administrator"
|
||||||
}
|
}
|
||||||
@ -246,16 +233,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
|||||||
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
||||||
}
|
}
|
||||||
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
|
if cfg.RoomOutbox.Enabled {
|
||||||
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
|
cfg.Enabled = true
|
||||||
}
|
}
|
||||||
if cfg.Enabled {
|
if cfg.Enabled {
|
||||||
|
|||||||
@ -7,23 +7,17 @@ func TestLoadLocalKeepsRocketMQDisabled(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load local config failed: %v", err)
|
t.Fatalf("Load local config failed: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.RocketMQ.Enabled || cfg.RocketMQ.WalletOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Enabled {
|
if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled {
|
||||||
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
|
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadTencentExampleEnablesOutboxMQConsumers(t *testing.T) {
|
func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
|
||||||
cfg, err := Load("../../configs/config.tencent.example.yaml")
|
cfg, err := Load("../../configs/config.tencent.example.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load tencent example failed: %v", err)
|
t.Fatalf("Load tencent example failed: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.WalletNoticeWorker.Enabled || cfg.RoomNoticeWorker.Enabled {
|
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" {
|
||||||
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)
|
t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
"hyapp/pkg/appcode"
|
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
@ -97,7 +96,7 @@ func TestMySQLRepositoryProcessesRealRoomKickOutbox(t *testing.T) {
|
|||||||
|
|
||||||
publisher := &fakePublisher{}
|
publisher := &fakePublisher{}
|
||||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
||||||
processed, err := service.ProcessRoomKickNotices(appcode.WithContext(ctx, appCode), RoomNoticeWorkerOptions{
|
processed, err := service.ProcessRoomKickNotices(ctx, RoomNoticeWorkerOptions{
|
||||||
WorkerID: "notice-real-room-test-worker",
|
WorkerID: "notice-real-room-test-worker",
|
||||||
BatchSize: 1,
|
BatchSize: 1,
|
||||||
LockTTL: 30 * time.Second,
|
LockTTL: 30 * time.Second,
|
||||||
@ -167,7 +166,7 @@ func ensureRoomOutboxTable(ctx context.Context, db *sql.DB, roomDatabase string)
|
|||||||
last_error TEXT NULL,
|
last_error TEXT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, event_id),
|
PRIMARY KEY (app_code, event_id),
|
||||||
KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)
|
KEY idx_room_outbox_notice (app_code, event_type, created_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
quoteDB(roomDatabase),
|
quoteDB(roomDatabase),
|
||||||
))
|
))
|
||||||
|
|||||||
@ -103,72 +103,35 @@ type roomKickCandidate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) {
|
func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) {
|
||||||
appCode := appcode.FromContext(ctx)
|
query := fmt.Sprintf(`
|
||||||
candidates := make([]roomKickCandidate, 0, limit)
|
|
||||||
branches := []struct {
|
|
||||||
query string
|
|
||||||
args []any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
query: fmt.Sprintf(`
|
|
||||||
SELECT ro.app_code, ro.event_id
|
SELECT ro.app_code, ro.event_id
|
||||||
FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
|
FROM %s ro
|
||||||
LEFT JOIN notice_delivery_events nde
|
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 = ?
|
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 = ?
|
WHERE ro.event_type = ?
|
||||||
AND nde.source_event_id IS NULL
|
AND (
|
||||||
|
nde.source_event_id IS NULL
|
||||||
|
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
|
||||||
|
OR (nde.status = ? AND nde.lock_until_ms <= ?)
|
||||||
|
)
|
||||||
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
|
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
|
||||||
LIMIT ?`, r.roomOutboxTable),
|
LIMIT ?`, r.roomOutboxTable)
|
||||||
args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked},
|
rows, err := r.db.QueryContext(ctx, query,
|
||||||
},
|
sourceRoomOutbox,
|
||||||
{
|
channelTencentIMC2C,
|
||||||
query: fmt.Sprintf(`
|
eventRoomUserKicked,
|
||||||
SELECT ro.app_code, ro.event_id
|
deliveryStatusRetryable,
|
||||||
FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
|
nowMs,
|
||||||
JOIN notice_delivery_events nde
|
deliveryStatusDelivering,
|
||||||
ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
|
nowMs,
|
||||||
WHERE ro.app_code = ? AND ro.event_type = ?
|
limit,
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
candidates := make([]roomKickCandidate, 0)
|
candidates := make([]roomKickCandidate, 0, limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate roomKickCandidate
|
var candidate roomKickCandidate
|
||||||
if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
|
if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
|
||||||
@ -350,7 +313,10 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
UPDATE notice_delivery_events
|
UPDATE notice_delivery_events
|
||||||
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
||||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
||||||
AND status = ? AND next_retry_at_ms <= ?`,
|
AND (
|
||||||
|
(status = ? AND next_retry_at_ms <= ?)
|
||||||
|
OR (status = ? AND lock_until_ms <= ?)
|
||||||
|
)`,
|
||||||
deliveryStatusDelivering,
|
deliveryStatusDelivering,
|
||||||
workerID,
|
workerID,
|
||||||
lockUntilMS,
|
lockUntilMS,
|
||||||
@ -362,40 +328,16 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
channelTencentIMC2C,
|
channelTencentIMC2C,
|
||||||
deliveryStatusRetryable,
|
deliveryStatusRetryable,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
deliveryStatusDelivering,
|
||||||
|
nowMs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
affected, err := result.RowsAffected()
|
affected, err := result.RowsAffected()
|
||||||
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 {
|
if err != nil || affected == 0 {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var retryCount int
|
var retryCount int
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
"hyapp/pkg/appcode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
||||||
@ -83,7 +82,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
|||||||
|
|
||||||
publisher := &fakePublisher{}
|
publisher := &fakePublisher{}
|
||||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
||||||
processed, err := service.ProcessWalletBalanceNotices(appcode.WithContext(ctx, appCode), WalletNoticeWorkerOptions{
|
processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{
|
||||||
WorkerID: "notice-real-test-worker",
|
WorkerID: "notice-real-test-worker",
|
||||||
BatchSize: 1,
|
BatchSize: 1,
|
||||||
LockTTL: 30 * time.Second,
|
LockTTL: 30 * time.Second,
|
||||||
@ -187,7 +186,7 @@ func TestMySQLRepositoryProcessesRealVipActivatedOutbox(t *testing.T) {
|
|||||||
|
|
||||||
publisher := &fakePublisher{}
|
publisher := &fakePublisher{}
|
||||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
||||||
processed, err := service.ProcessWalletBalanceNotices(appcode.WithContext(ctx, appCode), WalletNoticeWorkerOptions{
|
processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{
|
||||||
WorkerID: "notice-real-test-worker",
|
WorkerID: "notice-real-test-worker",
|
||||||
BatchSize: 1,
|
BatchSize: 1,
|
||||||
LockTTL: 30 * time.Second,
|
LockTTL: 30 * time.Second,
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/walletmq"
|
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -109,71 +108,40 @@ type walletBalanceCandidate struct {
|
|||||||
|
|
||||||
func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
|
func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
|
||||||
eventTypes := walletPrivateNoticeEventTypes()
|
eventTypes := walletPrivateNoticeEventTypes()
|
||||||
appCode := appcode.FromContext(ctx)
|
query := fmt.Sprintf(`
|
||||||
candidates := make([]walletBalanceCandidate, 0)
|
|
||||||
branches := []struct {
|
|
||||||
query string
|
|
||||||
args []any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
query: fmt.Sprintf(`
|
|
||||||
SELECT wo.app_code, wo.event_id, wo.event_type
|
SELECT wo.app_code, wo.event_id, wo.event_type
|
||||||
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
|
FROM %s wo
|
||||||
LEFT JOIN notice_delivery_events nde
|
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 = ?
|
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)
|
WHERE wo.event_type IN (%s)
|
||||||
AND nde.source_event_id IS NULL
|
AND (
|
||||||
|
nde.source_event_id IS NULL
|
||||||
|
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
|
||||||
|
OR (nde.status = ? AND nde.lock_until_ms <= ?)
|
||||||
|
)
|
||||||
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
||||||
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
|
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes)))
|
||||||
args: append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...),
|
args := []any{
|
||||||
},
|
sourceWalletOutbox,
|
||||||
{
|
channelTencentIMC2C,
|
||||||
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 _, branch := range branches {
|
for _, eventType := range eventTypes {
|
||||||
remaining := limit - len(candidates)
|
args = append(args, eventType)
|
||||||
if remaining <= 0 {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
args := append(append([]any{}, branch.args...), remaining)
|
args = append(args,
|
||||||
branchCandidates, err := queryWalletBalanceCandidates(ctx, r.db, branch.query, args...)
|
deliveryStatusRetryable,
|
||||||
if err != nil {
|
nowMs,
|
||||||
return nil, err
|
deliveryStatusDelivering,
|
||||||
}
|
nowMs,
|
||||||
candidates = append(candidates, branchCandidates...)
|
limit,
|
||||||
}
|
)
|
||||||
return candidates, nil
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
}
|
|
||||||
|
|
||||||
func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) {
|
|
||||||
rows, err := db.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
candidates := make([]walletBalanceCandidate, 0)
|
|
||||||
|
candidates := make([]walletBalanceCandidate, 0, limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate walletBalanceCandidate
|
var candidate walletBalanceCandidate
|
||||||
if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil {
|
if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil {
|
||||||
@ -214,73 +182,6 @@ func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candi
|
|||||||
return event, true, nil
|
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) {
|
func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) {
|
||||||
var event WalletBalanceEvent
|
var event WalletBalanceEvent
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
@ -340,7 +241,10 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
UPDATE notice_delivery_events
|
UPDATE notice_delivery_events
|
||||||
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
|
||||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
||||||
AND status = ? AND next_retry_at_ms <= ?`,
|
AND (
|
||||||
|
(status = ? AND next_retry_at_ms <= ?)
|
||||||
|
OR (status = ? AND lock_until_ms <= ?)
|
||||||
|
)`,
|
||||||
deliveryStatusDelivering,
|
deliveryStatusDelivering,
|
||||||
workerID,
|
workerID,
|
||||||
lockUntilMS,
|
lockUntilMS,
|
||||||
@ -352,40 +256,16 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
|
|||||||
channelTencentIMC2C,
|
channelTencentIMC2C,
|
||||||
deliveryStatusRetryable,
|
deliveryStatusRetryable,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
deliveryStatusDelivering,
|
||||||
|
nowMs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
affected, err := result.RowsAffected()
|
affected, err := result.RowsAffected()
|
||||||
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 {
|
if err != nil || affected == 0 {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var retryCount int
|
var retryCount int
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
@ -489,14 +369,6 @@ func sqlPlaceholders(count int) string {
|
|||||||
return strings.Join(parts, ",")
|
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 {
|
func isDuplicateKey(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -10,13 +10,11 @@ import (
|
|||||||
|
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/pkg/walletmq"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository 是 notice-service 需要的持久化能力。
|
// Repository 是 notice-service 需要的持久化能力。
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error)
|
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
|
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
|
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
|
MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error
|
||||||
@ -67,26 +65,6 @@ func (s *Service) ProcessWalletBalanceNotices(ctx context.Context, options Walle
|
|||||||
return processed, nil
|
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 {
|
func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions) error {
|
||||||
payload, err := walletBalanceNoticePayload(event)
|
payload, err := walletBalanceNoticePayload(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -153,15 +131,6 @@ func walletNoticeExt(eventType string) string {
|
|||||||
return "wallet_notice"
|
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) {
|
func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) {
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {
|
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/pkg/walletmq"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) {
|
func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) {
|
||||||
@ -87,34 +86,6 @@ 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) {
|
func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
events: []WalletBalanceEvent{{
|
events: []WalletBalanceEvent{{
|
||||||
@ -147,7 +118,6 @@ func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
|
|||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
events []WalletBalanceEvent
|
events []WalletBalanceEvent
|
||||||
claimedMessages []walletmq.WalletOutboxMessage
|
|
||||||
delivered []WalletBalanceEvent
|
delivered []WalletBalanceEvent
|
||||||
retryable []retryMarker
|
retryable []retryMarker
|
||||||
failed []WalletBalanceEvent
|
failed []WalletBalanceEvent
|
||||||
@ -164,23 +134,6 @@ func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int,
|
|||||||
return r.events, nil
|
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 {
|
func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ json.RawMessage, _ int64) error {
|
||||||
r.delivered = append(r.delivered, event)
|
r.delivered = append(r.delivered, event)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -143,11 +143,9 @@ CREATE TABLE IF NOT EXISTS room_outbox (
|
|||||||
last_error TEXT NULL COMMENT '最后一次失败原因',
|
last_error TEXT NULL COMMENT '最后一次失败原因',
|
||||||
updated_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),
|
PRIMARY KEY (app_code, event_id),
|
||||||
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
|
KEY idx_room_outbox_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, event_id),
|
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, event_id),
|
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms)
|
||||||
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 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间事件 outbox 表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS room_user_feed_entries (
|
CREATE TABLE IF NOT EXISTS room_user_feed_entries (
|
||||||
|
|||||||
@ -368,12 +368,6 @@ type SendGift struct {
|
|||||||
PoolID string `json:"pool_id,omitempty"`
|
PoolID string `json:"pool_id,omitempty"`
|
||||||
// GiftCount 是礼物数量,必须为正数。
|
// GiftCount 是礼物数量,必须为正数。
|
||||||
GiftCount int32 `json:"gift_count"`
|
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 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
|
||||||
BillingReceiptID string `json:"billing_receipt_id,omitempty"`
|
BillingReceiptID string `json:"billing_receipt_id,omitempty"`
|
||||||
// CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。
|
// CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。
|
||||||
@ -386,10 +380,6 @@ type SendGift struct {
|
|||||||
PriceVersion string `json:"price_version,omitempty"`
|
PriceVersion string `json:"price_version,omitempty"`
|
||||||
// GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。
|
// GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。
|
||||||
GiftTypeCode string `json:"gift_type_code,omitempty"`
|
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 表示本次送礼已经计算过宝箱状态,恢复时直接使用下列确定性快照。
|
||||||
TreasureTouched bool `json:"treasure_touched,omitempty"`
|
TreasureTouched bool `json:"treasure_touched,omitempty"`
|
||||||
// TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。
|
// TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。
|
||||||
@ -483,8 +473,6 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
|||||||
delete(values, "heat_value")
|
delete(values, "heat_value")
|
||||||
delete(values, "price_version")
|
delete(values, "price_version")
|
||||||
delete(values, "gift_type_code")
|
delete(values, "gift_type_code")
|
||||||
delete(values, "host_period_diamond_added")
|
|
||||||
delete(values, "host_period_cycle_key")
|
|
||||||
delete(values, "treasure_touched")
|
delete(values, "treasure_touched")
|
||||||
delete(values, "treasure_added_energy")
|
delete(values, "treasure_added_energy")
|
||||||
delete(values, "treasure_effective_energy")
|
delete(values, "treasure_effective_energy")
|
||||||
|
|||||||
@ -30,10 +30,6 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
GiftID: req.GetGiftId(),
|
GiftID: req.GetGiftId(),
|
||||||
PoolID: strings.TrimSpace(req.GetPoolId()),
|
PoolID: strings.TrimSpace(req.GetPoolId()),
|
||||||
GiftCount: req.GetGiftCount(),
|
GiftCount: req.GetGiftCount(),
|
||||||
TargetIsHost: req.GetTargetIsHost(),
|
|
||||||
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
|
|
||||||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
|
||||||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
|
||||||
}
|
}
|
||||||
if cmd.TargetType == "" {
|
if cmd.TargetType == "" {
|
||||||
cmd.TargetType = "user"
|
cmd.TargetType = "user"
|
||||||
@ -109,9 +105,6 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
GiftCount: cmd.GiftCount,
|
GiftCount: cmd.GiftCount,
|
||||||
AppCode: appcode.FromContext(ctx),
|
AppCode: appcode.FromContext(ctx),
|
||||||
RegionId: roomMeta.VisibleRegionID,
|
RegionId: roomMeta.VisibleRegionID,
|
||||||
TargetIsHost: cmd.TargetIsHost,
|
|
||||||
TargetHostRegionId: cmd.TargetHostRegionID,
|
|
||||||
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
|
|
||||||
})
|
})
|
||||||
walletDebitMS := elapsedMS(walletStartedAt)
|
walletDebitMS := elapsedMS(walletStartedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -128,8 +121,6 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
settledCommand.HeatValue = heatValue
|
settledCommand.HeatValue = heatValue
|
||||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||||||
settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded()
|
|
||||||
settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey()
|
|
||||||
if !luckyEnabled {
|
if !luckyEnabled {
|
||||||
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
|
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
|
||||||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||||||
@ -271,8 +262,6 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
|
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
|
||||||
"gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded),
|
"gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded),
|
||||||
"price_version": settledCommand.PriceVersion,
|
"price_version": settledCommand.PriceVersion,
|
||||||
"host_period_diamond_added": fmt.Sprintf("%d", settledCommand.HostPeriodDiamondAdded),
|
|
||||||
"host_period_cycle_key": settledCommand.HostPeriodCycleKey,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, records, nil
|
}, records, nil
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user