政策相关

This commit is contained in:
zhx 2026-05-29 19:56:14 +08:00
parent a7a9ed08ff
commit 0917fe5009
110 changed files with 11855 additions and 744 deletions

View File

@ -6510,8 +6510,14 @@ type SendGiftRequest struct {
TargetType string `protobuf:"bytes,5,opt,name=target_type,json=targetType,proto3" json:"target_type,omitempty"`
TargetUserIds []int64 `protobuf:"varint,6,rep,packed,name=target_user_ids,json=targetUserIds,proto3" json:"target_user_ids,omitempty"`
PoolId string `protobuf:"bytes,7,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// target_is_host 由 gateway 根据 user-service active host profile 注入room-service 只透传给 wallet-service。
TargetIsHost bool `protobuf:"varint,8,opt,name=target_is_host,json=targetIsHost,proto3" json:"target_is_host,omitempty"`
// target_host_region_id 是主播所属区域,后续结算按该区域匹配 Host & Agency 政策。
TargetHostRegionId int64 `protobuf:"varint,9,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"`
// target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。
TargetAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SendGiftRequest) Reset() {
@ -6593,6 +6599,27 @@ func (x *SendGiftRequest) GetPoolId() string {
return ""
}
func (x *SendGiftRequest) GetTargetIsHost() bool {
if x != nil {
return x.TargetIsHost
}
return false
}
func (x *SendGiftRequest) GetTargetHostRegionId() int64 {
if x != nil {
return x.TargetHostRegionId
}
return 0
}
func (x *SendGiftRequest) GetTargetAgencyOwnerUserId() int64 {
if x != nil {
return x.TargetAgencyOwnerUserId
}
return 0
}
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
type SendGiftResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -9345,7 +9372,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" +
"\n" +
"rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" +
"\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x81\x02\n" +
"\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x98\x03\n" +
"\x0fSendGiftRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -9355,7 +9382,11 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\vtarget_type\x18\x05 \x01(\tR\n" +
"targetType\x12&\n" +
"\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\x12\x17\n" +
"\apool_id\x18\a \x01(\tR\x06poolId\"\xfb\x02\n" +
"\apool_id\x18\a \x01(\tR\x06poolId\x12$\n" +
"\x0etarget_is_host\x18\b \x01(\bR\ftargetIsHost\x121\n" +
"\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" +
"\x1btarget_agency_owner_user_id\x18\n" +
" \x01(\x03R\x17targetAgencyOwnerUserId\"\xfb\x02\n" +
"\x10SendGiftResponse\x124\n" +
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" +
"\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" +

View File

@ -776,6 +776,12 @@ message SendGiftRequest {
string target_type = 5;
repeated int64 target_user_ids = 6;
string pool_id = 7;
// target_is_host gateway user-service active host profile room-service wallet-service
bool target_is_host = 8;
// target_host_region_id Host & Agency
int64 target_host_region_id = 9;
// target_agency_owner_user_id
int64 target_agency_owner_user_id = 10;
}
// SendGiftResponse

View File

@ -33,8 +33,10 @@ type HostProfile struct {
FirstBecameHostAtMs int64 `protobuf:"varint,7,opt,name=first_became_host_at_ms,json=firstBecameHostAtMs,proto3" json:"first_became_host_at_ms,omitempty"`
CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// current_agency_owner_user_id 是当前 Agency 的收款用户快照wallet-service 入账时固化该值,避免月底按新归属错发代理工资。
CurrentAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=current_agency_owner_user_id,json=currentAgencyOwnerUserId,proto3" json:"current_agency_owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HostProfile) Reset() {
@ -130,6 +132,13 @@ func (x *HostProfile) GetUpdatedAtMs() int64 {
return 0
}
func (x *HostProfile) GetCurrentAgencyOwnerUserId() int64 {
if x != nil {
return x.CurrentAgencyOwnerUserId
}
return 0
}
// Agency 是主播组织owner 自动拥有 host 身份和 owner membership。
type Agency struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -144,6 +153,8 @@ type Agency struct {
CreatedByUserId int64 `protobuf:"varint,9,opt,name=created_by_user_id,json=createdByUserId,proto3" json:"created_by_user_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,10,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,11,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
Avatar string `protobuf:"bytes,12,opt,name=avatar,proto3" json:"avatar,omitempty"`
HostCount int32 `protobuf:"varint,13,opt,name=host_count,json=hostCount,proto3" json:"host_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -255,6 +266,20 @@ func (x *Agency) GetUpdatedAtMs() int64 {
return 0
}
func (x *Agency) GetAvatar() string {
if x != nil {
return x.Avatar
}
return ""
}
func (x *Agency) GetHostCount() int32 {
if x != nil {
return x.HostCount
}
return 0
}
// BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。
type BDProfile struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -3903,7 +3928,7 @@ var File_proto_user_v1_host_proto protoreflect.FileDescriptor
const file_proto_user_v1_host_proto_rawDesc = "" +
"\n" +
"\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\xd1\x02\n" +
"\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\x91\x03\n" +
"\vHostProfile\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" +
@ -3913,7 +3938,9 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
"\x06source\x18\x06 \x01(\tR\x06source\x124\n" +
"\x17first_became_host_at_ms\x18\a \x01(\x03R\x13firstBecameHostAtMs\x12\"\n" +
"\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xf2\x02\n" +
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12>\n" +
"\x1ccurrent_agency_owner_user_id\x18\n" +
" \x01(\x03R\x18currentAgencyOwnerUserId\"\xa9\x03\n" +
"\x06Agency\x12\x1b\n" +
"\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" +
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x1b\n" +
@ -3926,7 +3953,10 @@ const file_proto_user_v1_host_proto_rawDesc = "" +
"\x12created_by_user_id\x18\t \x01(\x03R\x0fcreatedByUserId\x12\"\n" +
"\rcreated_at_ms\x18\n" +
" \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\x95\x02\n" +
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\x12\x16\n" +
"\x06avatar\x18\f \x01(\tR\x06avatar\x12\x1d\n" +
"\n" +
"host_count\x18\r \x01(\x05R\thostCount\"\x95\x02\n" +
"\tBDProfile\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" +
"\x04role\x18\x02 \x01(\tR\x04role\x12\x1b\n" +

View File

@ -17,6 +17,8 @@ message HostProfile {
int64 first_became_host_at_ms = 7;
int64 created_at_ms = 8;
int64 updated_at_ms = 9;
// current_agency_owner_user_id Agency wallet-service
int64 current_agency_owner_user_id = 10;
}
// Agency owner host owner membership
@ -32,6 +34,8 @@ message Agency {
int64 created_by_user_id = 9;
int64 created_at_ms = 10;
int64 updated_at_ms = 11;
string avatar = 12;
int32 host_count = 13;
}
// BDProfile BD BD Leader BD host

File diff suppressed because it is too large Load Diff

View File

@ -360,6 +360,35 @@ message ListFriendApplicationsResponse {
int64 total = 2;
}
// UserReport App
message UserReport {
string report_id = 1;
int64 reporter_user_id = 2;
string target_type = 3;
int64 user_id = 4;
string room_id = 5;
string report_type = 6;
string reason = 7;
repeated string image_urls = 8;
string status = 9;
int64 created_at_ms = 10;
}
// SubmitReportRequest user_id room_id
message SubmitReportRequest {
RequestMeta meta = 1;
int64 reporter_user_id = 2;
int64 user_id = 3;
string room_id = 4;
string report_type = 5;
string reason = 6;
repeated string image_urls = 7;
}
message SubmitReportResponse {
UserReport report = 1;
}
// BatchGetUsersRequest
message BatchGetUsersRequest {
RequestMeta meta = 1;
@ -716,6 +745,7 @@ service UserSocialService {
rpc DeleteFriend(DeleteFriendRequest) returns (DeleteFriendResponse);
rpc ListFriends(ListFriendsRequest) returns (ListFriendsResponse);
rpc ListFriendApplications(ListFriendApplicationsRequest) returns (ListFriendApplicationsResponse);
rpc SubmitReport(SubmitReportRequest) returns (SubmitReportResponse);
}
// UserCronService cron-service user-service owner

View File

@ -477,6 +477,7 @@ const (
UserSocialService_DeleteFriend_FullMethodName = "/hyapp.user.v1.UserSocialService/DeleteFriend"
UserSocialService_ListFriends_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFriends"
UserSocialService_ListFriendApplications_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFriendApplications"
UserSocialService_SubmitReport_FullMethodName = "/hyapp.user.v1.UserSocialService/SubmitReport"
)
// UserSocialServiceClient is the client API for UserSocialService service.
@ -495,6 +496,7 @@ type UserSocialServiceClient interface {
DeleteFriend(ctx context.Context, in *DeleteFriendRequest, opts ...grpc.CallOption) (*DeleteFriendResponse, error)
ListFriends(ctx context.Context, in *ListFriendsRequest, opts ...grpc.CallOption) (*ListFriendsResponse, error)
ListFriendApplications(ctx context.Context, in *ListFriendApplicationsRequest, opts ...grpc.CallOption) (*ListFriendApplicationsResponse, error)
SubmitReport(ctx context.Context, in *SubmitReportRequest, opts ...grpc.CallOption) (*SubmitReportResponse, error)
}
type userSocialServiceClient struct {
@ -605,6 +607,16 @@ func (c *userSocialServiceClient) ListFriendApplications(ctx context.Context, in
return out, nil
}
func (c *userSocialServiceClient) SubmitReport(ctx context.Context, in *SubmitReportRequest, opts ...grpc.CallOption) (*SubmitReportResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SubmitReportResponse)
err := c.cc.Invoke(ctx, UserSocialService_SubmitReport_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserSocialServiceServer is the server API for UserSocialService service.
// All implementations must embed UnimplementedUserSocialServiceServer
// for forward compatibility.
@ -621,6 +633,7 @@ type UserSocialServiceServer interface {
DeleteFriend(context.Context, *DeleteFriendRequest) (*DeleteFriendResponse, error)
ListFriends(context.Context, *ListFriendsRequest) (*ListFriendsResponse, error)
ListFriendApplications(context.Context, *ListFriendApplicationsRequest) (*ListFriendApplicationsResponse, error)
SubmitReport(context.Context, *SubmitReportRequest) (*SubmitReportResponse, error)
mustEmbedUnimplementedUserSocialServiceServer()
}
@ -661,6 +674,9 @@ func (UnimplementedUserSocialServiceServer) ListFriends(context.Context, *ListFr
func (UnimplementedUserSocialServiceServer) ListFriendApplications(context.Context, *ListFriendApplicationsRequest) (*ListFriendApplicationsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListFriendApplications not implemented")
}
func (UnimplementedUserSocialServiceServer) SubmitReport(context.Context, *SubmitReportRequest) (*SubmitReportResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SubmitReport not implemented")
}
func (UnimplementedUserSocialServiceServer) mustEmbedUnimplementedUserSocialServiceServer() {}
func (UnimplementedUserSocialServiceServer) testEmbeddedByValue() {}
@ -862,6 +878,24 @@ func _UserSocialService_ListFriendApplications_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_SubmitReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitReportRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).SubmitReport(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_SubmitReport_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).SubmitReport(ctx, req.(*SubmitReportRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserSocialService_ServiceDesc is the grpc.ServiceDesc for UserSocialService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -909,6 +943,10 @@ var UserSocialService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListFriendApplications",
Handler: _UserSocialService_ListFriendApplications_Handler,
},
{
MethodName: "SubmitReport",
Handler: _UserSocialService_SubmitReport_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",

View File

@ -34,9 +34,15 @@ type DebitGiftRequest struct {
PriceVersion string `protobuf:"bytes,7,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"`
AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
// region_id 来自 room-service 房间 visible_region_id用于校验礼物区域可用性。
RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
// target_is_host 由 gateway 根据 user-service active host profile 注入,客户端不能直接声明。
TargetIsHost bool `protobuf:"varint,10,opt,name=target_is_host,json=targetIsHost,proto3" json:"target_is_host,omitempty"`
// target_host_region_id 是主播身份所属区域,用于后续按主播区域匹配工资政策。
TargetHostRegionId int64 `protobuf:"varint,11,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"`
// target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner用于后续代理工资结算。
TargetAgencyOwnerUserId int64 `protobuf:"varint,12,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DebitGiftRequest) Reset() {
@ -132,6 +138,27 @@ func (x *DebitGiftRequest) GetRegionId() int64 {
return 0
}
func (x *DebitGiftRequest) GetTargetIsHost() bool {
if x != nil {
return x.TargetIsHost
}
return false
}
func (x *DebitGiftRequest) GetTargetHostRegionId() int64 {
if x != nil {
return x.TargetHostRegionId
}
return 0
}
func (x *DebitGiftRequest) GetTargetAgencyOwnerUserId() int64 {
if x != nil {
return x.TargetAgencyOwnerUserId
}
return 0
}
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
type DebitGiftResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -146,8 +173,12 @@ type DebitGiftResponse struct {
ChargeAssetType string `protobuf:"bytes,8,opt,name=charge_asset_type,json=chargeAssetType,proto3" json:"charge_asset_type,omitempty"`
ChargeAmount int64 `protobuf:"varint,9,opt,name=charge_amount,json=chargeAmount,proto3" json:"charge_amount,omitempty"`
GiftTypeCode string `protobuf:"bytes,10,opt,name=gift_type_code,json=giftTypeCode,proto3" json:"gift_type_code,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// host_period_diamond_added 是本次送礼给主播周期钻石账户增加的钻石;非主播为 0。
HostPeriodDiamondAdded int64 `protobuf:"varint,11,opt,name=host_period_diamond_added,json=hostPeriodDiamondAdded,proto3" json:"host_period_diamond_added,omitempty"`
// host_period_cycle_key 是 UTC 月周期,例如 2026-05非主播为空。
HostPeriodCycleKey string `protobuf:"bytes,12,opt,name=host_period_cycle_key,json=hostPeriodCycleKey,proto3" json:"host_period_cycle_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DebitGiftResponse) Reset() {
@ -250,6 +281,20 @@ func (x *DebitGiftResponse) GetGiftTypeCode() string {
return ""
}
func (x *DebitGiftResponse) GetHostPeriodDiamondAdded() int64 {
if x != nil {
return x.HostPeriodDiamondAdded
}
return 0
}
func (x *DebitGiftResponse) GetHostPeriodCycleKey() string {
if x != nil {
return x.HostPeriodCycleKey
}
return ""
}
// AssetBalance 是用户某类资产的余额投影。
type AssetBalance struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -10676,6 +10721,7 @@ type RedPacketConfig struct {
UpdatedByAdminId int64 `protobuf:"varint,8,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,9,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,10,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
RuleUrl string `protobuf:"bytes,11,opt,name=rule_url,json=ruleUrl,proto3" json:"rule_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -10780,6 +10826,13 @@ func (x *RedPacketConfig) GetUpdatedAtMs() int64 {
return 0
}
func (x *RedPacketConfig) GetRuleUrl() string {
if x != nil {
return x.RuleUrl
}
return ""
}
type RedPacketClaim struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
@ -10925,6 +10978,9 @@ type RedPacket struct {
ClaimedByViewer bool `protobuf:"varint,17,opt,name=claimed_by_viewer,json=claimedByViewer,proto3" json:"claimed_by_viewer,omitempty"`
ViewerClaimAmount int64 `protobuf:"varint,18,opt,name=viewer_claim_amount,json=viewerClaimAmount,proto3" json:"viewer_claim_amount,omitempty"`
Claims []*RedPacketClaim `protobuf:"bytes,19,rep,name=claims,proto3" json:"claims,omitempty"`
RefundAmount int64 `protobuf:"varint,20,opt,name=refund_amount,json=refundAmount,proto3" json:"refund_amount,omitempty"`
RefundStatus string `protobuf:"bytes,21,opt,name=refund_status,json=refundStatus,proto3" json:"refund_status,omitempty"`
RefundedAtMs int64 `protobuf:"varint,22,opt,name=refunded_at_ms,json=refundedAtMs,proto3" json:"refunded_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -11092,6 +11148,27 @@ func (x *RedPacket) GetClaims() []*RedPacketClaim {
return nil
}
func (x *RedPacket) GetRefundAmount() int64 {
if x != nil {
return x.RefundAmount
}
return 0
}
func (x *RedPacket) GetRefundStatus() string {
if x != nil {
return x.RefundStatus
}
return ""
}
func (x *RedPacket) GetRefundedAtMs() int64 {
if x != nil {
return x.RefundedAtMs
}
return 0
}
type GetRedPacketConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
@ -11207,6 +11284,7 @@ type UpdateRedPacketConfigRequest struct {
ExpireSeconds int32 `protobuf:"varint,7,opt,name=expire_seconds,json=expireSeconds,proto3" json:"expire_seconds,omitempty"`
DailySendLimit int32 `protobuf:"varint,8,opt,name=daily_send_limit,json=dailySendLimit,proto3" json:"daily_send_limit,omitempty"`
OperatorUserId int64 `protobuf:"varint,9,opt,name=operator_user_id,json=operatorUserId,proto3" json:"operator_user_id,omitempty"`
RuleUrl string `protobuf:"bytes,10,opt,name=rule_url,json=ruleUrl,proto3" json:"rule_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -11304,6 +11382,13 @@ func (x *UpdateRedPacketConfigRequest) GetOperatorUserId() int64 {
return 0
}
func (x *UpdateRedPacketConfigRequest) GetRuleUrl() string {
if x != nil {
return x.RuleUrl
}
return ""
}
type UpdateRedPacketConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Config *RedPacketConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"`
@ -11605,6 +11690,7 @@ type ClaimRedPacketResponse struct {
Claim *RedPacketClaim `protobuf:"bytes,1,opt,name=claim,proto3" json:"claim,omitempty"`
BalanceAfter int64 `protobuf:"varint,2,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
ServerTimeMs int64 `protobuf:"varint,3,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
Packet *RedPacket `protobuf:"bytes,4,opt,name=packet,proto3" json:"packet,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -11660,6 +11746,13 @@ func (x *ClaimRedPacketResponse) GetServerTimeMs() int64 {
return 0
}
func (x *ClaimRedPacketResponse) GetPacket() *RedPacket {
if x != nil {
return x.Packet
}
return nil
}
type ListRedPacketsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
@ -12100,11 +12193,301 @@ func (x *ExpireRedPacketsResponse) GetServerTimeMs() int64 {
return 0
}
type RetryRedPacketRefundRequest 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"`
PacketId string `protobuf:"bytes,3,opt,name=packet_id,json=packetId,proto3" json:"packet_id,omitempty"`
OperatorUserId int64 `protobuf:"varint,4,opt,name=operator_user_id,json=operatorUserId,proto3" json:"operator_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RetryRedPacketRefundRequest) Reset() {
*x = RetryRedPacketRefundRequest{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[137]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RetryRedPacketRefundRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RetryRedPacketRefundRequest) ProtoMessage() {}
func (x *RetryRedPacketRefundRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[137]
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 RetryRedPacketRefundRequest.ProtoReflect.Descriptor instead.
func (*RetryRedPacketRefundRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{137}
}
func (x *RetryRedPacketRefundRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *RetryRedPacketRefundRequest) GetAppCode() string {
if x != nil {
return x.AppCode
}
return ""
}
func (x *RetryRedPacketRefundRequest) GetPacketId() string {
if x != nil {
return x.PacketId
}
return ""
}
func (x *RetryRedPacketRefundRequest) GetOperatorUserId() int64 {
if x != nil {
return x.OperatorUserId
}
return 0
}
type RetryRedPacketRefundResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Packet *RedPacket `protobuf:"bytes,1,opt,name=packet,proto3" json:"packet,omitempty"`
RefundedAmount int64 `protobuf:"varint,2,opt,name=refunded_amount,json=refundedAmount,proto3" json:"refunded_amount,omitempty"`
ServerTimeMs int64 `protobuf:"varint,3,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RetryRedPacketRefundResponse) Reset() {
*x = RetryRedPacketRefundResponse{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[138]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RetryRedPacketRefundResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RetryRedPacketRefundResponse) ProtoMessage() {}
func (x *RetryRedPacketRefundResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[138]
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 RetryRedPacketRefundResponse.ProtoReflect.Descriptor instead.
func (*RetryRedPacketRefundResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{138}
}
func (x *RetryRedPacketRefundResponse) GetPacket() *RedPacket {
if x != nil {
return x.Packet
}
return nil
}
func (x *RetryRedPacketRefundResponse) GetRefundedAmount() int64 {
if x != nil {
return x.RefundedAmount
}
return 0
}
func (x *RetryRedPacketRefundResponse) GetServerTimeMs() int64 {
if x != nil {
return x.ServerTimeMs
}
return 0
}
// CronBatchRequest 是 cron-service 触发 wallet-service 后台批处理的统一请求。
type CronBatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
RunId string `protobuf:"bytes,3,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
WorkerId string `protobuf:"bytes,4,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
BatchSize int32 `protobuf:"varint,5,opt,name=batch_size,json=batchSize,proto3" json:"batch_size,omitempty"`
LockTtlMs int64 `protobuf:"varint,6,opt,name=lock_ttl_ms,json=lockTtlMs,proto3" json:"lock_ttl_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CronBatchRequest) Reset() {
*x = CronBatchRequest{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[139]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CronBatchRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CronBatchRequest) ProtoMessage() {}
func (x *CronBatchRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[139]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CronBatchRequest.ProtoReflect.Descriptor instead.
func (*CronBatchRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{139}
}
func (x *CronBatchRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *CronBatchRequest) GetAppCode() string {
if x != nil {
return x.AppCode
}
return ""
}
func (x *CronBatchRequest) GetRunId() string {
if x != nil {
return x.RunId
}
return ""
}
func (x *CronBatchRequest) GetWorkerId() string {
if x != nil {
return x.WorkerId
}
return ""
}
func (x *CronBatchRequest) GetBatchSize() int32 {
if x != nil {
return x.BatchSize
}
return 0
}
func (x *CronBatchRequest) GetLockTtlMs() int64 {
if x != nil {
return x.LockTtlMs
}
return 0
}
// CronBatchResponse 返回一个批处理 run 的执行摘要。
type CronBatchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClaimedCount int32 `protobuf:"varint,1,opt,name=claimed_count,json=claimedCount,proto3" json:"claimed_count,omitempty"`
ProcessedCount int32 `protobuf:"varint,2,opt,name=processed_count,json=processedCount,proto3" json:"processed_count,omitempty"`
SuccessCount int32 `protobuf:"varint,3,opt,name=success_count,json=successCount,proto3" json:"success_count,omitempty"`
FailureCount int32 `protobuf:"varint,4,opt,name=failure_count,json=failureCount,proto3" json:"failure_count,omitempty"`
HasMore bool `protobuf:"varint,5,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CronBatchResponse) Reset() {
*x = CronBatchResponse{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[140]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CronBatchResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CronBatchResponse) ProtoMessage() {}
func (x *CronBatchResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[140]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CronBatchResponse.ProtoReflect.Descriptor instead.
func (*CronBatchResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{140}
}
func (x *CronBatchResponse) GetClaimedCount() int32 {
if x != nil {
return x.ClaimedCount
}
return 0
}
func (x *CronBatchResponse) GetProcessedCount() int32 {
if x != nil {
return x.ProcessedCount
}
return 0
}
func (x *CronBatchResponse) GetSuccessCount() int32 {
if x != nil {
return x.SuccessCount
}
return 0
}
func (x *CronBatchResponse) GetFailureCount() int32 {
if x != nil {
return x.FailureCount
}
return 0
}
func (x *CronBatchResponse) GetHasMore() bool {
if x != nil {
return x.HasMore
}
return false
}
var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\n" +
"\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xab\x02\n" +
"\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xc2\x03\n" +
"\x10DebitGiftRequest\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
@ -12116,7 +12499,11 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" +
"\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" +
"\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" +
"\tregion_id\x18\t \x01(\x03R\bregionId\"\x91\x03\n" +
"\tregion_id\x18\t \x01(\x03R\bregionId\x12$\n" +
"\x0etarget_is_host\x18\n" +
" \x01(\bR\ftargetIsHost\x121\n" +
"\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" +
"\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\"\xff\x03\n" +
"\x11DebitGiftResponse\x12,\n" +
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
@ -12130,7 +12517,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\x12#\n" +
"\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\x12$\n" +
"\x0egift_type_code\x18\n" +
" \x01(\tR\fgiftTypeCode\"\xb2\x01\n" +
" \x01(\tR\fgiftTypeCode\x129\n" +
"\x19host_period_diamond_added\x18\v \x01(\x03R\x16hostPeriodDiamondAdded\x121\n" +
"\x15host_period_cycle_key\x18\f \x01(\tR\x12hostPeriodCycleKey\"\xb2\x01\n" +
"\fAssetBalance\x12\x1d\n" +
"\n" +
"asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" +
@ -13214,7 +13603,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x1bApplyGameCoinChangeResponse\x122\n" +
"\x15wallet_transaction_id\x18\x01 \x01(\tR\x13walletTransactionId\x12#\n" +
"\rbalance_after\x18\x02 \x01(\x03R\fbalanceAfter\x12+\n" +
"\x11idempotent_replay\x18\x03 \x01(\bR\x10idempotentReplay\"\x84\x03\n" +
"\x11idempotent_replay\x18\x03 \x01(\bR\x10idempotentReplay\"\x9f\x03\n" +
"\x0fRedPacketConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1f\n" +
@ -13227,7 +13616,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x13updated_by_admin_id\x18\b \x01(\x03R\x10updatedByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\t \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\n" +
" \x01(\x03R\vupdatedAtMs\"\xee\x02\n" +
" \x01(\x03R\vupdatedAtMs\x12\x19\n" +
"\brule_url\x18\v \x01(\tR\aruleUrl\"\xee\x02\n" +
"\x0eRedPacketClaim\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x19\n" +
"\bclaim_id\x18\x02 \x01(\tR\aclaimId\x12\x1d\n" +
@ -13241,7 +13631,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0efailure_reason\x18\t \x01(\tR\rfailureReason\x12\"\n" +
"\rcreated_at_ms\x18\n" +
" \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\xb0\x05\n" +
"\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\xa0\x06\n" +
"\tRedPacket\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x1b\n" +
"\tpacket_id\x18\x02 \x01(\tR\bpacketId\x12\x1d\n" +
@ -13265,14 +13655,17 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\rupdated_at_ms\x18\x10 \x01(\x03R\vupdatedAtMs\x12*\n" +
"\x11claimed_by_viewer\x18\x11 \x01(\bR\x0fclaimedByViewer\x12.\n" +
"\x13viewer_claim_amount\x18\x12 \x01(\x03R\x11viewerClaimAmount\x127\n" +
"\x06claims\x18\x13 \x03(\v2\x1f.hyapp.wallet.v1.RedPacketClaimR\x06claims\"U\n" +
"\x06claims\x18\x13 \x03(\v2\x1f.hyapp.wallet.v1.RedPacketClaimR\x06claims\x12#\n" +
"\rrefund_amount\x18\x14 \x01(\x03R\frefundAmount\x12#\n" +
"\rrefund_status\x18\x15 \x01(\tR\frefundStatus\x12$\n" +
"\x0erefunded_at_ms\x18\x16 \x01(\x03R\frefundedAtMs\"U\n" +
"\x19GetRedPacketConfigRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\"|\n" +
"\x1aGetRedPacketConfigResponse\x128\n" +
"\x06config\x18\x01 \x01(\v2 .hyapp.wallet.v1.RedPacketConfigR\x06config\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xe3\x02\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xfe\x02\n" +
"\x1cUpdateRedPacketConfigRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -13284,7 +13677,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x14delayed_open_seconds\x18\x06 \x01(\x05R\x12delayedOpenSeconds\x12%\n" +
"\x0eexpire_seconds\x18\a \x01(\x05R\rexpireSeconds\x12(\n" +
"\x10daily_send_limit\x18\b \x01(\x05R\x0edailySendLimit\x12(\n" +
"\x10operator_user_id\x18\t \x01(\x03R\x0eoperatorUserId\"\x7f\n" +
"\x10operator_user_id\x18\t \x01(\x03R\x0eoperatorUserId\x12\x19\n" +
"\brule_url\x18\n" +
" \x01(\tR\aruleUrl\"\x7f\n" +
"\x1dUpdateRedPacketConfigResponse\x128\n" +
"\x06config\x18\x01 \x01(\v2 .hyapp.wallet.v1.RedPacketConfigR\x06config\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xb4\x02\n" +
@ -13312,11 +13707,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\n" +
"command_id\x18\x03 \x01(\tR\tcommandId\x12\x1b\n" +
"\tpacket_id\x18\x04 \x01(\tR\bpacketId\x12\x17\n" +
"\auser_id\x18\x05 \x01(\x03R\x06userId\"\x9a\x01\n" +
"\auser_id\x18\x05 \x01(\x03R\x06userId\"\xce\x01\n" +
"\x16ClaimRedPacketResponse\x125\n" +
"\x05claim\x18\x01 \x01(\v2\x1f.hyapp.wallet.v1.RedPacketClaimR\x05claim\x12#\n" +
"\rbalance_after\x18\x02 \x01(\x03R\fbalanceAfter\x12$\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xf9\x02\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\x122\n" +
"\x06packet\x18\x04 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\"\xf9\x02\n" +
"\x15ListRedPacketsRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -13356,7 +13752,36 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x18ExpireRedPacketsResponse\x12#\n" +
"\rexpired_count\x18\x01 \x01(\x05R\fexpiredCount\x12'\n" +
"\x0frefunded_amount\x18\x02 \x01(\x03R\x0erefundedAmount\x12$\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs2\xf01\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\x9e\x01\n" +
"\x1bRetryRedPacketRefundRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x1b\n" +
"\tpacket_id\x18\x03 \x01(\tR\bpacketId\x12(\n" +
"\x10operator_user_id\x18\x04 \x01(\x03R\x0eoperatorUserId\"\xa1\x01\n" +
"\x1cRetryRedPacketRefundResponse\x122\n" +
"\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12'\n" +
"\x0frefunded_amount\x18\x02 \x01(\x03R\x0erefundedAmount\x12$\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xbf\x01\n" +
"\x10CronBatchRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x15\n" +
"\x06run_id\x18\x03 \x01(\tR\x05runId\x12\x1b\n" +
"\tworker_id\x18\x04 \x01(\tR\bworkerId\x12\x1d\n" +
"\n" +
"batch_size\x18\x05 \x01(\x05R\tbatchSize\x12\x1e\n" +
"\vlock_ttl_ms\x18\x06 \x01(\x03R\tlockTtlMs\"\xc6\x01\n" +
"\x11CronBatchResponse\x12#\n" +
"\rclaimed_count\x18\x01 \x01(\x05R\fclaimedCount\x12'\n" +
"\x0fprocessed_count\x18\x02 \x01(\x05R\x0eprocessedCount\x12#\n" +
"\rsuccess_count\x18\x03 \x01(\x05R\fsuccessCount\x12#\n" +
"\rfailure_count\x18\x04 \x01(\x05R\ffailureCount\x12\x19\n" +
"\bhas_more\x18\x05 \x01(\bR\ahasMore2\xe0\x02\n" +
"\x11WalletCronService\x12n\n" +
"%ProcessHostSalaryDailySettlementBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12r\n" +
")ProcessHostSalaryHalfMonthSettlementBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12g\n" +
"\x1eProcessHostSalaryMonthEndBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse2\xe52\n" +
"\rWalletService\x12R\n" +
"\tDebitGift\x12!.hyapp.wallet.v1.DebitGiftRequest\x1a\".hyapp.wallet.v1.DebitGiftResponse\x12X\n" +
"\vGetBalances\x12#.hyapp.wallet.v1.GetBalancesRequest\x1a$.hyapp.wallet.v1.GetBalancesResponse\x12g\n" +
@ -13416,7 +13841,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0eClaimRedPacket\x12&.hyapp.wallet.v1.ClaimRedPacketRequest\x1a'.hyapp.wallet.v1.ClaimRedPacketResponse\x12a\n" +
"\x0eListRedPackets\x12&.hyapp.wallet.v1.ListRedPacketsRequest\x1a'.hyapp.wallet.v1.ListRedPacketsResponse\x12[\n" +
"\fGetRedPacket\x12$.hyapp.wallet.v1.GetRedPacketRequest\x1a%.hyapp.wallet.v1.GetRedPacketResponse\x12g\n" +
"\x10ExpireRedPackets\x12(.hyapp.wallet.v1.ExpireRedPacketsRequest\x1a).hyapp.wallet.v1.ExpireRedPacketsResponseB*Z(hyapp.local/api/proto/wallet/v1;walletv1b\x06proto3"
"\x10ExpireRedPackets\x12(.hyapp.wallet.v1.ExpireRedPacketsRequest\x1a).hyapp.wallet.v1.ExpireRedPacketsResponse\x12s\n" +
"\x14RetryRedPacketRefund\x12,.hyapp.wallet.v1.RetryRedPacketRefundRequest\x1a-.hyapp.wallet.v1.RetryRedPacketRefundResponseB*Z(hyapp.local/api/proto/wallet/v1;walletv1b\x06proto3"
var (
file_proto_wallet_v1_wallet_proto_rawDescOnce sync.Once
@ -13430,7 +13856,7 @@ func file_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte {
return file_proto_wallet_v1_wallet_proto_rawDescData
}
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 137)
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 141)
var file_proto_wallet_v1_wallet_proto_goTypes = []any{
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
@ -13569,6 +13995,10 @@ var file_proto_wallet_v1_wallet_proto_goTypes = []any{
(*GetRedPacketResponse)(nil), // 134: hyapp.wallet.v1.GetRedPacketResponse
(*ExpireRedPacketsRequest)(nil), // 135: hyapp.wallet.v1.ExpireRedPacketsRequest
(*ExpireRedPacketsResponse)(nil), // 136: hyapp.wallet.v1.ExpireRedPacketsResponse
(*RetryRedPacketRefundRequest)(nil), // 137: hyapp.wallet.v1.RetryRedPacketRefundRequest
(*RetryRedPacketRefundResponse)(nil), // 138: hyapp.wallet.v1.RetryRedPacketRefundResponse
(*CronBatchRequest)(nil), // 139: hyapp.wallet.v1.CronBatchRequest
(*CronBatchResponse)(nil), // 140: hyapp.wallet.v1.CronBatchResponse
}
var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance
@ -13634,131 +14064,141 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
120, // 60: hyapp.wallet.v1.UpdateRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
122, // 61: hyapp.wallet.v1.CreateRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
121, // 62: hyapp.wallet.v1.ClaimRedPacketResponse.claim:type_name -> hyapp.wallet.v1.RedPacketClaim
122, // 63: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket
122, // 64: hyapp.wallet.v1.GetRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
0, // 65: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
3, // 66: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
5, // 67: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
7, // 68: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
9, // 69: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
21, // 70: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
23, // 71: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
25, // 72: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
26, // 73: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
27, // 74: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
29, // 75: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
31, // 76: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
33, // 77: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
34, // 78: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
35, // 79: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
37, // 80: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
39, // 81: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
43, // 82: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
44, // 83: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
45, // 84: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
41, // 85: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
47, // 86: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
48, // 87: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
50, // 88: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
52, // 89: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
54, // 90: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
57, // 91: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
59, // 92: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
61, // 93: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
63, // 94: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
66, // 95: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
69, // 96: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
72, // 97: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
75, // 98: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
78, // 99: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
80, // 100: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
82, // 101: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
84, // 102: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
85, // 103: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
86, // 104: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
90, // 105: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
93, // 106: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
96, // 107: hyapp.wallet.v1.WalletService.ApplyWithdrawal:input_type -> hyapp.wallet.v1.ApplyWithdrawalRequest
101, // 108: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
103, // 109: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
105, // 110: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
107, // 111: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
110, // 112: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
112, // 113: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
114, // 114: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
116, // 115: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
118, // 116: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
123, // 117: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
125, // 118: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
127, // 119: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
129, // 120: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
131, // 121: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
133, // 122: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
135, // 123: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
1, // 124: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
4, // 125: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
6, // 126: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
8, // 127: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
10, // 128: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
22, // 129: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
24, // 130: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
28, // 131: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 132: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 133: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
30, // 134: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
32, // 135: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
36, // 136: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 137: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 138: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
38, // 139: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
40, // 140: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
46, // 141: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
46, // 142: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
46, // 143: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
42, // 144: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
49, // 145: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
49, // 146: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
51, // 147: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
53, // 148: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
55, // 149: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
58, // 150: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
60, // 151: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
62, // 152: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
64, // 153: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
67, // 154: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
70, // 155: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
73, // 156: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
76, // 157: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
79, // 158: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
81, // 159: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
83, // 160: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
87, // 161: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
87, // 162: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
88, // 163: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
91, // 164: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
94, // 165: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
97, // 166: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse
102, // 167: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
104, // 168: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
106, // 169: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
108, // 170: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
111, // 171: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
113, // 172: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
115, // 173: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
117, // 174: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
119, // 175: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
124, // 176: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
126, // 177: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
128, // 178: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
130, // 179: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
132, // 180: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse
134, // 181: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse
136, // 182: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse
124, // [124:183] is the sub-list for method output_type
65, // [65:124] is the sub-list for method input_type
65, // [65:65] is the sub-list for extension type_name
65, // [65:65] is the sub-list for extension extendee
0, // [0:65] is the sub-list for field type_name
122, // 63: hyapp.wallet.v1.ClaimRedPacketResponse.packet: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, // 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
139, // 68: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
139, // 69: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
0, // 70: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
3, // 71: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
5, // 72: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
7, // 73: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
9, // 74: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
21, // 75: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
23, // 76: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
25, // 77: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
26, // 78: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
27, // 79: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
29, // 80: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
31, // 81: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
33, // 82: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
34, // 83: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
35, // 84: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
37, // 85: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
39, // 86: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
43, // 87: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
44, // 88: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
45, // 89: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
41, // 90: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
47, // 91: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
48, // 92: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
50, // 93: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
52, // 94: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
54, // 95: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
57, // 96: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
59, // 97: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
61, // 98: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
63, // 99: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
66, // 100: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
69, // 101: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
72, // 102: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
75, // 103: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
78, // 104: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
80, // 105: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
82, // 106: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
84, // 107: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
85, // 108: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
86, // 109: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
90, // 110: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
93, // 111: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
96, // 112: hyapp.wallet.v1.WalletService.ApplyWithdrawal:input_type -> hyapp.wallet.v1.ApplyWithdrawalRequest
101, // 113: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
103, // 114: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
105, // 115: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
107, // 116: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
110, // 117: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
112, // 118: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
114, // 119: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
116, // 120: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
118, // 121: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
123, // 122: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
125, // 123: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
127, // 124: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
129, // 125: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
131, // 126: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
133, // 127: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
135, // 128: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
137, // 129: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest
140, // 130: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
140, // 131: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
140, // 132: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
1, // 133: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
4, // 134: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
6, // 135: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
8, // 136: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
10, // 137: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
22, // 138: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
24, // 139: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
28, // 140: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 141: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 142: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
30, // 143: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
32, // 144: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
36, // 145: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 146: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 147: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
38, // 148: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
40, // 149: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
46, // 150: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
46, // 151: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
46, // 152: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
42, // 153: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
49, // 154: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
49, // 155: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
51, // 156: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
53, // 157: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
55, // 158: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
58, // 159: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
60, // 160: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
62, // 161: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
64, // 162: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
67, // 163: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
70, // 164: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
73, // 165: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
76, // 166: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
79, // 167: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
81, // 168: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
83, // 169: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
87, // 170: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
87, // 171: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
88, // 172: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
91, // 173: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
94, // 174: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
97, // 175: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse
102, // 176: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
104, // 177: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
106, // 178: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
108, // 179: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
111, // 180: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
113, // 181: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
115, // 182: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
117, // 183: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
119, // 184: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
124, // 185: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
126, // 186: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
128, // 187: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
130, // 188: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
132, // 189: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse
134, // 190: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse
136, // 191: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse
138, // 192: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse
130, // [130:193] is the sub-list for method output_type
67, // [67:130] is the sub-list for method input_type
67, // [67:67] is the sub-list for extension type_name
67, // [67:67] is the sub-list for extension extendee
0, // [0:67] is the sub-list for field type_name
}
func init() { file_proto_wallet_v1_wallet_proto_init() }
@ -13774,9 +14214,9 @@ func file_proto_wallet_v1_wallet_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_wallet_v1_wallet_proto_rawDesc), len(file_proto_wallet_v1_wallet_proto_rawDesc)),
NumEnums: 0,
NumMessages: 137,
NumMessages: 141,
NumExtensions: 0,
NumServices: 1,
NumServices: 2,
},
GoTypes: file_proto_wallet_v1_wallet_proto_goTypes,
DependencyIndexes: file_proto_wallet_v1_wallet_proto_depIdxs,

View File

@ -17,6 +17,12 @@ message DebitGiftRequest {
string app_code = 8;
// region_id room-service visible_region_id
int64 region_id = 9;
// target_is_host gateway user-service active host profile
bool target_is_host = 10;
// target_host_region_id
int64 target_host_region_id = 11;
// target_agency_owner_user_id Agency owner
int64 target_agency_owner_user_id = 12;
}
// DebitGiftResponse sender COIN
@ -32,6 +38,10 @@ message DebitGiftResponse {
string charge_asset_type = 8;
int64 charge_amount = 9;
string gift_type_code = 10;
// host_period_diamond_added 0
int64 host_period_diamond_added = 11;
// host_period_cycle_key UTC 2026-05
string host_period_cycle_key = 12;
}
// AssetBalance
@ -1189,6 +1199,7 @@ message RedPacketConfig {
int64 updated_by_admin_id = 8;
int64 created_at_ms = 9;
int64 updated_at_ms = 10;
string rule_url = 11;
}
message RedPacketClaim {
@ -1225,6 +1236,9 @@ message RedPacket {
bool claimed_by_viewer = 17;
int64 viewer_claim_amount = 18;
repeated RedPacketClaim claims = 19;
int64 refund_amount = 20;
string refund_status = 21;
int64 refunded_at_ms = 22;
}
message GetRedPacketConfigRequest {
@ -1247,6 +1261,7 @@ message UpdateRedPacketConfigRequest {
int32 expire_seconds = 7;
int32 daily_send_limit = 8;
int64 operator_user_id = 9;
string rule_url = 10;
}
message UpdateRedPacketConfigResponse {
@ -1284,6 +1299,7 @@ message ClaimRedPacketResponse {
RedPacketClaim claim = 1;
int64 balance_after = 2;
int64 server_time_ms = 3;
RedPacket packet = 4;
}
message ListRedPacketsRequest {
@ -1332,6 +1348,45 @@ message ExpireRedPacketsResponse {
int64 server_time_ms = 3;
}
message RetryRedPacketRefundRequest {
string request_id = 1;
string app_code = 2;
string packet_id = 3;
int64 operator_user_id = 4;
}
message RetryRedPacketRefundResponse {
RedPacket packet = 1;
int64 refunded_amount = 2;
int64 server_time_ms = 3;
}
// CronBatchRequest cron-service wallet-service
message CronBatchRequest {
string request_id = 1;
string app_code = 2;
string run_id = 3;
string worker_id = 4;
int32 batch_size = 5;
int64 lock_ttl_ms = 6;
}
// CronBatchResponse run
message CronBatchResponse {
int32 claimed_count = 1;
int32 processed_count = 2;
int32 success_count = 3;
int32 failure_count = 4;
bool has_more = 5;
}
// WalletCronService cron-service wallet-service owner
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessHostSalaryHalfMonthSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessHostSalaryMonthEndBatch(CronBatchRequest) returns (CronBatchResponse);
}
// WalletService gRPC HTTP gateway-service
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
@ -1393,4 +1448,5 @@ service WalletService {
rpc ListRedPackets(ListRedPacketsRequest) returns (ListRedPacketsResponse);
rpc GetRedPacket(GetRedPacketRequest) returns (GetRedPacketResponse);
rpc ExpireRedPackets(ExpireRedPacketsRequest) returns (ExpireRedPacketsResponse);
rpc RetryRedPacketRefund(RetryRedPacketRefundRequest) returns (RetryRedPacketRefundResponse);
}

View File

@ -18,6 +18,188 @@ import (
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryDailySettlementBatch"
WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryHalfMonthSettlementBatch"
WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryMonthEndBatch"
)
// WalletCronServiceClient is the client API for WalletCronService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
type WalletCronServiceClient interface {
ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
}
type walletCronServiceClient struct {
cc grpc.ClientConnInterface
}
func NewWalletCronServiceClient(cc grpc.ClientConnInterface) WalletCronServiceClient {
return &walletCronServiceClient{cc}
}
func (c *walletCronServiceClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletCronServiceClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletCronServiceClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletCronServiceServer is the server API for WalletCronService service.
// All implementations must embed UnimplementedWalletCronServiceServer
// for forward compatibility.
//
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
type WalletCronServiceServer interface {
ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
mustEmbedUnimplementedWalletCronServiceServer()
}
// UnimplementedWalletCronServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedWalletCronServiceServer struct{}
func (UnimplementedWalletCronServiceServer) ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryDailySettlementBatch not implemented")
}
func (UnimplementedWalletCronServiceServer) ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryHalfMonthSettlementBatch not implemented")
}
func (UnimplementedWalletCronServiceServer) ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryMonthEndBatch not implemented")
}
func (UnimplementedWalletCronServiceServer) mustEmbedUnimplementedWalletCronServiceServer() {}
func (UnimplementedWalletCronServiceServer) testEmbeddedByValue() {}
// UnsafeWalletCronServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to WalletCronServiceServer will
// result in compilation errors.
type UnsafeWalletCronServiceServer interface {
mustEmbedUnimplementedWalletCronServiceServer()
}
func RegisterWalletCronServiceServer(s grpc.ServiceRegistrar, srv WalletCronServiceServer) {
// If the following call panics, it indicates UnimplementedWalletCronServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&WalletCronService_ServiceDesc, srv)
}
func _WalletCronService_ProcessHostSalaryDailySettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletCronServiceServer).ProcessHostSalaryDailySettlementBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletCronServiceServer).ProcessHostSalaryDailySettlementBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletCronServiceServer).ProcessHostSalaryHalfMonthSettlementBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletCronServiceServer).ProcessHostSalaryHalfMonthSettlementBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletCronServiceServer).ProcessHostSalaryMonthEndBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletCronServiceServer).ProcessHostSalaryMonthEndBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletCronService_ServiceDesc is the grpc.ServiceDesc for WalletCronService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var WalletCronService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.wallet.v1.WalletCronService",
HandlerType: (*WalletCronServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ProcessHostSalaryDailySettlementBatch",
Handler: _WalletCronService_ProcessHostSalaryDailySettlementBatch_Handler,
},
{
MethodName: "ProcessHostSalaryHalfMonthSettlementBatch",
Handler: _WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_Handler,
},
{
MethodName: "ProcessHostSalaryMonthEndBatch",
Handler: _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet/v1/wallet.proto",
}
const (
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
@ -78,6 +260,7 @@ const (
WalletService_ListRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRedPackets"
WalletService_GetRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacket"
WalletService_ExpireRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ExpireRedPackets"
WalletService_RetryRedPacketRefund_FullMethodName = "/hyapp.wallet.v1.WalletService/RetryRedPacketRefund"
)
// WalletServiceClient is the client API for WalletService service.
@ -145,6 +328,7 @@ type WalletServiceClient interface {
ListRedPackets(ctx context.Context, in *ListRedPacketsRequest, opts ...grpc.CallOption) (*ListRedPacketsResponse, error)
GetRedPacket(ctx context.Context, in *GetRedPacketRequest, opts ...grpc.CallOption) (*GetRedPacketResponse, error)
ExpireRedPackets(ctx context.Context, in *ExpireRedPacketsRequest, opts ...grpc.CallOption) (*ExpireRedPacketsResponse, error)
RetryRedPacketRefund(ctx context.Context, in *RetryRedPacketRefundRequest, opts ...grpc.CallOption) (*RetryRedPacketRefundResponse, error)
}
type walletServiceClient struct {
@ -745,6 +929,16 @@ func (c *walletServiceClient) ExpireRedPackets(ctx context.Context, in *ExpireRe
return out, nil
}
func (c *walletServiceClient) RetryRedPacketRefund(ctx context.Context, in *RetryRedPacketRefundRequest, opts ...grpc.CallOption) (*RetryRedPacketRefundResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RetryRedPacketRefundResponse)
err := c.cc.Invoke(ctx, WalletService_RetryRedPacketRefund_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletServiceServer is the server API for WalletService service.
// All implementations must embed UnimplementedWalletServiceServer
// for forward compatibility.
@ -810,6 +1004,7 @@ type WalletServiceServer interface {
ListRedPackets(context.Context, *ListRedPacketsRequest) (*ListRedPacketsResponse, error)
GetRedPacket(context.Context, *GetRedPacketRequest) (*GetRedPacketResponse, error)
ExpireRedPackets(context.Context, *ExpireRedPacketsRequest) (*ExpireRedPacketsResponse, error)
RetryRedPacketRefund(context.Context, *RetryRedPacketRefundRequest) (*RetryRedPacketRefundResponse, error)
mustEmbedUnimplementedWalletServiceServer()
}
@ -997,6 +1192,9 @@ func (UnimplementedWalletServiceServer) GetRedPacket(context.Context, *GetRedPac
func (UnimplementedWalletServiceServer) ExpireRedPackets(context.Context, *ExpireRedPacketsRequest) (*ExpireRedPacketsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpireRedPackets not implemented")
}
func (UnimplementedWalletServiceServer) RetryRedPacketRefund(context.Context, *RetryRedPacketRefundRequest) (*RetryRedPacketRefundResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RetryRedPacketRefund not implemented")
}
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
@ -2080,6 +2278,24 @@ func _WalletService_ExpireRedPackets_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _WalletService_RetryRedPacketRefund_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RetryRedPacketRefundRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).RetryRedPacketRefund(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_RetryRedPacketRefund_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).RetryRedPacketRefund(ctx, req.(*RetryRedPacketRefundRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -2323,6 +2539,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ExpireRedPackets",
Handler: _WalletService_ExpireRedPackets_Handler,
},
{
MethodName: "RetryRedPacketRefund",
Handler: _WalletService_RetryRedPacketRefund_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet/v1/wallet.proto",

View File

@ -0,0 +1,272 @@
# 用户举报 Flutter 对接
本文描述 Flutter App 对接用户举报和房间举报的 HTTP 契约。通用文件上传接口用于上传举报图片并拿到 URL提交举报接口只接收 JSON。
## 基础地址
本地开发地址:
```text
http://127.0.0.1:13000
```
线上环境使用当前 App 配置里的 gateway base URL。下面所有路径都拼在 gateway base URL 后。
## 请求头
| Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token举报发起人只从 token 读取Flutter 不传举报发起人 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
| `Content-Type` | 提交举报必填 | `application/json` | 提交举报使用 JSON。 |
| `Content-Type` | 上传图片必填 | `multipart/form-data` | 上传举报图片使用 multipart。 |
统一响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {}
}
```
Flutter 只在 `code == "OK"` 时读取 `data`。失败时记录 `request_id`,用于后端排查。
## 调用顺序
```text
1. 用户在举报弹窗选择举报类型。
2. 如有举报图,逐张调用 POST /api/v1/files/upload收集每次返回的 data.url。
3. 调用 POST /api/v1/reports 提交举报。
4. 提交成功后关闭弹窗,并用 data.report_id 写本地日志。
```
## 上传举报图片
上传接口只负责把文件写入对象存储并返回 URL不创建举报单。
```http
POST /api/v1/files/upload
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: multipart/form-data
file=<binary>
```
请求字段:
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `file` | 是 | multipart file | 单文件最大 20MB。多图逐张上传Flutter 把每张图返回的 `data.url` 放进提交举报接口的 `image_urls`。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_upload_report_image",
"data": {
"url": "https://cdn.example.com/app/files/10001/20260529/file_xxx.jpg",
"object_key": "app/files/10001/20260529/file_xxx.jpg",
"content_type": "image/jpeg",
"size_bytes": 345678
}
}
```
Flutter 提交举报时只需要使用 `data.url`
## 提交举报
```http
POST /api/v1/reports
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"user_id": "10002",
"report_type": "pornography",
"reason": "用户在房间内发布违规内容",
"image_urls": [
"https://cdn.example.com/app/files/10001/20260529/file_xxx.jpg"
]
}
```
举报房间示例:
```http
POST /api/v1/reports
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "lalu_abc123",
"report_type": "fraud",
"reason": "房间引导线下交易",
"image_urls": []
}
```
请求字段:
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `user_id` | 条件必填 | string | 被举报用户 ID。和 `room_id` 二选一,只能传一个。 |
| `room_id` | 条件必填 | string | 被举报房间 ID。和 `user_id` 二选一,只能传一个。 |
| `report_type` | 是 | string | 举报类型,取值见下方枚举。 |
| `reason` | 否 | string | 举报理由Flutter 可允许用户不填。 |
| `image_urls` | 否 | string[] | 举报图 URL 数组;无图可传 `[]` 或不传。 |
目标校验规则:
| 场景 | 结果 |
| --- | --- |
| 只传 `user_id` | 举报用户。 |
| 只传 `room_id` | 举报房间。 |
| `user_id``room_id` 都不传 | `400 INVALID_ARGUMENT`。 |
| `user_id``room_id` 同时传 | `400 INVALID_ARGUMENT`Flutter 需要拆成一次明确的用户举报或房间举报。 |
举报发起人不在请求体里传。gateway 必须从 `Authorization` 对应的 access token 读取当前登录用户,避免客户端伪造举报发起人。
## 举报类型
| UI 文案 | `report_type` |
| --- | --- |
| Politics | `politics` |
| Pornography | `pornography` |
| Graphic Violence | `graphic_violence` |
| Verbal Abuse | `verbal_abuse` |
| Fraud | `fraud` |
| Illegal Content | `illegal_content` |
| Minors | `minors` |
| In-Person Transactions | `in_person_transactions` |
| Other | `other` |
## 返回体模拟数据
举报用户成功:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_report_user",
"data": {
"report_id": "rpt_10001_1778000000000",
"target_type": "user",
"user_id": "10002",
"room_id": "",
"report_type": "pornography",
"status": "submitted",
"created_at_ms": 1778000000000
}
}
```
举报房间成功:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_report_room",
"data": {
"report_id": "rpt_10001_1778000001000",
"target_type": "room",
"user_id": "",
"room_id": "lalu_abc123",
"report_type": "fraud",
"status": "submitted",
"created_at_ms": 1778000001000
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `report_id` | string | 举报单 IDFlutter 可用于本地日志和客服排查。 |
| `target_type` | string | `user``room`,由服务端按本次传入目标生成。 |
| `user_id` | string | 被举报用户 ID房间举报返回空字符串。 |
| `room_id` | string | 被举报房间 ID用户举报返回空字符串。 |
| `report_type` | string | 本次提交的举报类型。 |
| `status` | string | 首次提交固定返回 `submitted`。 |
| `created_at_ms` | int64 | 举报创建时间Unix epoch milliseconds。 |
## 错误处理
失败返回仍使用统一 envelope
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_abc"
}
```
| HTTP 状态码 | `code` | Flutter 处理 |
| --- | --- | --- |
| `400` | `INVALID_JSON` | JSON 格式错误,检查本地序列化逻辑。 |
| `400` | `INVALID_ARGUMENT` | `user_id/room_id` 二选一不满足、`report_type` 非法、图片 URL 格式非法或字段超长。 |
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效,重新登录。 |
| `403` | `PROFILE_REQUIRED` | 用户资料未完成,跳转资料补全流程。 |
| `404` | `NOT_FOUND` | 被举报用户不存在,刷新页面状态。 |
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用,可提示稍后重试。 |
## Flutter 数据模型建议
```dart
class SubmitReportRequest {
SubmitReportRequest({
this.userId,
this.roomId,
required this.reportType,
this.reason,
this.imageUrls = const [],
});
final String? userId;
final String? roomId;
final String reportType;
final String? reason;
final List<String> imageUrls;
Map<String, dynamic> toJson() => {
if (userId != null && userId!.isNotEmpty) 'user_id': userId,
if (roomId != null && roomId!.isNotEmpty) 'room_id': roomId,
'report_type': reportType,
if (reason != null && reason!.trim().isNotEmpty) 'reason': reason!.trim(),
if (imageUrls.isNotEmpty) 'image_urls': imageUrls,
};
}
```
提交前本地校验:
```dart
void validateReportTarget({String? userId, String? roomId}) {
final hasUser = userId != null && userId.trim().isNotEmpty;
final hasRoom = roomId != null && roomId.trim().isNotEmpty;
if (hasUser == hasRoom) {
throw ArgumentError('user_id and room_id must be exactly one');
}
}
```
## 实现边界
- Flutter 不传举报发起人 ID服务端只认 token 中的当前用户。
- `user_id` 表示被举报用户,不是举报发起人。
- `room_id` 表示被举报房间,不要求同时传房间内某个用户。
- `user_id``room_id` 必须二选一,不能同时提交一个混合举报。
- 举报图片先上传再提交 URL提交举报接口不接收 multipart 文件。
- `message` 不作为业务分支依据Flutter 只按 `code` 和 HTTP 状态处理。

View File

@ -37,7 +37,9 @@ import (
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
healthmodule "hyapp-admin-server/internal/modules/health"
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
jobmodule "hyapp-admin-server/internal/modules/job"
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
@ -47,11 +49,14 @@ import (
redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
reportmodule "hyapp-admin-server/internal/modules/report"
resourcemodule "hyapp-admin-server/internal/modules/resource"
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure"
searchmodule "hyapp-admin-server/internal/modules/search"
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy"
teamsalarysettlementmodule "hyapp-admin-server/internal/modules/teamsalarysettlement"
uploadmodule "hyapp-admin-server/internal/modules/upload"
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
@ -190,6 +195,12 @@ func main() {
}
auditHandler := auditmodule.New(store)
teamSalarySettlementHandler := teamsalarysettlementmodule.New(sqlDB, walletDB, userDB, auditHandler)
if cfg.Jobs.Enabled {
teamSalaryRunner := teamsalarysettlementmodule.NewAutoRunner(teamSalarySettlementHandler.Service(), "lalu", cfg.NodeID)
teamSalaryRunner.Start()
defer teamSalaryRunner.Close()
}
var objectUploader uploadmodule.ObjectUploader
if cfg.TencentCOS.Enabled {
uploader, err := tencentcos.NewUploader(tencentcos.Config{
@ -205,38 +216,43 @@ func main() {
objectUploader = uploader
}
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler),
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
Report: reportmodule.New(userDB, roomClient),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
RoomTreasure: roomtreasuremodule.New(roomClient, auditHandler),
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
TeamSalarySettlement: teamSalarySettlementHandler,
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
}
engine := router.New(cfg, auth, handlers)

View File

@ -46,6 +46,7 @@ type Client interface {
UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error)
ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error)
GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error)
RetryRedPacketRefund(ctx context.Context, req *walletv1.RetryRedPacketRefundRequest) (*walletv1.RetryRedPacketRefundResponse, error)
}
type GRPCClient struct {
@ -199,3 +200,7 @@ func (c *GRPCClient) ListRedPackets(ctx context.Context, req *walletv1.ListRedPa
func (c *GRPCClient) GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
return c.client.GetRedPacket(ctx, req)
}
func (c *GRPCClient) RetryRedPacketRefund(ctx context.Context, req *walletv1.RetryRedPacketRefundRequest) (*walletv1.RetryRedPacketRefundResponse, error) {
return c.client.RetryRedPacketRefund(ctx, req)
}

View File

@ -147,6 +147,108 @@ func (AppExploreTab) TableName() string {
return "admin_app_explore_tabs"
}
type HostAgencySalaryPolicy struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null;default:lalu" json:"appCode"`
Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"`
// RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。
RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null" json:"regionId"`
Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"`
// SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。
SettlementMode string `gorm:"size:24;not null;default:daily" json:"settlementMode"`
// SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。
SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"`
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
// 时间范围采用 epoch ms 半开区间EffectiveToMS=0 表示长期有效。
EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"`
EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"`
Description string `gorm:"size:255;not null;default:''" json:"description"`
CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"`
UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"`
// 发布字段只记录 admin 配置同步到 wallet 运行快照的结果;结算仍只读取 wallet-service 自己的运行表。
PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"`
PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"`
PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"`
PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
Levels []HostAgencySalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"`
}
func (HostAgencySalaryPolicy) TableName() string {
return "admin_host_agency_salary_policies"
}
type HostAgencySalaryLevel struct {
ID uint `gorm:"primaryKey" json:"id"`
PolicyID uint `gorm:"uniqueIndex:uk_admin_host_agency_salary_level;index:idx_admin_host_agency_salary_level_policy,not null" json:"policyId"`
// LevelNo 是累计工资差额结算的顺序锚点,必须在同一 policy 下唯一。
LevelNo int32 `gorm:"column:level_no;uniqueIndex:uk_admin_host_agency_salary_level;not null" json:"level"`
// RequiredDiamonds 保存主播当月钻石门槛,月底清空逻辑不在 admin-server 第一阶段执行。
RequiredDiamonds int64 `gorm:"column:required_diamonds;index:idx_admin_host_agency_salary_level_policy,not null" json:"requiredDiamonds"`
// 三个权益字段保存“达到该等级后的累计值”,结算任务只发本次等级与已结算等级之间的差额。
HostSalaryUSD string `gorm:"column:host_salary_usd;type:decimal(12,2);not null;default:0.00" json:"hostSalaryUsd"`
HostCoinReward int64 `gorm:"column:host_coin_reward;not null;default:0" json:"hostCoinReward"`
AgencySalaryUSD string `gorm:"column:agency_salary_usd;type:decimal(12,2);not null;default:0.00" json:"agencySalaryUsd"`
Status string `gorm:"size:24;not null;default:active" json:"status"`
SortOrder int32 `gorm:"column:sort_order;not null;default:0" json:"sortOrder"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (HostAgencySalaryLevel) TableName() string {
return "admin_host_agency_salary_policy_levels"
}
type TeamSalaryPolicy struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null;default:lalu" json:"appCode"`
// PolicyType 当前只允许 bd/admin拆表会让两类策略后续结算扩展变重所以用类型字段保持同一套配置 API。
PolicyType string `gorm:"column:policy_type;size:24;uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null" json:"policyType"`
Name string `gorm:"size:120;uniqueIndex:uk_admin_team_salary_policy_name;not null" json:"name"`
// RegionID 沿用 Host 政策的区域边界,保证同一区域组织链路可以命中同一套工资配置。
RegionID int64 `gorm:"uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null" json:"regionId"`
Status string `gorm:"size:24;index:idx_admin_team_salary_policy_scope,not null;default:disabled" json:"status"`
// SettlementTriggerMode 决定次月 5 日窗口由自动任务处理还是进入后台人工批量结算池。
SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"`
// BD/Admin 都按完整自然月统计,结算日期固定为次月 5 日;这里不再提供日结/半月结节奏字段。
EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_team_salary_policy_scope,not null;default:0" json:"effectiveFromMs"`
EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"`
Description string `gorm:"size:255;not null;default:''" json:"description"`
CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"`
UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
Levels []TeamSalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"`
}
func (TeamSalaryPolicy) TableName() string {
return "admin_team_salary_policies"
}
type TeamSalaryLevel struct {
ID uint `gorm:"primaryKey" json:"id"`
PolicyID uint `gorm:"uniqueIndex:uk_admin_team_salary_level;index:idx_admin_team_salary_level_policy,not null" json:"policyId"`
// LevelNo 是差额结算锚点,当前等级累计工资减去已结算累计工资就是本次应发金额。
LevelNo int32 `gorm:"column:level_no;uniqueIndex:uk_admin_team_salary_level;not null" json:"level"`
// ThresholdUSD 对 BD 表示下属 Agency 管理主播 Host Salary 合计门槛,对 Admin 表示下属 BD Salary 合计门槛。
ThresholdUSD string `gorm:"column:threshold_usd;type:decimal(12,2);index:idx_admin_team_salary_level_policy,not null;default:0.00" json:"thresholdUsd"`
// RatePercent 保存运营看到的百分比数字,例如 8 表示 8%,结算时再换算成比例。
RatePercent string `gorm:"column:rate_percent;type:decimal(9,4);not null;default:0.0000" json:"ratePercent"`
// SalaryUSD 是达到该等级后的累计应得工资,实际入账仍按差额结算。
SalaryUSD string `gorm:"column:salary_usd;type:decimal(12,2);not null;default:0.00" json:"salaryUsd"`
Status string `gorm:"size:24;not null;default:active" json:"status"`
SortOrder int32 `gorm:"column:sort_order;not null;default:0" json:"sortOrder"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (TeamSalaryLevel) TableName() string {
return "admin_team_salary_policy_levels"
}
type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"userId"`

View File

@ -0,0 +1,138 @@
package hostagencypolicy
import (
"database/sql"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
func New(store *repository.Store, walletDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(store, walletDB), audit: audit}
}
func (h *Handler) ListPolicies(c *gin.Context) {
options := shared.ListOptions(c)
// query 层保留 snake_case/camelCase 两种写法,兼容生成端和手写端调用。
items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
Status: options.Status,
SettlementMode: firstQuery(c, "settlement_mode", "settlementMode"),
// trigger mode 可直接筛出“只允许手动结算”的政策,后续工资结算页会复用同一查询口径。
SettlementTriggerMode: firstQuery(c, "settlement_trigger_mode", "settlementTriggerMode"),
Page: options.Page,
PageSize: options.PageSize,
})
if err != nil {
response.ServerError(c, "获取工资政策配置失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) CreatePolicy(c *gin.Context) {
var req policyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "工资政策参数不正确")
return
}
// 操作人只从登录上下文取,不信任请求体,方便后续审计定位配置变更来源。
item, err := h.service.Create(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID))
response.Created(c, item)
}
func (h *Handler) UpdatePolicy(c *gin.Context) {
id, ok := policyID(c)
if !ok {
return
}
var req policyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "工资政策参数不正确")
return
}
// PUT 语义是整条政策配置替换,等级明细也会由 service/repository 按整包替换。
item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID))
response.OK(c, item)
}
func (h *Handler) DeletePolicy(c *gin.Context) {
id, ok := policyID(c)
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), appctx.FromContext(c.Request.Context()), id); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "delete-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(id), 10), "success", "")
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) PublishPolicy(c *gin.Context) {
id, ok := policyID(c)
if !ok {
return
}
item, err := h.service.Publish(c.Request.Context(), appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "publish-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d status=%s", item.RegionID, item.Status))
response.OK(c, item)
}
func policyID(c *gin.Context) (uint, bool) {
value := strings.TrimSpace(c.Param("policy_id"))
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 {
response.BadRequest(c, "工资政策 ID 不正确")
return 0, false
}
return uint(parsed), true
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,25 @@
package hostagencypolicy
type policyRequest struct {
Name string `json:"name"`
RegionID int64 `json:"region_id"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
Description string `json:"description"`
Levels []levelRequest `json:"levels"`
}
type levelRequest struct {
Level int32 `json:"level"`
RequiredDiamonds int64 `json:"required_diamonds"`
HostSalaryUSD string `json:"host_salary_usd"`
HostCoinReward int64 `json:"host_coin_reward"`
AgencySalaryUSD string `json:"agency_salary_usd"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
}

View File

@ -0,0 +1,122 @@
package hostagencypolicy
import (
"strconv"
"hyapp-admin-server/internal/model"
)
type policyDTO struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
RegionID int64 `json:"region_id"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
Description string `json:"description"`
CreatedByAdminID uint `json:"created_by_admin_id"`
UpdatedByAdminID uint `json:"updated_by_admin_id"`
PublishStatus string `json:"publish_status"`
PublishError string `json:"publish_error"`
PublishedAtMS int64 `json:"published_at_ms"`
PublishedByAdminID uint `json:"published_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Levels []levelDTO `json:"levels"`
}
type levelDTO struct {
ID uint `json:"id"`
PolicyID uint `json:"policy_id"`
Level int32 `json:"level"`
RequiredDiamonds int64 `json:"required_diamonds"`
HostSalaryUSD string `json:"host_salary_usd"`
HostCoinReward int64 `json:"host_coin_reward"`
AgencySalaryUSD string `json:"agency_salary_usd"`
TotalSalaryUSD string `json:"total_salary_usd"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
levels := make([]levelDTO, 0, len(item.Levels))
for _, level := range item.Levels {
levels = append(levels, levelFromModel(level))
}
// 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。
return policyDTO{
ID: item.ID,
AppCode: item.AppCode,
Name: item.Name,
RegionID: item.RegionID,
Status: item.Status,
SettlementMode: item.SettlementMode,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
EffectiveFromMS: item.EffectiveFromMS,
EffectiveToMS: item.EffectiveToMS,
Description: item.Description,
CreatedByAdminID: item.CreatedByAdminID,
UpdatedByAdminID: item.UpdatedByAdminID,
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
PublishError: item.PublishError,
PublishedAtMS: item.PublishedAtMS,
PublishedByAdminID: item.PublishedByAdminID,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
Levels: levels,
}
}
func levelFromModel(item model.HostAgencySalaryLevel) levelDTO {
hostSalary := trimDecimalZeros(item.HostSalaryUSD)
agencySalary := trimDecimalZeros(item.AgencySalaryUSD)
// total_salary_usd 只是后台表格辅助展示字段,结算仍应分别读取主播工资和代理工资做各自入账。
return levelDTO{
ID: item.ID,
PolicyID: item.PolicyID,
Level: item.LevelNo,
RequiredDiamonds: item.RequiredDiamonds,
HostSalaryUSD: hostSalary,
HostCoinReward: item.HostCoinReward,
AgencySalaryUSD: agencySalary,
TotalSalaryUSD: formatCents(parseCentsOrZero(hostSalary) + parseCentsOrZero(agencySalary)),
Status: item.Status,
SortOrder: item.SortOrder,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
}
func parseCentsOrZero(value string) int64 {
// 复用服务层 decimal parser确保展示合计和入库金额使用同一套小数规则。
_, cents, err := parseFixedDecimal(value, 2, true, "salary")
if err != nil {
return 0
}
return cents
}
func formatCents(value int64) string {
whole := value / 100
fraction := value % 100
if fraction == 0 {
return strconv.FormatInt(whole, 10)
}
return strconv.FormatInt(whole, 10) + "." + twoDigits(fraction)
}
func twoDigits(value int64) string {
if value < 10 {
return "0" + strconv.FormatInt(value, 10)
}
return strconv.FormatInt(value, 10)
}

View File

@ -0,0 +1,19 @@
package hostagencypolicy
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/host-agency-policies", middleware.RequirePermission("host-agency-policy:view"), h.ListPolicies)
protected.POST("/admin/host-agency-policies", middleware.RequirePermission("host-agency-policy:create"), h.CreatePolicy)
protected.PUT("/admin/host-agency-policies/:policy_id", middleware.RequirePermission("host-agency-policy:update"), h.UpdatePolicy)
protected.POST("/admin/host-agency-policies/:policy_id/publish", middleware.RequirePermission("host-agency-policy:publish"), h.PublishPolicy)
protected.DELETE("/admin/host-agency-policies/:policy_id", middleware.RequirePermission("host-agency-policy:delete"), h.DeletePolicy)
}

View File

@ -0,0 +1,614 @@
package hostagencypolicy
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
const (
// active/disabled 只表达后台政策是否可用于结算匹配;历史结算记录不应因为停用政策被回写改口径。
policyStatusActive = "active"
policyStatusDisabled = "disabled"
// 第一阶段只配置可选结算节奏,真正的日结/半月结算调度会在后续结算任务里消费该字段。
settlementModeDaily = "daily"
settlementModeHalfMonth = "half_month"
// 自动触发由 cron-service 扫描结算;手动触发只进入后台待结算池,避免运营要求人工复核时被定时任务提前发放。
settlementTriggerAutomatic = "automatic"
settlementTriggerManual = "manual"
// 礼物金币转主播钻石默认 1:1月底剩余钻石转美元默认不开启由后台显式配置。
defaultGiftCoinToDiamondRatio = "1"
defaultResidualDiamondRate = "0"
publishStatusDraft = "draft"
publishStatusPublished = "published"
publishStatusFailed = "failed"
)
type Service struct {
store *repository.Store
walletDB *sql.DB
}
func NewService(store *repository.Store, walletDB *sql.DB) *Service {
return &Service{store: store, walletDB: walletDB}
}
func (s *Service) List(appCode string, options repository.HostAgencySalaryPolicyListOptions) ([]policyDTO, int64, error) {
// 列表入口统一收敛 app_code、状态和结算方式避免前端传别名或非法值时把无效条件下推到 DB。
options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeStatusFilter(options.Status)
options.SettlementMode = normalizeSettlementModeFilter(options.SettlementMode)
options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode)
items, total, err := s.store.ListHostAgencySalaryPolicies(options)
if err != nil {
return nil, 0, err
}
out := make([]policyDTO, 0, len(items))
for _, item := range items {
out = append(out, policyFromModel(item))
}
return out, total, nil
}
func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) {
// 创建时先把请求规整为模型,所有金额和等级递增规则都在这个边界完成,仓储层只负责持久化。
item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req)
if err != nil {
return policyDTO{}, err
}
// 启用政策必须先做时间段冲突校验,否则同一区域结算时会匹配到两套工资规则。
if err := s.ensureNoActiveOverlap(item, 0); err != nil {
return policyDTO{}, err
}
if err := s.store.CreateHostAgencySalaryPolicy(&item); err != nil {
return policyDTO{}, err
}
return policyFromModel(item), nil
}
func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) {
// 更新先读原记录,保证 app_code 和主键归属来自数据库,避免请求体越权改租户。
item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
if err != nil {
return policyDTO{}, err
}
// 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
if err != nil {
return policyDTO{}, err
}
item.Name = updated.Name
item.RegionID = updated.RegionID
item.Status = updated.Status
item.SettlementMode = updated.SettlementMode
item.SettlementTriggerMode = updated.SettlementTriggerMode
item.GiftCoinToDiamondRatio = updated.GiftCoinToDiamondRatio
item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
item.EffectiveFromMS = updated.EffectiveFromMS
item.EffectiveToMS = updated.EffectiveToMS
item.Description = updated.Description
item.UpdatedByAdminID = actorID
// 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft。
item.PublishStatus = publishStatusDraft
item.PublishError = ""
// 等级明细按整包替换处理:前端提交的是一套完整政策梯度,不做局部 patch减少排序和递增口径漂移。
item.Levels = updated.Levels
if err := s.ensureNoActiveOverlap(item, id); err != nil {
return policyDTO{}, err
}
if err := s.store.UpdateHostAgencySalaryPolicy(&item); err != nil {
return policyDTO{}, err
}
return policyFromModel(item), nil
}
func (s *Service) Delete(ctx context.Context, appCode string, id uint) error {
// 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。
if _, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id); err != nil {
return err
}
// 删除后台配置前先清掉 wallet 运行快照,避免 admin 记录消失后结算仍命中过期政策。
if err := s.deleteRuntimePolicy(ctx, appctx.Normalize(appCode), id); err != nil {
return err
}
return s.store.DeleteHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
}
func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id uint) (policyDTO, error) {
appCode = appctx.Normalize(appCode)
item, err := s.store.GetHostAgencySalaryPolicy(appCode, id)
if err != nil {
return policyDTO{}, err
}
if err := s.ensureNoActiveOverlap(item, id); err != nil {
return policyDTO{}, err
}
publishedAtMS := time.Now().UTC().UnixMilli()
if err := s.publishRuntimePolicy(ctx, item, publishedAtMS); err != nil {
// 发布失败也写回状态,方便后台列表明确展示“配置未进入 wallet 运行态”的事实。
_ = s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusFailed, truncateRunes(err.Error(), 255), item.PublishedAtMS, actorID)
return policyDTO{}, err
}
if err := s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusPublished, "", publishedAtMS, actorID); err != nil {
return policyDTO{}, err
}
item.PublishStatus = publishStatusPublished
item.PublishError = ""
item.PublishedAtMS = publishedAtMS
item.PublishedByAdminID = actorID
return policyFromModel(item), nil
}
func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, excludeID uint) error {
// 停用政策允许提前配置多套草稿;只有 active 才会参与结算匹配,所以冲突检查只约束启用态。
if item.Status != policyStatusActive {
return nil
}
// 时间段判断采用半开区间:[effective_from_ms, effective_to_ms)effective_to_ms=0 表示长期有效。
overlap, err := s.store.HasOverlappingActiveHostAgencySalaryPolicy(item.AppCode, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID)
if err != nil {
return err
}
if overlap {
return errors.New("同一区域同一时间只能存在一个启用政策")
}
return nil
}
func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgencySalaryPolicy, publishedAtMS int64) error {
if s == nil || s.walletDB == nil {
return errors.New("wallet mysql is not configured")
}
if len(item.Levels) == 0 {
return errors.New("至少需要配置一个等级")
}
tx, err := s.walletDB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// wallet-service 运行表以 admin policy_id 为主键;每次发布整包覆盖主表和等级,保证结算读取到一套完整梯度。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policies (
app_code, policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode,
gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate,
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
region_id = VALUES(region_id),
status = VALUES(status),
settlement_mode = VALUES(settlement_mode),
settlement_trigger_mode = VALUES(settlement_trigger_mode),
gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio),
residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate),
effective_from_ms = VALUES(effective_from_ms),
effective_to_ms = VALUES(effective_to_ms),
updated_at_ms = VALUES(updated_at_ms)`,
item.AppCode,
item.ID,
item.Name,
item.RegionID,
item.Status,
item.SettlementMode,
firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
item.GiftCoinToDiamondRatio,
item.ResidualDiamondToUSDRate,
item.EffectiveFromMS,
item.EffectiveToMS,
nonZeroMS(item.CreatedAtMS, publishedAtMS),
publishedAtMS,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, item.AppCode, item.ID); err != nil {
return err
}
for _, level := range item.Levels {
_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
if err != nil {
return err
}
_, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资")
if err != nil {
return err
}
// 运行表保存美分整数,避免 wallet-service 结算时再解析 decimal 字符串造成重复口径。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor,
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.AppCode,
item.ID,
level.LevelNo,
level.RequiredDiamonds,
hostSalaryMinor,
level.HostCoinReward,
agencySalaryMinor,
level.Status,
level.SortOrder,
nonZeroMS(level.CreatedAtMS, publishedAtMS),
publishedAtMS,
); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Service) deleteRuntimePolicy(ctx context.Context, appCode string, policyID uint) error {
if s == nil || s.walletDB == nil {
return errors.New("wallet mysql is not configured")
}
tx, err := s.walletDB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policies WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil {
return err
}
return tx.Commit()
}
func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.HostAgencySalaryPolicy, error) {
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > 120 {
return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确")
}
// 当前阶段保持单区域政策:一个区域同一时段只能命中一条 active 规则,后续结算查询也以 region_id 做索引。
if req.RegionID <= 0 {
return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域")
}
status := normalizeStatus(req.Status)
if status == "" {
// 新建默认停用,避免运营保存草稿时立即影响结算口径。
status = policyStatusDisabled
}
if !validStatus(status) {
return model.HostAgencySalaryPolicy{}, errors.New("政策状态不正确")
}
settlementMode := normalizeSettlementMode(req.SettlementMode)
if settlementMode == "" {
// 产品当前默认日结;半月结算只是调度节奏差异,等级和差额发放规则相同。
settlementMode = settlementModeDaily
}
if !validSettlementMode(settlementMode) {
return model.HostAgencySalaryPolicy{}, errors.New("结算方式不正确")
}
settlementTriggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode)
if settlementTriggerMode == "" {
// 默认自动触发,兼容现有政策和现有 cron 日结/半月结任务;需要人工复核时由后台显式切到 manual。
settlementTriggerMode = settlementTriggerAutomatic
}
if !validSettlementTriggerMode(settlementTriggerMode) {
return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确")
}
if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) {
return model.HostAgencySalaryPolicy{}, errors.New("政策生效时间不正确")
}
// 比例字段用 decimal 字符串入库,避免 float 在金币/钻石/美元换算中产生精度噪声。
giftRatio := firstNonBlank(req.GiftCoinToDiamondRatio, defaultGiftCoinToDiamondRatio)
giftRatio, _, err := parseFixedDecimal(giftRatio, 6, false, "金币转钻石比例")
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
residualRate := firstNonBlank(req.ResidualDiamondToUSDRate, defaultResidualDiamondRate)
residualRate, _, err = parseFixedDecimal(residualRate, 12, true, "剩余钻石转美元比例")
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
description := strings.TrimSpace(req.Description)
if len([]rune(description)) > 255 {
return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符")
}
// 等级表保存的是累计门槛和累计权益;实际发薪时应减去上一已结算等级,只发差额。
levels, err := levelModelsFromRequest(req.Levels)
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
return model.HostAgencySalaryPolicy{
AppCode: appCode,
Name: name,
RegionID: req.RegionID,
Status: status,
SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode,
GiftCoinToDiamondRatio: giftRatio,
ResidualDiamondToUSDRate: residualRate,
EffectiveFromMS: req.EffectiveFromMS,
EffectiveToMS: req.EffectiveToMS,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
Levels: levels,
}, nil
}
func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) {
if len(requests) == 0 {
return nil, errors.New("至少需要配置一个等级")
}
// 先按等级号排序再校验,允许前端在编辑过程中临时乱序提交,但最终持久化必须是稳定梯度。
levels := append([]levelRequest(nil), requests...)
sortLevelRequests(levels)
seen := map[int32]struct{}{}
out := make([]model.HostAgencySalaryLevel, 0, len(levels))
var previousRequiredDiamonds int64
var previousHostSalaryCents int64
var previousHostCoinReward int64
var previousAgencySalaryCents int64
for index, req := range levels {
// 等级号是结算差额的顺序锚点,不能为 0也不能重复。
if req.Level <= 0 {
return nil, errors.New("等级必须大于 0")
}
if _, ok := seen[req.Level]; ok {
return nil, fmt.Errorf("等级 %d 重复", req.Level)
}
seen[req.Level] = struct{}{}
if req.RequiredDiamonds <= 0 {
return nil, fmt.Errorf("等级 %d 所需钻石必须大于 0", req.Level)
}
// 钻石门槛必须严格递增,否则用户达到高等级时无法确定唯一命中等级。
if index > 0 && req.RequiredDiamonds <= previousRequiredDiamonds {
return nil, fmt.Errorf("等级 %d 所需钻石必须大于上一等级", req.Level)
}
// 主播美元工资是“累计应得”,后续结算以当前等级累计值减去已结算累计值发差额。
hostSalary, hostSalaryCents, err := parseFixedDecimal(req.HostSalaryUSD, 2, true, "主播美元工资")
if err != nil {
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
}
if index > 0 && hostSalaryCents < previousHostSalaryCents {
return nil, fmt.Errorf("等级 %d 主播美元工资不能小于上一等级", req.Level)
}
if req.HostCoinReward < 0 {
return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于 0", req.Level)
}
// 金币奖励同样是累计配置,不能比上一等级低,否则差额发放会变成负数。
if index > 0 && req.HostCoinReward < previousHostCoinReward {
return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于上一等级", req.Level)
}
// 代理美元工资跟随主播结算同时发放,也按累计值减已结算累计值计算差额。
agencySalary, agencySalaryCents, err := parseFixedDecimal(req.AgencySalaryUSD, 2, true, "代理美元工资")
if err != nil {
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
}
if index > 0 && agencySalaryCents < previousAgencySalaryCents {
return nil, fmt.Errorf("等级 %d 代理美元工资不能小于上一等级", req.Level)
}
status := normalizeStatus(req.Status)
if status == "" {
// 单个等级默认启用;需要临时屏蔽某个等级时由运营显式停用。
status = policyStatusActive
}
if !validStatus(status) {
return nil, fmt.Errorf("等级 %d 状态不正确", req.Level)
}
sortOrder := req.SortOrder
if sortOrder == 0 {
// 默认排序跟等级号一致,前端展示和后续结算扫描都能保持自然顺序。
sortOrder = req.Level
}
out = append(out, model.HostAgencySalaryLevel{
LevelNo: req.Level,
RequiredDiamonds: req.RequiredDiamonds,
HostSalaryUSD: hostSalary,
HostCoinReward: req.HostCoinReward,
AgencySalaryUSD: agencySalary,
Status: status,
SortOrder: sortOrder,
})
previousRequiredDiamonds = req.RequiredDiamonds
previousHostSalaryCents = hostSalaryCents
previousHostCoinReward = req.HostCoinReward
previousAgencySalaryCents = agencySalaryCents
}
return out, nil
}
func sortLevelRequests(items []levelRequest) {
for i := 1; i < len(items); i++ {
item := items[i]
j := i - 1
for j >= 0 && items[j].Level > item.Level {
items[j+1] = items[j]
j--
}
items[j+1] = item
}
}
func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) {
value := strings.TrimSpace(raw)
if strings.HasPrefix(value, "+") {
value = strings.TrimPrefix(value, "+")
}
if value == "" || strings.HasPrefix(value, "-") {
return "", 0, fmt.Errorf("%s不正确", field)
}
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) {
return "", 0, fmt.Errorf("%s不正确", field)
}
if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) {
return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale)
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return "", 0, fmt.Errorf("%s过大", field)
}
multiplier := pow10(scale)
if whole > (1<<62)/multiplier {
return "", 0, fmt.Errorf("%s过大", field)
}
var fraction int64
if len(parts) == 2 {
fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1]))
fraction, err = strconv.ParseInt(fractionText, 10, 64)
if err != nil {
return "", 0, fmt.Errorf("%s不正确", field)
}
}
scaled := whole*multiplier + fraction
if !allowZero && scaled <= 0 {
return "", 0, fmt.Errorf("%s必须大于 0", field)
}
return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil
}
func fixedDigits(value int64, scale int) string {
text := strconv.FormatInt(value, 10)
if len(text) >= scale {
return text
}
return strings.Repeat("0", scale-len(text)) + text
}
func trimDecimalZeros(value string) string {
value = strings.TrimSpace(value)
if !strings.Contains(value, ".") {
return value
}
value = strings.TrimRight(value, "0")
return strings.TrimRight(value, ".")
}
func pow10(scale int) int64 {
var out int64 = 1
for i := 0; i < scale; i++ {
out *= 10
}
return out
}
func allDigits(value string) bool {
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func nonZeroMS(value int64, fallback int64) int64 {
if value > 0 {
return value
}
return fallback
}
func truncateRunes(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(value))
if len(runes) <= limit {
return string(runes)
}
return string(runes[:limit])
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", policyStatusActive, "enabled":
if strings.TrimSpace(value) == "" {
return ""
}
return policyStatusActive
case policyStatusDisabled, "inactive":
return policyStatusDisabled
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeStatusFilter(value string) string {
status := normalizeStatus(value)
if !validStatus(status) {
return ""
}
return status
}
func validStatus(value string) bool {
return value == policyStatusActive || value == policyStatusDisabled
}
func normalizeSettlementMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", settlementModeDaily, "day":
if strings.TrimSpace(value) == "" {
return ""
}
return settlementModeDaily
case settlementModeHalfMonth, "half-month", "semi_month":
return settlementModeHalfMonth
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeSettlementModeFilter(value string) string {
mode := normalizeSettlementMode(value)
if !validSettlementMode(mode) {
return ""
}
return mode
}
func validSettlementMode(value string) bool {
return value == settlementModeDaily || value == settlementModeHalfMonth
}
func normalizeSettlementTriggerMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", settlementTriggerAutomatic, "auto":
if strings.TrimSpace(value) == "" {
return ""
}
return settlementTriggerAutomatic
case settlementTriggerManual, "manual_settlement":
return settlementTriggerManual
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeSettlementTriggerModeFilter(value string) string {
mode := normalizeSettlementTriggerMode(value)
if !validSettlementTriggerMode(mode) {
return ""
}
return mode
}
func validSettlementTriggerMode(value string) bool {
return value == settlementTriggerAutomatic || value == settlementTriggerManual
}

View File

@ -0,0 +1,95 @@
package hostagencypolicy
import "testing"
func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
item, err := policyModelFromRequest("lalu", 7, policyRequest{
Name: "Lalu 默认政策",
RegionID: 101,
Status: "active",
SettlementMode: "half-month",
SettlementTriggerMode: "manual",
GiftCoinToDiamondRatio: "1",
ResidualDiamondToUSDRate: "0.000001",
Levels: []levelRequest{
{Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err != nil {
t.Fatalf("policy should be valid: %v", err)
}
if item.AppCode != "lalu" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual {
t.Fatalf("policy fields mismatch: %+v", item)
}
if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
t.Fatalf("ratio fields mismatch: %+v", item)
}
if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 {
t.Fatalf("levels should be sorted: %+v", item.Levels)
}
if item.Levels[0].HostSalaryUSD != "1.50" || item.Levels[1].AgencySalaryUSD != "0.80" {
t.Fatalf("salary fields should be fixed decimals: %+v", item.Levels)
}
}
func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid",
RegionID: 101,
SettlementMode: settlementModeDaily,
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
{Level: 2, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err == nil {
t.Fatal("expected non-increasing levels to fail")
}
}
func TestPolicyModelFromRequestDefaultsSettlementTriggerMode(t *testing.T) {
item, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Default trigger",
RegionID: 101,
SettlementMode: settlementModeDaily,
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err != nil {
t.Fatalf("policy should be valid: %v", err)
}
if item.SettlementTriggerMode != settlementTriggerAutomatic {
t.Fatalf("default trigger mismatch: %+v", item)
}
}
func TestPolicyModelFromRequestRejectsInvalidSettlementMode(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid",
RegionID: 101,
SettlementMode: "monthly",
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err == nil {
t.Fatal("expected invalid settlement mode to fail")
}
}
func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid",
RegionID: 101,
SettlementMode: settlementModeDaily,
SettlementTriggerMode: "cron",
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err == nil {
t.Fatal("expected invalid settlement trigger mode to fail")
}
}

View File

@ -0,0 +1,46 @@
package hostsalarysettlement
type userDTO struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type settlementDTO struct {
SettlementID string `json:"settlement_id"`
CommandID string `json:"command_id"`
TransactionID string `json:"transaction_id"`
SettlementType string `json:"settlement_type"`
UserID string `json:"user_id"`
User userDTO `json:"user"`
AgencyOwnerUserID string `json:"agency_owner_user_id"`
AgencyOwner userDTO `json:"agency_owner"`
CycleKey string `json:"cycle_key"`
PolicyID uint64 `json:"policy_id"`
LevelNo int32 `json:"level_no"`
TotalDiamonds int64 `json:"total_diamonds"`
HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"`
HostSalaryUSDDelta string `json:"host_salary_usd_delta"`
HostCoinRewardDelta int64 `json:"host_coin_reward_delta"`
AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"`
AgencySalaryUSDDelta string `json:"agency_salary_usd_delta"`
ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"`
ResidualUSDDelta string `json:"residual_usd_delta"`
Status string `json:"status"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type query struct {
Page int
PageSize int
CycleKey string
SettlementType string
Status string
UserID int64
AgencyOwnerUserID int64
PolicyID uint64
StartAtMS int64
EndAtMS int64
}

View File

@ -0,0 +1,115 @@
package hostsalarysettlement
import (
"database/sql"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(walletDB *sql.DB, userDB *sql.DB) *Handler {
return &Handler{service: NewService(walletDB, userDB)}
}
func (h *Handler) List(c *gin.Context) {
req, ok := parseQuery(c)
if !ok {
return
}
items, total, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
if err != nil {
response.ServerError(c, "获取工资结算记录失败")
return
}
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
}
func parseQuery(c *gin.Context) (query, bool) {
options := shared.ListOptions(c)
userID, ok := optionalInt64(c, "user_id", "userId")
if !ok {
response.BadRequest(c, "主播用户 ID 不正确")
return query{}, false
}
agencyOwnerUserID, ok := optionalInt64(c, "agency_owner_user_id", "agencyOwnerUserId")
if !ok {
response.BadRequest(c, "代理用户 ID 不正确")
return query{}, false
}
policyID, ok := optionalUint64(c, "policy_id", "policyId")
if !ok {
response.BadRequest(c, "政策 ID 不正确")
return query{}, false
}
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return query{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return query{}, false
}
if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS {
response.BadRequest(c, "时间区间不正确")
return query{}, false
}
req := normalizeQuery(query{
Page: options.Page,
PageSize: options.PageSize,
CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")),
SettlementType: normalizeSettlementType(firstQuery(c, "settlement_type", "settlementType")),
Status: normalizeStatus(firstQuery(c, "status")),
UserID: userID,
AgencyOwnerUserID: agencyOwnerUserID,
PolicyID: policyID,
StartAtMS: startAtMS,
EndAtMS: endAtMS,
})
if rawType := strings.TrimSpace(firstQuery(c, "settlement_type", "settlementType")); rawType != "" && strings.ToLower(rawType) != "all" && req.SettlementType == "" {
response.BadRequest(c, "结算类型不正确")
return query{}, false
}
if rawStatus := strings.TrimSpace(firstQuery(c, "status")); rawStatus != "" && strings.ToLower(rawStatus) != "all" && req.Status == "" {
response.BadRequest(c, "结算状态不正确")
return query{}, false
}
return req, true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
return parsed, err == nil && parsed >= 0
}
func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseUint(value, 10, 64)
return parsed, err == nil
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,15 @@
package hostsalarysettlement
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/host-salary-settlements", middleware.RequirePermission("host-salary-settlement:view"), h.List)
}

View File

@ -0,0 +1,290 @@
package hostsalarysettlement
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
)
const maxPageSize = 100
type Service struct {
walletDB *sql.DB
userDB *sql.DB
}
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
func NewService(walletDB *sql.DB, userDB *sql.DB) *Service {
return &Service{walletDB: walletDB, userDB: userDB}
}
func (s *Service) List(ctx context.Context, appCode string, req query) ([]settlementDTO, int64, error) {
if s == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
}
req = normalizeQuery(req)
whereSQL, args := settlementWhere(appCode, req)
var total int64
if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM host_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.walletDB.QueryContext(ctx, `
SELECT settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id,
cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta,
host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta,
status, reason, created_at_ms
FROM host_salary_settlement_records
`+whereSQL+`
ORDER BY created_at_ms DESC, settlement_id DESC
LIMIT ? OFFSET ?`,
append(args, req.PageSize, offset(req.Page, req.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]settlementDTO, 0, req.PageSize)
userIDs := make([]int64, 0, req.PageSize*2)
for rows.Next() {
var item settlementDTO
var userID int64
var agencyOwnerUserID int64
if err := rows.Scan(
&item.SettlementID,
&item.CommandID,
&item.TransactionID,
&item.SettlementType,
&userID,
&agencyOwnerUserID,
&item.CycleKey,
&item.PolicyID,
&item.LevelNo,
&item.TotalDiamonds,
&item.HostSalaryUSDMinorDelta,
&item.HostCoinRewardDelta,
&item.AgencySalaryUSDMinorDelta,
&item.ResidualUSDMinorDelta,
&item.Status,
&item.Reason,
&item.CreatedAtMS,
); err != nil {
return nil, 0, err
}
item.UserID = strconv.FormatInt(userID, 10)
item.AgencyOwnerUserID = formatOptionalUserID(agencyOwnerUserID)
item.User = userDTO{UserID: item.UserID}
item.AgencyOwner = userDTO{UserID: item.AgencyOwnerUserID}
item.HostSalaryUSDDelta = formatUSDMinor(item.HostSalaryUSDMinorDelta)
item.AgencySalaryUSDDelta = formatUSDMinor(item.AgencySalaryUSDMinorDelta)
item.ResidualUSDDelta = formatUSDMinor(item.ResidualUSDMinorDelta)
items = append(items, item)
if userID > 0 {
userIDs = append(userIDs, userID)
}
if agencyOwnerUserID > 0 {
userIDs = append(userIDs, agencyOwnerUserID)
}
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, 0, err
}
for i := range items {
if userID, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil {
items[i].User = userDTOFromProfile(items[i].UserID, profiles[userID])
}
if agencyID, err := strconv.ParseInt(items[i].AgencyOwnerUserID, 10, 64); err == nil && agencyID > 0 {
items[i].AgencyOwner = userDTOFromProfile(items[i].AgencyOwnerUserID, profiles[agencyID])
}
}
return items, total, nil
}
func settlementWhere(appCode string, req query) (string, []any) {
conditions := []string{"app_code = ?"}
args := []any{strings.TrimSpace(appCode)}
if req.CycleKey != "" {
conditions = append(conditions, "cycle_key = ?")
args = append(args, req.CycleKey)
}
if req.SettlementType != "" {
conditions = append(conditions, "settlement_type = ?")
args = append(args, req.SettlementType)
}
if req.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, req.Status)
}
if req.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, req.UserID)
}
if req.AgencyOwnerUserID > 0 {
conditions = append(conditions, "agency_owner_user_id = ?")
args = append(args, req.AgencyOwnerUserID)
}
if req.PolicyID > 0 {
conditions = append(conditions, "policy_id = ?")
args = append(args, req.PolicyID)
}
if req.StartAtMS > 0 {
conditions = append(conditions, "created_at_ms >= ?")
args = append(args, req.StartAtMS)
}
if req.EndAtMS > 0 {
conditions = append(conditions, "created_at_ms < ?")
args = append(args, req.EndAtMS)
}
// 查询只读 wallet-service 结算事实表,不回推钱包分录;账务对账需要单独走 entries/transactions 明细。
return "WHERE " + strings.Join(conditions, " AND "), args
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
if s == nil || s.userDB == nil || len(userIDs) == 0 {
return map[int64]userProfile{}, nil
}
uniqueIDs := uniquePositiveUserIDs(userIDs)
if len(uniqueIDs) == 0 {
return map[int64]userProfile{}, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
args := make([]any, 0, len(uniqueIDs)+1)
args = append(args, strings.TrimSpace(appCode))
for _, id := range uniqueIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders+`)`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
profiles := make(map[int64]userProfile, len(uniqueIDs))
for rows.Next() {
var item userProfile
if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil {
return nil, err
}
profiles[item.UserID] = item
}
return profiles, rows.Err()
}
func normalizeQuery(req query) query {
req.CycleKey = strings.TrimSpace(req.CycleKey)
req.SettlementType = normalizeSettlementType(req.SettlementType)
req.Status = normalizeStatus(req.Status)
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 50
}
if req.PageSize > maxPageSize {
req.PageSize = maxPageSize
}
return req
}
func normalizeSettlementType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case "daily", "day":
return "daily"
case "half_month", "half-month", "halfmonth":
return "half_month"
case "month_end", "month-end", "monthend":
return "month_end"
default:
return ""
}
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case "succeeded", "skipped", "failed":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func uniquePositiveUserIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func userDTOFromProfile(fallbackID string, profile userProfile) userDTO {
if profile.UserID <= 0 {
return userDTO{UserID: fallbackID}
}
return userDTO{
UserID: strconv.FormatInt(profile.UserID, 10),
DisplayUserID: profile.DisplayUserID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
func formatOptionalUserID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func formatUSDMinor(value int64) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
whole := value / 100
fraction := value % 100
if fraction == 0 {
return sign + strconv.FormatInt(whole, 10)
}
if fraction < 10 {
return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10)
}
return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10)
}
func offset(page int, pageSize int) int {
if page <= 1 {
return 0
}
return (page - 1) * pageSize
}

View File

@ -26,10 +26,16 @@ func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
type configRequest struct {
Enabled bool `json:"enabled"`
CountTiers []int32 `json:"count_tiers"`
CountOptions []int32 `json:"countOptions"`
AmountTiers []int64 `json:"amount_tiers"`
AmountOptions []int64 `json:"amountOptions"`
DelayedOpenSeconds int32 `json:"delayed_open_seconds"`
DelaySeconds int32 `json:"delaySeconds"`
ExpireSeconds int32 `json:"expire_seconds"`
DailySendLimit int32 `json:"daily_send_limit"`
DailySendLimitV2 int32 `json:"dailySendLimit"`
RuleURL string `json:"rule_url"`
RuleURLV2 string `json:"ruleUrl"`
}
type configDTO struct {
@ -40,6 +46,7 @@ type configDTO struct {
DelayedOpenSeconds int32 `json:"delayed_open_seconds"`
ExpireSeconds int32 `json:"expire_seconds"`
DailySendLimit int32 `json:"daily_send_limit"`
RuleURL string `json:"rule_url"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
@ -62,9 +69,17 @@ type packetDTO struct {
ExpiresAtMS int64 `json:"expires_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RefundAmount int64 `json:"refund_amount"`
RefundStatus string `json:"refund_status"`
RefundedAtMS int64 `json:"refunded_at_ms"`
Claims []claimDTO `json:"claims,omitempty"`
}
type refundRetryRequest struct {
PacketNo string `json:"packetNo"`
PacketID string `json:"packet_id"`
}
type claimDTO struct {
AppCode string `json:"app_code"`
ClaimID string `json:"claim_id"`
@ -104,15 +119,36 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
response.BadRequest(c, "红包配置参数不正确")
return
}
countTiers := req.CountTiers
if len(countTiers) == 0 {
countTiers = req.CountOptions
}
amountTiers := req.AmountTiers
if len(amountTiers) == 0 {
amountTiers = req.AmountOptions
}
delaySeconds := req.DelayedOpenSeconds
if delaySeconds == 0 {
delaySeconds = req.DelaySeconds
}
dailyLimit := req.DailySendLimit
if dailyLimit == 0 {
dailyLimit = req.DailySendLimitV2
}
ruleURL := strings.TrimSpace(req.RuleURL)
if ruleURL == "" {
ruleURL = strings.TrimSpace(req.RuleURLV2)
}
resp, err := h.wallet.UpdateRedPacketConfig(c.Request.Context(), &walletv1.UpdateRedPacketConfigRequest{
AppCode: appctx.FromContext(c.Request.Context()),
Enabled: req.Enabled,
CountTiers: req.CountTiers,
AmountTiers: req.AmountTiers,
DelayedOpenSeconds: req.DelayedOpenSeconds,
ExpireSeconds: req.ExpireSeconds,
DailySendLimit: req.DailySendLimit,
CountTiers: countTiers,
AmountTiers: amountTiers,
DelayedOpenSeconds: delaySeconds,
ExpireSeconds: 86400,
DailySendLimit: dailyLimit,
OperatorUserId: int64(middleware.CurrentUserID(c)),
RuleUrl: ruleURL,
})
if err != nil {
response.BadRequest(c, err.Error())
@ -136,10 +172,16 @@ func (h *Handler) ListPackets(c *gin.Context) {
Status: strings.TrimSpace(options.Status),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
SenderUserId: parseInt64Query(c, "sender_user_id"),
SenderUserId: firstInt64Query(c, "senderUserId", "sender_user_id"),
RegionId: parseInt64Query(c, "region_id"),
StartAtMs: parseInt64Query(c, "start_at_ms"),
EndAtMs: parseInt64Query(c, "end_at_ms"),
StartAtMs: firstInt64Query(c, "startTime", "start_at_ms"),
EndAtMs: firstInt64Query(c, "endTime", "end_at_ms"),
}
if roomID := strings.TrimSpace(c.Query("roomId")); roomID != "" {
req.RoomId = roomID
}
if packetMode := strings.TrimSpace(c.Query("packetMode")); packetMode != "" {
req.PacketType = packetMode
}
resp, err := h.wallet.ListRedPackets(c.Request.Context(), req)
if err != nil {
@ -175,6 +217,41 @@ func (h *Handler) GetPacket(c *gin.Context) {
response.OK(c, packetFromProto(resp.GetPacket()))
}
func (h *Handler) RetryRefund(c *gin.Context) {
if h.wallet == nil {
response.ServerError(c, "钱包服务未配置")
return
}
var req refundRetryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "红包退款参数不正确")
return
}
packetID := strings.TrimSpace(req.PacketNo)
if packetID == "" {
packetID = strings.TrimSpace(req.PacketID)
}
if packetID == "" {
response.BadRequest(c, "红包 ID 不正确")
return
}
resp, err := h.wallet.RetryRedPacketRefund(c.Request.Context(), &walletv1.RetryRedPacketRefundRequest{
AppCode: appctx.FromContext(c.Request.Context()),
PacketId: packetID,
OperatorUserId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "retry-red-packet-refund", "red_packets", packetID, "success", "")
response.OK(c, gin.H{
"packet": packetFromProto(resp.GetPacket()),
"refundedAmount": resp.GetRefundedAmount(),
"serverTime": resp.GetServerTimeMs(),
})
}
func configFromProto(config *walletv1.RedPacketConfig) configDTO {
if config == nil {
return configDTO{}
@ -187,6 +264,7 @@ func configFromProto(config *walletv1.RedPacketConfig) configDTO {
DelayedOpenSeconds: config.GetDelayedOpenSeconds(),
ExpireSeconds: config.GetExpireSeconds(),
DailySendLimit: config.GetDailySendLimit(),
RuleURL: config.GetRuleUrl(),
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
@ -218,6 +296,9 @@ func packetFromProto(packet *walletv1.RedPacket) packetDTO {
ExpiresAtMS: packet.GetExpiresAtMs(),
CreatedAtMS: packet.GetCreatedAtMs(),
UpdatedAtMS: packet.GetUpdatedAtMs(),
RefundAmount: packet.GetRefundAmount(),
RefundStatus: packet.GetRefundStatus(),
RefundedAtMS: packet.GetRefundedAtMs(),
Claims: claims,
}
}
@ -241,6 +322,15 @@ func claimFromProto(claim *walletv1.RedPacketClaim) claimDTO {
}
}
func firstInt64Query(c *gin.Context, keys ...string) int64 {
for _, key := range keys {
if value := parseInt64Query(c, key); value > 0 {
return value
}
}
return 0
}
func parseInt64Query(c *gin.Context, key string) int64 {
value := strings.TrimSpace(c.Query(key))
if value == "" {

View File

@ -10,8 +10,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:view"), h.GetConfig)
protected.PUT("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:update"), h.UpdateConfig)
protected.GET("/admin/activity/red-packets", middleware.RequirePermission("red-packet:view"), h.ListPackets)
protected.GET("/admin/activity/red-packets/:packet_id", middleware.RequirePermission("red-packet:view"), h.GetPacket)
protected.GET("/resident-activity/voice-room-red-packet/config", middleware.RequirePermission("red-packet:view"), h.GetConfig)
protected.POST("/resident-activity/voice-room-red-packet/config", middleware.RequirePermission("red-packet:update"), h.UpdateConfig)
protected.GET("/resident-activity/voice-room-red-packet/records", middleware.RequirePermission("red-packet:view"), h.ListPackets)
protected.GET("/resident-activity/voice-room-red-packet/records/:packet_id", middleware.RequirePermission("red-packet:view"), h.GetPacket)
protected.POST("/resident-activity/voice-room-red-packet/refund/retry", middleware.RequirePermission("red-packet:update"), h.RetryRefund)
}

View File

@ -0,0 +1,83 @@
package report
import (
"database/sql"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(userDB *sql.DB, roomClient roomclient.Client) *Handler {
return &Handler{service: NewService(userDB, roomClient)}
}
func (h *Handler) ListReports(c *gin.Context) {
query, ok := parseListQuery(c)
if !ok {
return
}
items, total, err := h.service.ListReports(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
if err != nil {
response.ServerError(c, "获取举报列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return listQuery{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return listQuery{}, false
}
if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS {
response.BadRequest(c, "时间区间不正确")
return listQuery{}, false
}
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
TargetType: firstQuery(c, "target_type", "targetType"),
ReportType: firstQuery(c, "report_type", "reportType"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}), 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)
if err != nil || parsed < 0 {
return 0, false
}
return parsed, true
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,11 @@
package report
type listQuery struct {
Page int
PageSize int
Keyword string
TargetType string
ReportType string
StartAtMS int64
EndAtMS int64
}

View File

@ -0,0 +1,38 @@
package report
type reportUserDTO struct {
Avatar string `json:"avatar"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserID string `json:"displayUserId"`
UserID string `json:"userId"`
Username string `json:"username"`
}
type reportRoomDTO struct {
CoverURL string `json:"coverUrl"`
RoomID string `json:"roomId"`
RoomShortID string `json:"roomShortId"`
Title string `json:"title"`
}
type reportTargetDTO struct {
Type string `json:"type"`
Room *reportRoomDTO `json:"room,omitempty"`
User *reportUserDTO `json:"user,omitempty"`
}
type reportDTO struct {
CreatedAtMS int64 `json:"createdAtMs"`
ImageURLs []string `json:"imageUrls"`
Reason string `json:"reason"`
ReportID string `json:"reportId"`
ReporterUserID string `json:"reporterUserId"`
ReportType string `json:"reportType"`
RequestID string `json:"requestId"`
RoomID string `json:"roomId"`
Status string `json:"status"`
Target reportTargetDTO `json:"target"`
TargetType string `json:"targetType"`
UpdatedAtMS int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
}

View File

@ -0,0 +1,15 @@
package report
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/operations/reports", middleware.RequirePermission("report:view"), h.ListReports)
}

View File

@ -0,0 +1,274 @@
package report
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/integration/roomclient"
)
const (
targetTypeUser = "user"
targetTypeRoom = "room"
)
type Service struct {
roomClient roomclient.Client
userDB *sql.DB
}
func NewService(userDB *sql.DB, roomClient roomclient.Client) *Service {
return &Service{userDB: userDB, roomClient: roomClient}
}
func (s *Service) ListReports(ctx context.Context, appCode string, query listQuery) ([]reportDTO, int64, error) {
query = normalizeListQuery(query)
if s == nil || s.userDB == nil {
return nil, 0, fmt.Errorf("user mysql is not configured")
}
whereSQL, args := reportWhere(appCode, query)
var total int64
if err := s.userDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM user_reports r
LEFT JOIN users u ON u.app_code = r.app_code AND u.user_id = r.target_user_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT r.report_id, r.reporter_user_id, r.target_type, r.target_user_id, r.room_id,
r.report_type, r.reason, CAST(r.image_urls_json AS CHAR), r.status, r.request_id,
r.created_at_ms, r.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.default_display_user_id, ''),
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM user_reports r
LEFT JOIN users u ON u.app_code = r.app_code AND u.user_id = r.target_user_id
`+whereSQL+`
ORDER BY r.created_at_ms DESC, r.report_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]reportDTO, 0, query.PageSize)
for rows.Next() {
var item reportDTO
var reporterUserID int64
var targetUserID sql.NullInt64
var imageURLsJSON string
var displayUserID string
var defaultDisplayUserID string
var username string
var avatar string
if err := rows.Scan(
&item.ReportID,
&reporterUserID,
&item.TargetType,
&targetUserID,
&item.RoomID,
&item.ReportType,
&item.Reason,
&imageURLsJSON,
&item.Status,
&item.RequestID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&displayUserID,
&defaultDisplayUserID,
&username,
&avatar,
); err != nil {
return nil, 0, err
}
imageURLs, err := parseImageURLs(imageURLsJSON)
if err != nil {
return nil, 0, err
}
item.ReporterUserID = formatID(reporterUserID)
item.ImageURLs = imageURLs
item.Target = reportTargetDTO{Type: item.TargetType}
if targetUserID.Valid && targetUserID.Int64 > 0 {
item.UserID = formatID(targetUserID.Int64)
item.Target.User = &reportUserDTO{
Avatar: avatar,
DefaultDisplayUserID: defaultDisplayUserID,
DisplayUserID: displayUserID,
UserID: item.UserID,
Username: username,
}
}
if item.TargetType == targetTypeRoom {
item.Target.Room = &reportRoomDTO{RoomID: item.RoomID}
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := s.fillRoomTargets(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.TargetType = normalizeTargetType(query.TargetType)
query.ReportType = strings.ToLower(strings.TrimSpace(query.ReportType))
if query.StartAtMS < 0 {
query.StartAtMS = 0
}
if query.EndAtMS < 0 {
query.EndAtMS = 0
}
return query
}
func normalizeTargetType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case targetTypeUser, "用户":
return targetTypeUser
case targetTypeRoom, "房间":
return targetTypeRoom
default:
return ""
}
}
func reportWhere(appCode string, query listQuery) (string, []any) {
conditions := []string{"r.app_code = ?"}
args := []any{appCode}
if query.TargetType != "" {
conditions = append(conditions, "r.target_type = ?")
args = append(args, query.TargetType)
}
if query.ReportType != "" {
conditions = append(conditions, "r.report_type = ?")
args = append(args, query.ReportType)
}
if query.StartAtMS > 0 {
conditions = append(conditions, "r.created_at_ms >= ?")
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
conditions = append(conditions, "r.created_at_ms < ?")
args = append(args, query.EndAtMS)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
conditions = append(conditions, `(r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)`)
args = append(args, like, like, like, like, like, like, like)
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
func parseImageURLs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return []string{}, nil
}
var urls []string
if err := json.Unmarshal([]byte(raw), &urls); err != nil {
return nil, err
}
if urls == nil {
return []string{}, nil
}
return urls, nil
}
func (s *Service) fillRoomTargets(ctx context.Context, items []reportDTO) error {
if s == nil || s.roomClient == nil || len(items) == 0 {
return nil
}
roomIDs := make([]string, 0, len(items))
for _, item := range items {
if item.TargetType == targetTypeRoom && strings.TrimSpace(item.RoomID) != "" {
roomIDs = append(roomIDs, item.RoomID)
}
}
rooms, err := s.queryRooms(ctx, uniqueStrings(roomIDs))
if err != nil {
return err
}
for index := range items {
if items[index].TargetType != targetTypeRoom {
continue
}
room := reportRoomDTO{RoomID: items[index].RoomID}
if found, ok := rooms[items[index].RoomID]; ok {
room = found
}
items[index].Target.Room = &room
}
return nil
}
func (s *Service) queryRooms(ctx context.Context, roomIDs []string) (map[string]reportRoomDTO, error) {
rooms := make(map[string]reportRoomDTO, len(roomIDs))
for _, roomID := range roomIDs {
room, err := s.roomClient.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: roomID})
if err != nil {
// 举报列表是治理入口,房间被删或房间服务短暂不可读时仍保留举报事实本身。
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
rooms[roomID] = reportRoomDTO{
CoverURL: room.CoverURL,
RoomID: room.RoomID,
RoomShortID: room.RoomShortID,
Title: room.Title,
}
}
return rooms, nil
}
func formatID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}
func offset(page int, pageSize int) int {
return (page - 1) * pageSize
}

View File

@ -0,0 +1,38 @@
package report
import "testing"
func TestNormalizeListQueryCapsPageSizeAndFilters(t *testing.T) {
query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, Keyword: " abc ", TargetType: "用户", ReportType: " Fraud "})
if query.Page != 1 || query.PageSize != 100 || query.Keyword != "abc" || query.TargetType != targetTypeUser || query.ReportType != "fraud" {
t.Fatalf("normalized query mismatch: %+v", query)
}
}
func TestReportWhereUsesStableFilters(t *testing.T) {
query := listQuery{
Keyword: "10001",
TargetType: targetTypeRoom,
ReportType: "fraud",
StartAtMS: 100,
EndAtMS: 200,
}
where, args := reportWhere("lalu", query)
want := "WHERE r.app_code = ? AND r.target_type = ? AND r.report_type = ? AND r.created_at_ms >= ? AND r.created_at_ms < ? AND (r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)"
if where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 12 || args[0] != "lalu" || args[1] != targetTypeRoom || args[2] != "fraud" || args[3] != int64(100) || args[4] != int64(200) || args[5] != "%10001%" || args[11] != "%10001%" {
t.Fatalf("args mismatch: %#v", args)
}
}
func TestParseImageURLsNormalizesEmptyJSON(t *testing.T) {
urls, err := parseImageURLs("null")
if err != nil {
t.Fatalf("parse images failed: %v", err)
}
if len(urls) != 0 {
t.Fatalf("empty images mismatch: %#v", urls)
}
}

View File

@ -0,0 +1,121 @@
package teamsalarypolicy
import (
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
func New(store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(store), audit: audit}
}
func (h *Handler) ListPolicies(c *gin.Context) {
options := shared.ListOptions(c)
policyType := firstQuery(c, "policy_type", "policyType")
items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.TeamSalaryPolicyListOptions{
PolicyType: policyType,
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
Status: options.Status,
SettlementTriggerMode: firstQuery(c, "settlement_trigger_mode", "settlementTriggerMode"),
Page: options.Page,
PageSize: options.PageSize,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) CreatePolicy(c *gin.Context) {
var req policyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "工资政策参数不正确")
return
}
item, err := h.service.Create(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("policy_type=%s region_id=%d", item.PolicyType, item.RegionID))
response.Created(c, item)
}
func (h *Handler) UpdatePolicy(c *gin.Context) {
id, ok := policyID(c)
if !ok {
return
}
var req policyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "工资政策参数不正确")
return
}
item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("policy_type=%s region_id=%d", item.PolicyType, item.RegionID))
response.OK(c, item)
}
func (h *Handler) DeletePolicy(c *gin.Context) {
id, ok := policyID(c)
if !ok {
return
}
policyType := firstQuery(c, "policy_type", "policyType")
if err := h.service.Delete(appctx.FromContext(c.Request.Context()), policyType, id); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "delete-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(id), 10), "success", fmt.Sprintf("policy_type=%s", normalizePolicyType(policyType)))
response.OK(c, gin.H{"deleted": true})
}
func policyID(c *gin.Context) (uint, bool) {
value := strings.TrimSpace(c.Param("policy_id"))
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 {
response.BadRequest(c, "工资政策 ID 不正确")
return 0, false
}
return uint(parsed), true
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,22 @@
package teamsalarypolicy
type policyRequest struct {
PolicyType string `json:"policy_type"`
Name string `json:"name"`
RegionID int64 `json:"region_id"`
Status string `json:"status"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
Description string `json:"description"`
Levels []levelRequest `json:"levels"`
}
type levelRequest struct {
Level int32 `json:"level"`
ThresholdUSD string `json:"threshold_usd"`
RatePercent string `json:"rate_percent"`
SalaryUSD string `json:"salary_usd"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
}

View File

@ -0,0 +1,74 @@
package teamsalarypolicy
import "hyapp-admin-server/internal/model"
type policyDTO struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
PolicyType string `json:"policy_type"`
Name string `json:"name"`
RegionID int64 `json:"region_id"`
Status string `json:"status"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
Description string `json:"description"`
CreatedByAdminID uint `json:"created_by_admin_id"`
UpdatedByAdminID uint `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Levels []levelDTO `json:"levels"`
}
type levelDTO struct {
ID uint `json:"id"`
PolicyID uint `json:"policy_id"`
Level int32 `json:"level"`
ThresholdUSD string `json:"threshold_usd"`
RatePercent string `json:"rate_percent"`
SalaryUSD string `json:"salary_usd"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func policyFromModel(item model.TeamSalaryPolicy) policyDTO {
levels := make([]levelDTO, 0, len(item.Levels))
for _, level := range item.Levels {
levels = append(levels, levelFromModel(level))
}
// 响应层去掉 decimal 尾零,前端输入框仍以字符串处理金额和百分比,避免 JS 浮点误差。
return policyDTO{
ID: item.ID,
AppCode: item.AppCode,
PolicyType: item.PolicyType,
Name: item.Name,
RegionID: item.RegionID,
Status: item.Status,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
EffectiveFromMS: item.EffectiveFromMS,
EffectiveToMS: item.EffectiveToMS,
Description: item.Description,
CreatedByAdminID: item.CreatedByAdminID,
UpdatedByAdminID: item.UpdatedByAdminID,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
Levels: levels,
}
}
func levelFromModel(item model.TeamSalaryLevel) levelDTO {
return levelDTO{
ID: item.ID,
PolicyID: item.PolicyID,
Level: item.LevelNo,
ThresholdUSD: trimDecimalZeros(item.ThresholdUSD),
RatePercent: trimDecimalZeros(item.RatePercent),
SalaryUSD: trimDecimalZeros(item.SalaryUSD),
Status: item.Status,
SortOrder: item.SortOrder,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
}

View File

@ -0,0 +1,17 @@
package teamsalarypolicy
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/team-salary-policies", middleware.RequirePermission("team-salary-policy:view"), h.ListPolicies)
protected.POST("/admin/team-salary-policies", middleware.RequirePermission("team-salary-policy:create"), h.CreatePolicy)
protected.PUT("/admin/team-salary-policies/:policy_id", middleware.RequirePermission("team-salary-policy:update"), h.UpdatePolicy)
protected.DELETE("/admin/team-salary-policies/:policy_id", middleware.RequirePermission("team-salary-policy:delete"), h.DeletePolicy)
}

View File

@ -0,0 +1,406 @@
package teamsalarypolicy
import (
"errors"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
const (
policyTypeBD = "bd"
policyTypeAdmin = "admin"
policyStatusActive = "active"
policyStatusDisabled = "disabled"
settlementTriggerAutomatic = "automatic"
settlementTriggerManual = "manual"
)
type Service struct {
store *repository.Store
}
func NewService(store *repository.Store) *Service {
return &Service{store: store}
}
func (s *Service) List(appCode string, options repository.TeamSalaryPolicyListOptions) ([]policyDTO, int64, error) {
options.AppCode = appctx.Normalize(appCode)
options.PolicyType = normalizePolicyType(options.PolicyType)
if options.PolicyType == "" {
return nil, 0, errors.New("政策类型不正确")
}
options.Status = normalizeStatusFilter(options.Status)
options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode)
items, total, err := s.store.ListTeamSalaryPolicies(options)
if err != nil {
return nil, 0, err
}
out := make([]policyDTO, 0, len(items))
for _, item := range items {
out = append(out, policyFromModel(item))
}
return out, total, nil
}
func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) {
item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req)
if err != nil {
return policyDTO{}, err
}
if err := s.ensureNoActiveOverlap(item, 0); err != nil {
return policyDTO{}, err
}
if err := s.store.CreateTeamSalaryPolicy(&item); err != nil {
return policyDTO{}, err
}
return policyFromModel(item), nil
}
func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) {
policyType := normalizePolicyType(req.PolicyType)
if policyType == "" {
return policyDTO{}, errors.New("政策类型不正确")
}
item, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id)
if err != nil {
return policyDTO{}, err
}
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
if err != nil {
return policyDTO{}, err
}
item.Name = updated.Name
item.RegionID = updated.RegionID
item.Status = updated.Status
item.SettlementTriggerMode = updated.SettlementTriggerMode
item.EffectiveFromMS = updated.EffectiveFromMS
item.EffectiveToMS = updated.EffectiveToMS
item.Description = updated.Description
item.UpdatedByAdminID = actorID
item.Levels = updated.Levels
if err := s.ensureNoActiveOverlap(item, id); err != nil {
return policyDTO{}, err
}
if err := s.store.UpdateTeamSalaryPolicy(&item); err != nil {
return policyDTO{}, err
}
return policyFromModel(item), nil
}
func (s *Service) Delete(appCode string, policyType string, id uint) error {
policyType = normalizePolicyType(policyType)
if policyType == "" {
return errors.New("政策类型不正确")
}
if _, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id); err != nil {
return err
}
return s.store.DeleteTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id)
}
func (s *Service) ensureNoActiveOverlap(item model.TeamSalaryPolicy, excludeID uint) error {
if item.Status != policyStatusActive {
return nil
}
overlap, err := s.store.HasOverlappingActiveTeamSalaryPolicy(item.AppCode, item.PolicyType, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID)
if err != nil {
return err
}
if overlap {
return errors.New("同一区域同一时间只能存在一个启用政策")
}
return nil
}
func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.TeamSalaryPolicy, error) {
policyType := normalizePolicyType(req.PolicyType)
if policyType == "" {
return model.TeamSalaryPolicy{}, errors.New("政策类型不正确")
}
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > 120 {
return model.TeamSalaryPolicy{}, errors.New("政策名称不正确")
}
if req.RegionID <= 0 {
return model.TeamSalaryPolicy{}, errors.New("请选择适用区域")
}
status := normalizeStatus(req.Status)
if status == "" {
// BD/Admin 政策默认先保存为停用,运营确认等级表无误后再启用,避免自动结算读到半成品。
status = policyStatusDisabled
}
if !validStatus(status) {
return model.TeamSalaryPolicy{}, errors.New("政策状态不正确")
}
triggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode)
if triggerMode == "" {
triggerMode = settlementTriggerAutomatic
}
if !validSettlementTriggerMode(triggerMode) {
return model.TeamSalaryPolicy{}, errors.New("结算触发方式不正确")
}
if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) {
return model.TeamSalaryPolicy{}, errors.New("政策生效时间不正确")
}
description := strings.TrimSpace(req.Description)
if len([]rune(description)) > 255 {
return model.TeamSalaryPolicy{}, errors.New("备注不能超过 255 个字符")
}
levels, err := levelModelsFromRequest(policyType, req.Levels)
if err != nil {
return model.TeamSalaryPolicy{}, err
}
return model.TeamSalaryPolicy{
AppCode: appCode,
PolicyType: policyType,
Name: name,
RegionID: req.RegionID,
Status: status,
SettlementTriggerMode: triggerMode,
EffectiveFromMS: req.EffectiveFromMS,
EffectiveToMS: req.EffectiveToMS,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
Levels: levels,
}, nil
}
func levelModelsFromRequest(policyType string, requests []levelRequest) ([]model.TeamSalaryLevel, error) {
if len(requests) == 0 {
return nil, errors.New("至少需要配置一个等级")
}
levels := append([]levelRequest(nil), requests...)
sortLevelRequests(levels)
seen := map[int32]struct{}{}
out := make([]model.TeamSalaryLevel, 0, len(levels))
var previousThresholdCents int64
var previousSalaryCents int64
for index, req := range levels {
if req.Level <= 0 {
return nil, errors.New("等级必须大于 0")
}
if _, ok := seen[req.Level]; ok {
return nil, fmt.Errorf("等级 %d 重复", req.Level)
}
seen[req.Level] = struct{}{}
threshold, thresholdCents, err := parseFixedDecimal(req.ThresholdUSD, 2, false, thresholdFieldName(policyType))
if err != nil {
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
}
if index > 0 && thresholdCents <= previousThresholdCents {
return nil, fmt.Errorf("等级 %d 收入门槛必须大于上一等级", req.Level)
}
// rate_percent 保存百分比文本,例如 8 表示 8%;允许 4 位小数应对后续更细比例。
rate, _, err := parseFixedDecimal(req.RatePercent, 4, false, "提成比例")
if err != nil {
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
}
salary, salaryCents, err := parseFixedDecimal(req.SalaryUSD, 2, true, salaryFieldName(policyType))
if err != nil {
return nil, fmt.Errorf("等级 %d %w", req.Level, err)
}
if index > 0 && salaryCents < previousSalaryCents {
return nil, fmt.Errorf("等级 %d 累计工资不能小于上一等级", req.Level)
}
status := normalizeStatus(req.Status)
if status == "" {
status = policyStatusActive
}
if !validStatus(status) {
return nil, fmt.Errorf("等级 %d 状态不正确", req.Level)
}
sortOrder := req.SortOrder
if sortOrder == 0 {
sortOrder = req.Level
}
out = append(out, model.TeamSalaryLevel{
LevelNo: req.Level,
ThresholdUSD: threshold,
RatePercent: rate,
SalaryUSD: salary,
Status: status,
SortOrder: sortOrder,
})
previousThresholdCents = thresholdCents
previousSalaryCents = salaryCents
}
return out, nil
}
func thresholdFieldName(policyType string) string {
if policyType == policyTypeAdmin {
return "BD工资门槛"
}
return "下属主播工资门槛"
}
func salaryFieldName(policyType string) string {
if policyType == policyTypeAdmin {
return "Admin工资"
}
return "BD工资"
}
func sortLevelRequests(items []levelRequest) {
for i := 1; i < len(items); i++ {
item := items[i]
j := i - 1
for j >= 0 && items[j].Level > item.Level {
items[j+1] = items[j]
j--
}
items[j+1] = item
}
}
func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) {
value := strings.TrimSpace(raw)
if strings.HasPrefix(value, "+") {
value = strings.TrimPrefix(value, "+")
}
if value == "" || strings.HasPrefix(value, "-") {
return "", 0, fmt.Errorf("%s不正确", field)
}
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) {
return "", 0, fmt.Errorf("%s不正确", field)
}
if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) {
return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale)
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return "", 0, fmt.Errorf("%s过大", field)
}
multiplier := pow10(scale)
if whole > (1<<62)/multiplier {
return "", 0, fmt.Errorf("%s过大", field)
}
var fraction int64
if len(parts) == 2 {
fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1]))
fraction, err = strconv.ParseInt(fractionText, 10, 64)
if err != nil {
return "", 0, fmt.Errorf("%s不正确", field)
}
}
scaled := whole*multiplier + fraction
if !allowZero && scaled <= 0 {
return "", 0, fmt.Errorf("%s必须大于 0", field)
}
return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil
}
func fixedDigits(value int64, scale int) string {
text := strconv.FormatInt(value, 10)
if len(text) >= scale {
return text
}
return strings.Repeat("0", scale-len(text)) + text
}
func trimDecimalZeros(value string) string {
value = strings.TrimSpace(value)
if !strings.Contains(value, ".") {
return value
}
value = strings.TrimRight(value, "0")
return strings.TrimRight(value, ".")
}
func pow10(scale int) int64 {
var out int64 = 1
for i := 0; i < scale; i++ {
out *= 10
}
return out
}
func allDigits(value string) bool {
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func normalizePolicyType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case policyTypeBD:
return policyTypeBD
case policyTypeAdmin, "super_admin", "super-admin":
return policyTypeAdmin
default:
return ""
}
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", policyStatusActive, "enabled":
if strings.TrimSpace(value) == "" {
return ""
}
return policyStatusActive
case policyStatusDisabled, "inactive":
return policyStatusDisabled
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeStatusFilter(value string) string {
status := normalizeStatus(value)
if !validStatus(status) {
return ""
}
return status
}
func validStatus(value string) bool {
return value == policyStatusActive || value == policyStatusDisabled
}
func normalizeSettlementTriggerMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", settlementTriggerAutomatic, "auto":
if strings.TrimSpace(value) == "" {
return ""
}
return settlementTriggerAutomatic
case settlementTriggerManual, "manual_settlement":
return settlementTriggerManual
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeSettlementTriggerModeFilter(value string) string {
mode := normalizeSettlementTriggerMode(value)
if !validSettlementTriggerMode(mode) {
return ""
}
return mode
}
func validSettlementTriggerMode(value string) bool {
return value == settlementTriggerAutomatic || value == settlementTriggerManual
}

View File

@ -0,0 +1,58 @@
package teamsalarypolicy
import "testing"
func TestPolicyModelFromRequestBuildsBDLevels(t *testing.T) {
item, err := policyModelFromRequest("lalu", 7, policyRequest{
PolicyType: "bd",
Name: "BD 默认政策",
RegionID: 101,
Status: "active",
SettlementTriggerMode: "manual",
Levels: []levelRequest{
{Level: 2, ThresholdUSD: "200", RatePercent: "8", SalaryUSD: "16"},
{Level: 1, ThresholdUSD: "100", RatePercent: "8", SalaryUSD: "8"},
},
})
if err != nil {
t.Fatalf("policy should be valid: %v", err)
}
if item.PolicyType != policyTypeBD || item.CreatedByAdminID != 7 || item.SettlementTriggerMode != settlementTriggerManual {
t.Fatalf("policy fields mismatch: %+v", item)
}
if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].ThresholdUSD != "200.00" {
t.Fatalf("levels should be sorted and normalized: %+v", item.Levels)
}
if item.Levels[0].RatePercent != "8.0000" || item.Levels[1].SalaryUSD != "16.00" {
t.Fatalf("level decimals mismatch: %+v", item.Levels)
}
}
func TestPolicyModelFromRequestRejectsNonIncreasingThreshold(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
PolicyType: "admin",
Name: "Invalid",
RegionID: 101,
Levels: []levelRequest{
{Level: 1, ThresholdUSD: "500", RatePercent: "20", SalaryUSD: "100"},
{Level: 2, ThresholdUSD: "200", RatePercent: "20", SalaryUSD: "200"},
},
})
if err == nil {
t.Fatal("expected non-increasing threshold to fail")
}
}
func TestPolicyModelFromRequestRejectsInvalidPolicyType(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
PolicyType: "host",
Name: "Invalid",
RegionID: 101,
Levels: []levelRequest{
{Level: 1, ThresholdUSD: "100", RatePercent: "8", SalaryUSD: "8"},
},
})
if err == nil {
t.Fatal("expected invalid policy type to fail")
}
}

View File

@ -0,0 +1,98 @@
package teamsalarysettlement
import (
"context"
"log/slog"
"strings"
"sync"
"time"
)
const autoRunnerInterval = time.Hour
// AutoRunner 是 admin-server 内的轻量自动结算循环;真正的幂等不靠 runner而靠 wallet 结算进度表。
type AutoRunner struct {
service *Service
appCode string
worker string
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewAutoRunner 只保存运行参数,不立刻启动 goroutine便于 main 按 jobs.enabled 控制生命周期。
func NewAutoRunner(service *Service, appCode string, worker string) *AutoRunner {
return &AutoRunner{
service: service,
appCode: normalizeAppCode(appCode),
worker: strings.TrimSpace(worker),
}
}
// Start 启动后台循环;重复调用不创建第二个 worker避免同进程内重复扫描。
func (r *AutoRunner) Start() {
if r == nil || r.service == nil || r.cancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
r.cancel = cancel
r.wg.Add(1)
go r.loop(ctx)
}
// Close 停止后台循环并等待退出,保证 admin-server 优雅关闭时不会截断正在执行的事务。
func (r *AutoRunner) Close() {
if r == nil || r.cancel == nil {
return
}
r.cancel()
r.wg.Wait()
r.cancel = nil
}
// loop 启动后先立即检查一次,再按小时检查;是否真正发薪由 RunAutomaticDue 按日期判断。
func (r *AutoRunner) loop(ctx context.Context) {
defer r.wg.Done()
r.runOnce(ctx, time.Now())
ticker := time.NewTicker(autoRunnerInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
r.runOnce(ctx, now)
}
}
}
// runOnce 给单次自动结算设置超时,避免数据库异常时 goroutine 永久卡住。
func (r *AutoRunner) runOnce(parent context.Context, now time.Time) {
ctx, cancel := context.WithTimeout(parent, 10*time.Minute)
defer cancel()
// 多节点同时触发时不单独抢锁;结算进度行和 command_id 唯一索引会保证同一周期同一用户只发一次。
result, err := r.service.RunAutomaticDue(ctx, r.appCode, now)
if err != nil {
slog.Error("team_salary_auto_settlement_failed", "worker", r.worker, "error", err)
return
}
if !result.Due {
return
}
slog.Info("team_salary_auto_settlement_completed",
"worker", r.worker,
"cycle_key", result.CycleKey,
"bd_success", result.Results[policyTypeBD].SuccessCount,
"bd_skipped", result.Results[policyTypeBD].SkippedCount,
"admin_success", result.Results[policyTypeAdmin].SuccessCount,
"admin_skipped", result.Results[policyTypeAdmin].SkippedCount,
)
}
// normalizeAppCode 兜底默认应用编码,保证自动任务和后台手动入口都使用同一 app 维度。
func normalizeAppCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "lalu"
}
return value
}

View File

@ -0,0 +1,102 @@
package teamsalarysettlement
type userDTO struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type pendingDTO struct {
PolicyType string `json:"policy_type"`
UserID string `json:"user_id"`
User userDTO `json:"user"`
CycleKey string `json:"cycle_key"`
RegionID int64 `json:"region_id"`
RegionName string `json:"region_name"`
PolicyID uint64 `json:"policy_id"`
PolicyName string `json:"policy_name"`
LevelNo int32 `json:"level_no"`
IncomeUSDMinor int64 `json:"income_usd_minor"`
IncomeUSD string `json:"income_usd"`
SalaryUSDMinorTarget int64 `json:"salary_usd_minor_target"`
SalaryUSDTarget string `json:"salary_usd_target"`
SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"`
SalaryUSDDelta string `json:"salary_usd_delta"`
SettledLevelNo int32 `json:"settled_level_no"`
SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"`
SettledSalaryUSD string `json:"settled_salary_usd"`
LastSettledIncomeUSDMinor int64 `json:"last_settled_income_usd_minor"`
LastSettledIncomeUSD string `json:"last_settled_income_usd"`
MonthClosedAtMS int64 `json:"month_closed_at_ms"`
}
type recordDTO struct {
SettlementID string `json:"settlement_id"`
CommandID string `json:"command_id"`
TransactionID string `json:"transaction_id"`
PolicyType string `json:"policy_type"`
TriggerMode string `json:"trigger_mode"`
UserID string `json:"user_id"`
User userDTO `json:"user"`
CycleKey string `json:"cycle_key"`
RegionID int64 `json:"region_id"`
PolicyID uint64 `json:"policy_id"`
LevelNo int32 `json:"level_no"`
IncomeUSDMinor int64 `json:"income_usd_minor"`
IncomeUSD string `json:"income_usd"`
SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"`
SalaryUSDDelta string `json:"salary_usd_delta"`
Status string `json:"status"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type pendingQuery struct {
Page int
PageSize int
PolicyType string
TriggerMode string
CycleKey string
RegionID int64
CountryID int64
CountryCode string
UserID int64
}
type recordQuery struct {
Page int
PageSize int
PolicyType string
TriggerMode string
CycleKey string
Status string
RegionID int64
CountryID int64
CountryCode string
UserID int64
PolicyID uint64
StartAtMS int64
EndAtMS int64
}
type settleRequest struct {
PolicyType string `json:"policy_type"`
TriggerMode string `json:"trigger_mode"`
CycleKey string `json:"cycle_key"`
RegionID int64 `json:"region_id"`
CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code"`
UserIDs []int64 `json:"user_ids"`
}
type settleResultDTO struct {
PolicyType string `json:"policy_type"`
CycleKey string `json:"cycle_key"`
RequestedCount int `json:"requested_count"`
ProcessedCount int `json:"processed_count"`
SuccessCount int `json:"success_count"`
SkippedCount int `json:"skipped_count"`
FailureCount int `json:"failure_count"`
Records []recordDTO `json:"records"`
}

View File

@ -0,0 +1,209 @@
package teamsalarysettlement
import (
"database/sql"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
audit shared.OperationLogger
service *Service
}
// New 只做 HTTP 层组装,具体工资口径全部下沉到 Service避免 handler 里复制账务逻辑。
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{audit: audit, service: NewService(adminDB, walletDB, userDB)}
}
// Service 暴露给自动任务 runner 复用同一个结算引擎,保证手动和自动结算走同一套规则。
func (h *Handler) Service() *Service {
if h == nil {
return nil
}
return h.service
}
// ListPending 返回当前筛选条件下可产生正向工资差值的 BD/Admin 用户。
func (h *Handler) ListPending(c *gin.Context) {
req, ok := parsePendingQuery(c)
if !ok {
return
}
items, total, err := h.service.ListPending(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
if err != nil {
response.ServerError(c, "获取待结算工资失败")
return
}
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
}
// ListRecords 返回已经生成 settlement_id 的 BD/Admin 结算记录,用于后台查账。
func (h *Handler) ListRecords(c *gin.Context) {
req, ok := parseRecordQuery(c)
if !ok {
return
}
items, total, err := h.service.ListRecords(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
if err != nil {
response.ServerError(c, "获取 BD/Admin 结算记录失败")
return
}
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
}
// Settle 执行后台批量手动结算;前端传入 user_ids 后Service 会再次按候选白名单过滤。
func (h *Handler) Settle(c *gin.Context) {
var req settleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结算参数不正确")
return
}
req.PolicyType = normalizePolicyType(req.PolicyType)
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
req.CycleKey = strings.TrimSpace(req.CycleKey)
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
if req.PolicyType == "" {
response.BadRequest(c, "工资类型不正确")
return
}
if req.TriggerMode == "" {
response.BadRequest(c, "触发方式不正确")
return
}
result, err := h.service.Settle(c.Request.Context(), appctx.FromContext(c.Request.Context()), int64(shared.ActorFromContext(c).UserID), req)
if err != nil {
response.ServerError(c, "工资结算失败")
return
}
shared.OperationLog(c, h.audit, "settle-team-salary", "team_salary_settlement_records", "success", fmt.Sprintf("policy_type=%s cycle_key=%s success=%d skipped=%d failed=%d", result.PolicyType, result.CycleKey, result.SuccessCount, result.SkippedCount, result.FailureCount))
response.OK(c, result)
}
// parsePendingQuery 兼容 snake_case/camelCase 查询参数,便于前端直接传 API DTO 或内部模型字段。
func parsePendingQuery(c *gin.Context) (pendingQuery, bool) {
options := shared.ListOptions(c)
req := pendingQuery{
Page: options.Page,
PageSize: options.PageSize,
PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")),
TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")),
CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")),
CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))),
}
var ok bool
if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok {
response.BadRequest(c, "区域 ID 不正确")
return pendingQuery{}, false
}
if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok {
response.BadRequest(c, "国家 ID 不正确")
return pendingQuery{}, false
}
if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok {
response.BadRequest(c, "用户 ID 不正确")
return pendingQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" {
response.BadRequest(c, "工资类型不正确")
return pendingQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" {
response.BadRequest(c, "触发方式不正确")
return pendingQuery{}, false
}
return normalizePendingQuery(req), true
}
// parseRecordQuery 统一记录列表的分页、枚举和时间区间校验,避免把非法筛选传到 SQL 组装层。
func parseRecordQuery(c *gin.Context) (recordQuery, bool) {
options := shared.ListOptions(c)
req := recordQuery{
Page: options.Page,
PageSize: options.PageSize,
PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")),
TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")),
CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")),
Status: normalizeStatus(firstQuery(c, "status")),
CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))),
}
var ok bool
if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok {
response.BadRequest(c, "区域 ID 不正确")
return recordQuery{}, false
}
if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok {
response.BadRequest(c, "国家 ID 不正确")
return recordQuery{}, false
}
if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok {
response.BadRequest(c, "用户 ID 不正确")
return recordQuery{}, false
}
if req.PolicyID, ok = optionalUint64(c, "policy_id", "policyId"); !ok {
response.BadRequest(c, "政策 ID 不正确")
return recordQuery{}, false
}
if req.StartAtMS, ok = optionalInt64(c, "start_at_ms", "startAtMs"); !ok {
response.BadRequest(c, "开始时间不正确")
return recordQuery{}, false
}
if req.EndAtMS, ok = optionalInt64(c, "end_at_ms", "endAtMs"); !ok {
response.BadRequest(c, "结束时间不正确")
return recordQuery{}, false
}
if req.StartAtMS > 0 && req.EndAtMS > 0 && req.StartAtMS >= req.EndAtMS {
response.BadRequest(c, "时间区间不正确")
return recordQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" {
response.BadRequest(c, "工资类型不正确")
return recordQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" {
response.BadRequest(c, "触发方式不正确")
return recordQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "status")); raw != "" && strings.ToLower(raw) != "all" && req.Status == "" {
response.BadRequest(c, "状态不正确")
return recordQuery{}, false
}
return normalizeRecordQuery(req), true
}
// optionalInt64 解析可选正整数查询参数;空值代表不筛选。
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
return parsed, err == nil && parsed >= 0
}
// optionalUint64 解析可选无符号整数,当前用于 policy_id。
func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseUint(value, 10, 64)
return parsed, err == nil
}
// firstQuery 按优先级读取同义查询参数,避免每个字段重复写 snake/camel fallback。
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,18 @@
package teamsalarysettlement
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
// 三个接口共用“工资结算”菜单权限view 看待结算/记录settle 才能真正写钱包和结算记录。
protected.GET("/admin/team-salary-settlements/pending", middleware.RequirePermission("host-salary-settlement:view"), h.ListPending)
protected.POST("/admin/team-salary-settlements/settle", middleware.RequirePermission("host-salary-settlement:settle"), h.Settle)
protected.GET("/admin/team-salary-settlements/records", middleware.RequirePermission("host-salary-settlement:view"), h.ListRecords)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,381 @@
package teamsalarysettlement
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
mysqlDriver "github.com/go-sql-driver/mysql"
)
func TestRealMySQLTeamSalaryFullFlow(t *testing.T) {
baseDSN := strings.TrimSpace(os.Getenv("TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN"))
if baseDSN == "" {
t.Skip("set TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN to run the real MySQL BD/Admin salary flow")
}
ctx := context.Background()
adminDB := newTeamSalaryTestDB(t, baseDSN, "admin")
userDB := newTeamSalaryTestDB(t, baseDSN, "user")
walletDB := newTeamSalaryTestDB(t, baseDSN, "wallet")
execAll(t, adminDB, teamSalaryAdminDDL()...)
execAll(t, userDB, teamSalaryUserDDL()...)
execAll(t, walletDB, teamSalaryWalletDDL()...)
seedTeamSalaryFlow(t, adminDB, userDB, walletDB)
service := NewService(adminDB, walletDB, userDB)
pendingBD, totalBD, err := service.ListPending(ctx, "lalu", pendingQuery{
PolicyType: policyTypeBD,
TriggerMode: triggerAutomatic,
CycleKey: "2026-01",
})
if err != nil {
t.Fatalf("list bd pending failed: %v", err)
}
if totalBD != 1 || len(pendingBD) != 1 {
t.Fatalf("bd pending count mismatch: total=%d len=%d", totalBD, len(pendingBD))
}
if pendingBD[0].IncomeUSDMinor != 1_000_000 || pendingBD[0].SalaryUSDMinorDelta != 100_000 {
t.Fatalf("bd pending amount mismatch: income=%d salary_delta=%d", pendingBD[0].IncomeUSDMinor, pendingBD[0].SalaryUSDMinorDelta)
}
firstRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 8, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("first automatic settlement failed: %v", err)
}
if !firstRun.Due || firstRun.CycleKey != "2026-01" {
t.Fatalf("automatic due state mismatch: due=%v cycle=%s", firstRun.Due, firstRun.CycleKey)
}
if firstRun.Results[policyTypeBD].SuccessCount != 1 || firstRun.Results[policyTypeAdmin].SuccessCount != 1 {
t.Fatalf("first settlement success mismatch: bd=%+v admin=%+v", firstRun.Results[policyTypeBD], firstRun.Results[policyTypeAdmin])
}
secondRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 9, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("second automatic settlement failed: %v", err)
}
if secondRun.Results[policyTypeBD].SuccessCount != 0 || secondRun.Results[policyTypeBD].SkippedCount != 1 {
t.Fatalf("bd duplicate guard mismatch: %+v", secondRun.Results[policyTypeBD])
}
if secondRun.Results[policyTypeAdmin].SuccessCount != 0 || secondRun.Results[policyTypeAdmin].SkippedCount != 1 {
t.Fatalf("admin duplicate guard mismatch: %+v", secondRun.Results[policyTypeAdmin])
}
records, totalRecords, err := service.ListRecords(ctx, "lalu", recordQuery{CycleKey: "2026-01"})
if err != nil {
t.Fatalf("list team records failed: %v", err)
}
if totalRecords != 2 || len(records) != 2 {
t.Fatalf("record count mismatch: total=%d len=%d records=%+v", totalRecords, len(records), records)
}
assertWalletBalance(t, walletDB, 4001, 100_000)
assertWalletBalance(t, walletDB, 5001, 20_000)
assertScalar(t, walletDB, `SELECT COUNT(*) FROM team_salary_settlement_records`, int64(2))
assertScalar(t, walletDB, `SELECT COUNT(*) FROM wallet_transactions WHERE biz_type = 'team_salary_settlement'`, int64(2))
notDue, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 4, 8, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("non-due automatic check failed: %v", err)
}
if notDue.Due {
t.Fatalf("non-due day should not run settlement")
}
}
func newTeamSalaryTestDB(t *testing.T, baseDSN string, suffix string) *sql.DB {
t.Helper()
cfg, err := mysqlDriver.ParseDSN(baseDSN)
if err != nil {
t.Fatalf("parse test dsn failed: %v", err)
}
dbName := fmt.Sprintf("hy_team_salary_%s_%d", suffix, time.Now().UnixNano())
adminCfg := cfg.Clone()
adminCfg.DBName = ""
adminDB, err := sql.Open("mysql", adminCfg.FormatDSN())
if err != nil {
t.Fatalf("open mysql admin connection failed: %v", err)
}
if _, err := adminDB.Exec("CREATE DATABASE " + quoteTeamSalaryIdentifier(dbName) + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
_ = adminDB.Close()
t.Fatalf("create test database failed: %v", err)
}
testCfg := cfg.Clone()
testCfg.DBName = dbName
db, err := sql.Open("mysql", testCfg.FormatDSN())
if err != nil {
_ = adminDB.Close()
t.Fatalf("open test database failed: %v", err)
}
t.Cleanup(func() {
_ = db.Close()
_, _ = adminDB.Exec("DROP DATABASE IF EXISTS " + quoteTeamSalaryIdentifier(dbName))
_ = adminDB.Close()
})
return db
}
func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) {
t.Helper()
nowMS := int64(1_769_971_200_000)
execAll(t, adminDB,
`INSERT INTO admin_team_salary_policies
(id, app_code, policy_type, name, region_id, status, settlement_trigger_mode, effective_from_ms, effective_to_ms, description, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms)
VALUES
(7001, 'lalu', 'bd', 'BD automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1),
(8001, 'lalu', 'admin', 'Admin automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1)`,
`INSERT INTO admin_team_salary_policy_levels
(policy_id, level_no, threshold_usd, rate_percent, salary_usd, status, sort_order, created_at_ms, updated_at_ms)
VALUES
(7001, 1, 100.00, 8.0000, 8.00, 'active', 1, 1, 1),
(7001, 2, 200.00, 8.0000, 16.00, 'active', 2, 1, 1),
(7001, 3, 500.00, 8.0000, 40.00, 'active', 3, 1, 1),
(7001, 4, 1200.00, 8.0000, 96.00, 'active', 4, 1, 1),
(7001, 5, 2000.00, 8.0000, 160.00, 'active', 5, 1, 1),
(7001, 6, 5000.00, 9.0000, 450.00, 'active', 6, 1, 1),
(7001, 7, 10000.00, 10.0000, 1000.00, 'active', 7, 1, 1),
(8001, 1, 500.00, 20.0000, 100.00, 'active', 1, 1, 1),
(8001, 2, 1000.00, 20.0000, 200.00, 'active', 2, 1, 1),
(8001, 3, 3000.00, 20.0000, 600.00, 'active', 3, 1, 1),
(8001, 4, 5000.00, 20.0000, 1000.00, 'active', 4, 1, 1),
(8001, 5, 10000.00, 20.0000, 2000.00, 'active', 5, 1, 1),
(8001, 6, 20000.00, 20.0000, 4000.00, 'active', 6, 1, 1)`,
)
execAll(t, userDB,
`INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`,
`INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`,
`INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar) VALUES
('lalu', 3001, 'AG3001', 'Agency One', ''),
('lalu', 3002, 'AG3002', 'Agency Two', ''),
('lalu', 4001, 'BD4001', 'BD One', ''),
('lalu', 5001, 'AD5001', 'Admin One', '')`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES
('lalu', 3001, 4001, 686, 'active'),
('lalu', 3002, 4001, 686, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 686, 5001, 'active'),
('lalu', 5001, 'bd_leader', 686, 0, 'active')`,
)
execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records
(app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id,
cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, host_coin_reward_delta,
agency_salary_usd_minor_delta, residual_usd_minor_delta, status, reason, created_at_ms)
VALUES
('lalu', 'hs_1', 'hcmd_1', 'htx_1', 'month_end', 1001, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d),
('lalu', 'hs_2', 'hcmd_2', 'htx_2', 'month_end', 1002, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d),
('lalu', 'hs_3', 'hcmd_3', 'htx_3', 'month_end', 1003, 3001, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d),
('lalu', 'hs_4', 'hcmd_4', 'htx_4', 'month_end', 1004, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d),
('lalu', 'hs_5', 'hcmd_5', 'htx_5', 'month_end', 1005, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d),
('lalu', 'hs_6', 'hcmd_6', 'htx_6', 'month_end', 1006, 3002, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d)`, nowMS, nowMS, nowMS, nowMS, nowMS, nowMS))
}
func teamSalaryAdminDDL() []string {
return []string{
`CREATE TABLE admin_team_salary_policies (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
policy_type VARCHAR(24) NOT NULL,
name VARCHAR(120) NOT NULL,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'disabled',
settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic',
effective_from_ms BIGINT NOT NULL DEFAULT 0,
effective_to_ms BIGINT NOT NULL DEFAULT 0,
description VARCHAR(255) NOT NULL DEFAULT '',
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms)
)`,
`CREATE TABLE admin_team_salary_policy_levels (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
policy_id BIGINT UNSIGNED NOT NULL,
level_no INT NOT NULL,
threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00,
rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000,
salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00,
status VARCHAR(24) NOT NULL DEFAULT 'active',
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
INDEX idx_policy_level (policy_id, threshold_usd)
)`,
}
}
func teamSalaryUserDDL() []string {
return []string{
`CREATE TABLE users (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
current_display_user_id VARCHAR(64) NOT NULL DEFAULT '',
username VARCHAR(128) NULL,
avatar VARCHAR(512) NULL,
PRIMARY KEY (app_code, user_id)
)`,
`CREATE TABLE regions (
app_code VARCHAR(32) NOT NULL,
region_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
PRIMARY KEY (app_code, region_id)
)`,
`CREATE TABLE countries (
app_code VARCHAR(32) NOT NULL,
country_id BIGINT NOT NULL,
country_code VARCHAR(16) NOT NULL,
name VARCHAR(128) NOT NULL,
PRIMARY KEY (app_code, country_id)
)`,
`CREATE TABLE region_countries (
app_code VARCHAR(32) NOT NULL,
region_id BIGINT NOT NULL,
country_code VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, region_id, country_code)
)`,
`CREATE TABLE agencies (
app_code VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
parent_bd_user_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, owner_user_id)
)`,
`CREATE TABLE bd_profiles (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(24) NOT NULL,
region_id BIGINT NOT NULL,
parent_leader_user_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id, role)
)`,
}
}
func teamSalaryWalletDDL() []string {
return []string{
`CREATE TABLE wallet_accounts (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
asset_type VARCHAR(32) NOT NULL,
available_amount BIGINT NOT NULL,
frozen_amount BIGINT NOT NULL,
version BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, asset_type)
)`,
`CREATE TABLE wallet_transactions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
transaction_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
biz_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
request_hash VARCHAR(128) NOT NULL,
external_ref VARCHAR(128) NOT NULL DEFAULT '',
metadata_json JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, transaction_id),
UNIQUE KEY uk_wallet_tx_command (app_code, command_id)
)`,
`CREATE TABLE wallet_entries (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
transaction_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
asset_type VARCHAR(32) NOT NULL,
available_delta BIGINT NOT NULL,
frozen_delta BIGINT NOT NULL,
available_after BIGINT NOT NULL,
frozen_after BIGINT NOT NULL,
counterparty_user_id BIGINT NOT NULL DEFAULT 0,
room_id VARCHAR(96) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL
)`,
`CREATE TABLE wallet_outbox (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
transaction_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
asset_type VARCHAR(32) NOT NULL,
available_delta BIGINT NOT NULL,
frozen_delta BIGINT NOT NULL,
payload JSON NOT NULL,
status VARCHAR(32) NOT NULL,
worker_id VARCHAR(128) NOT NULL DEFAULT '',
lock_until_ms BIGINT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NULL,
last_error TEXT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id)
)`,
`CREATE TABLE host_salary_settlement_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
settlement_id VARCHAR(128) NOT NULL,
command_id VARCHAR(128) NOT NULL,
transaction_id VARCHAR(96) NOT NULL,
settlement_type VARCHAR(24) NOT NULL,
user_id BIGINT NOT NULL,
agency_owner_user_id BIGINT NOT NULL DEFAULT 0,
cycle_key VARCHAR(16) NOT NULL,
policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
level_no INT NOT NULL DEFAULT 0,
total_diamonds BIGINT NOT NULL DEFAULT 0,
host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0,
host_coin_reward_delta BIGINT NOT NULL DEFAULT 0,
agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0,
residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL DEFAULT 'succeeded',
reason VARCHAR(255) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_host_command (app_code, command_id)
)`,
}
}
func execAll(t *testing.T, db *sql.DB, statements ...string) {
t.Helper()
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
t.Fatalf("exec statement failed: %v\n%s", err, statement)
}
}
}
func assertWalletBalance(t *testing.T, db *sql.DB, userID int64, expected int64) {
t.Helper()
var amount int64
if err := db.QueryRow(`SELECT available_amount FROM wallet_accounts WHERE app_code = 'lalu' AND user_id = ? AND asset_type = 'USD_BALANCE'`, userID).Scan(&amount); err != nil {
t.Fatalf("query wallet balance failed: %v", err)
}
if amount != expected {
t.Fatalf("wallet balance mismatch for user %d: got=%d want=%d", userID, amount, expected)
}
}
func assertScalar(t *testing.T, db *sql.DB, query string, expected int64) {
t.Helper()
var got int64
if err := db.QueryRow(query).Scan(&got); err != nil {
t.Fatalf("query scalar failed: %v", err)
}
if got != expected {
t.Fatalf("scalar mismatch for %s: got=%d want=%d", query, got, expected)
}
}
func quoteTeamSalaryIdentifier(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}

View File

@ -0,0 +1,154 @@
package repository
import (
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
)
type HostAgencySalaryPolicyListOptions struct {
AppCode string
Keyword string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
Page int
PageSize int
}
func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListOptions) ([]model.HostAgencySalaryPolicy, int64, error) {
// 工资政策是应用隔离配置,所有查询都必须先限定 app_code再叠加运营筛选条件。
db := s.db.Model(&model.HostAgencySalaryPolicy{}).Where("app_code = ?", strings.TrimSpace(options.AppCode))
if options.RegionID > 0 {
// 第一阶段仍按单区域保存,区域筛选直接命中主表索引。
db = db.Where("region_id = ?", options.RegionID)
}
if status := strings.TrimSpace(options.Status); status != "" {
db = db.Where("status = ?", status)
}
if mode := strings.TrimSpace(options.SettlementMode); mode != "" {
db = db.Where("settlement_mode = ?", mode)
}
if triggerMode := strings.TrimSpace(options.SettlementTriggerMode); triggerMode != "" {
// 触发方式是自动任务和人工结算入口的分界,列表需要能直接筛出需要人工处理的政策。
db = db.Where("settlement_trigger_mode = ?", triggerMode)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
page, pageSize := normalizePage(options.Page, options.PageSize)
var items []model.HostAgencySalaryPolicy
// 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。
err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
return tx.Order("level_no ASC")
}).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
return items, total, err
}
func (s *Store) GetHostAgencySalaryPolicy(appCode string, id uint) (model.HostAgencySalaryPolicy, error) {
var item model.HostAgencySalaryPolicy
// 单条读取也预加载等级,更新时会用完整等级包替换原明细。
err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
return tx.Order("level_no ASC")
}).Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
return item, err
}
func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// GORM 创建主表时不让它自动级联 levels先拿到 policy_id再批量写明细约束更直观。
levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...)
item.Levels = nil
if err := tx.Create(item).Error; err != nil {
return err
}
for index := range levels {
levels[index].PolicyID = item.ID
}
if len(levels) > 0 {
if err := tx.Create(&levels).Error; err != nil {
return err
}
}
// 恢复内存对象service 可以把创建后的完整对象直接转成响应。
item.Levels = levels
return nil
})
}
func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 等级是整包配置,不做逐条 diff主表保存成功后删除旧明细再插入新梯度。
levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...)
item.Levels = nil
if err := tx.Save(item).Error; err != nil {
return err
}
if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil {
return err
}
for index := range levels {
levels[index].ID = 0
levels[index].PolicyID = item.ID
}
if len(levels) > 0 {
if err := tx.Create(&levels).Error; err != nil {
return err
}
}
// 重新挂回最新明细,保证返回给前端的是本次提交后的等级集。
item.Levels = levels
return nil
})
}
func (s *Store) UpdateHostAgencySalaryPolicyPublishState(appCode string, id uint, status string, publishError string, publishedAtMS int64, actorID uint) error {
// 发布状态属于 admin 配置到 wallet 运行快照的同步结果,不影响政策主配置字段和等级明细。
return s.db.Model(&model.HostAgencySalaryPolicy{}).
Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).
Updates(map[string]any{
"publish_status": strings.TrimSpace(status),
"publish_error": strings.TrimSpace(publishError),
"published_at_ms": publishedAtMS,
"published_by_admin_id": actorID,
"updated_by_admin_id": actorID,
}).Error
}
func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 先删明细再删主表,兼容没有外键级联的部署环境。
if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil {
return err
}
return tx.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.HostAgencySalaryPolicy{}).Error
})
}
func (s *Store) HasOverlappingActiveHostAgencySalaryPolicy(appCode string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) {
// 与当前政策开始时间相交:已有长期有效或已有结束时间晚于当前开始。
db := s.db.Model(&model.HostAgencySalaryPolicy{}).
Where("app_code = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), regionID, "active").
Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS)
if effectiveToMS > 0 {
// 当前政策不是长期有效时,再要求已有政策开始时间早于当前结束,形成标准半开区间相交判断。
db = db.Where("effective_from_ms < ?", effectiveToMS)
}
if excludeID > 0 {
db = db.Where("id <> ?", excludeID)
}
var count int64
if err := db.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}

View File

@ -66,6 +66,10 @@ func (s *Store) AutoMigrate() error {
&model.AppBanner{},
&model.AppVersion{},
&model.AppExploreTab{},
&model.HostAgencySalaryPolicy{},
&model.HostAgencySalaryLevel{},
&model.TeamSalaryPolicy{},
&model.TeamSalaryLevel{},
&model.RefreshToken{},
&model.LoginLog{},
&model.OperationLog{},

View File

@ -63,6 +63,17 @@ var defaultPermissions = []model.Permission{
{Name: "区域更新", Code: "region:update", Kind: "button"},
{Name: "区域状态", Code: "region:status", Kind: "button"},
{Name: "主播查看", Code: "host:view", Kind: "menu"},
{Name: "主播代理工资政策查看", Code: "host-agency-policy:view", Kind: "menu"},
{Name: "主播代理工资政策创建", Code: "host-agency-policy:create", Kind: "button"},
{Name: "主播代理工资政策更新", Code: "host-agency-policy:update", Kind: "button"},
{Name: "主播代理工资政策删除", Code: "host-agency-policy:delete", Kind: "button"},
{Name: "主播代理工资政策发布", Code: "host-agency-policy:publish", Kind: "button"},
{Name: "BD/Admin 工资政策查看", Code: "team-salary-policy:view", Kind: "button"},
{Name: "BD/Admin 工资政策创建", Code: "team-salary-policy:create", Kind: "button"},
{Name: "BD/Admin 工资政策更新", Code: "team-salary-policy:update", Kind: "button"},
{Name: "BD/Admin 工资政策删除", Code: "team-salary-policy:delete", Kind: "button"},
{Name: "工资结算查看", Code: "host-salary-settlement:view", Kind: "menu"},
{Name: "工资手动结算", Code: "host-salary-settlement:settle", Kind: "button"},
{Name: "Agency 查看", Code: "agency:view", Kind: "menu"},
{Name: "Agency 创建", Code: "agency:create", Kind: "button"},
{Name: "Agency 状态", Code: "agency:status", Kind: "button"},
@ -76,6 +87,7 @@ var defaultPermissions = []model.Permission{
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
@ -238,6 +250,7 @@ func (s *Store) seedMenus() error {
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, Visible: true},
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 70, Visible: true},
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 71, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
@ -255,7 +268,9 @@ func (s *Store) seedMenus() error {
{ParentID: &hostOrgID, Title: "BD Leader 列表", Code: "host-org-bd-leaders", Path: "/host/bd-leaders", Icon: "shield", PermissionCode: "bd:view", Sort: 82, Visible: true},
{ParentID: &hostOrgID, Title: "BD 列表", Code: "host-org-bds", Path: "/host/bds", Icon: "team", PermissionCode: "bd:view", Sort: 83, Visible: true},
{ParentID: &hostOrgID, Title: "Host 列表", Code: "host-org-hosts", Path: "/host/hosts", Icon: "users", PermissionCode: "host:view", Sort: 84, Visible: true},
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 85, Visible: true},
{ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true},
{ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true},
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true},
}
for _, menu := range children {
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
@ -478,11 +493,11 @@ func defaultRolePermissionCodes(code string) []string {
"emoji-pack:view", "emoji-pack:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host:view",
"host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"agency:view", "agency:create", "agency:status",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "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",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -496,7 +511,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -518,11 +533,15 @@ func defaultRolePermissionCodes(code string) []string {
"country:view",
"region:view",
"host:view",
"host-agency-policy:view",
"team-salary-policy:view",
"host-salary-settlement:view",
"agency:view",
"bd:view",
"coin-seller:view",
"coin-ledger:view",
"coin-adjustment:view",
"report:view",
"lucky-gift:view",
"payment-bill:view",
"payment-product:view",
@ -563,8 +582,9 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"upload:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "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",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -575,7 +595,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"vip-config:view", "vip-config:update", "vip-config:grant",
}
case "auditor", "readonly":
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
default:
return nil
}

View File

@ -0,0 +1,132 @@
package repository
import (
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
)
type TeamSalaryPolicyListOptions struct {
AppCode string
PolicyType string
Keyword string
RegionID int64
Status string
SettlementTriggerMode string
Page int
PageSize int
}
func (s *Store) ListTeamSalaryPolicies(options TeamSalaryPolicyListOptions) ([]model.TeamSalaryPolicy, int64, error) {
// BD/Admin 政策共表保存,查询必须同时限定 app_code 和 policy_type避免两个 tab 的配置互相混入。
db := s.db.Model(&model.TeamSalaryPolicy{}).
Where("app_code = ? AND policy_type = ?", strings.TrimSpace(options.AppCode), strings.TrimSpace(options.PolicyType))
if options.RegionID > 0 {
db = db.Where("region_id = ?", options.RegionID)
}
if status := strings.TrimSpace(options.Status); status != "" {
db = db.Where("status = ?", status)
}
if triggerMode := strings.TrimSpace(options.SettlementTriggerMode); triggerMode != "" {
db = db.Where("settlement_trigger_mode = ?", triggerMode)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
page, pageSize := normalizePage(options.Page, options.PageSize)
var items []model.TeamSalaryPolicy
err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
// 等级顺序是差额结算的业务顺序,列表和编辑都必须使用同一排序。
return tx.Order("level_no ASC")
}).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
return items, total, err
}
func (s *Store) GetTeamSalaryPolicy(appCode string, policyType string, id uint) (model.TeamSalaryPolicy, error) {
var item model.TeamSalaryPolicy
err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
return tx.Order("level_no ASC")
}).Where("app_code = ? AND policy_type = ? AND id = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), id).First(&item).Error
return item, err
}
func (s *Store) CreateTeamSalaryPolicy(item *model.TeamSalaryPolicy) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 主表和等级整包保存,避免部分等级成功造成一套不可结算的政策梯度。
levels := append([]model.TeamSalaryLevel(nil), item.Levels...)
item.Levels = nil
if err := tx.Create(item).Error; err != nil {
return err
}
for index := range levels {
levels[index].PolicyID = item.ID
}
if len(levels) > 0 {
if err := tx.Create(&levels).Error; err != nil {
return err
}
}
item.Levels = levels
return nil
})
}
func (s *Store) UpdateTeamSalaryPolicy(item *model.TeamSalaryPolicy) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 编辑按整包替换等级,保证 UI 提交内容就是下一次结算读取的完整梯度。
levels := append([]model.TeamSalaryLevel(nil), item.Levels...)
item.Levels = nil
if err := tx.Save(item).Error; err != nil {
return err
}
if err := tx.Where("policy_id = ?", item.ID).Delete(&model.TeamSalaryLevel{}).Error; err != nil {
return err
}
for index := range levels {
levels[index].ID = 0
levels[index].PolicyID = item.ID
}
if len(levels) > 0 {
if err := tx.Create(&levels).Error; err != nil {
return err
}
}
item.Levels = levels
return nil
})
}
func (s *Store) DeleteTeamSalaryPolicy(appCode string, policyType string, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("policy_id = ?", id).Delete(&model.TeamSalaryLevel{}).Error; err != nil {
return err
}
return tx.Where("app_code = ? AND policy_type = ? AND id = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), id).Delete(&model.TeamSalaryPolicy{}).Error
})
}
func (s *Store) HasOverlappingActiveTeamSalaryPolicy(appCode string, policyType string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) {
// 同一类型、同一区域、同一时间只能有一条启用政策,避免结算时 BD/Admin 可命中多套比例。
db := s.db.Model(&model.TeamSalaryPolicy{}).
Where("app_code = ? AND policy_type = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), regionID, "active").
Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS)
if effectiveToMS > 0 {
db = db.Where("effective_from_ms < ?", effectiveToMS)
}
if excludeID > 0 {
db = db.Where("id <> ?", excludeID)
}
var count int64
if err := db.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}

View File

@ -17,7 +17,9 @@ import (
"hyapp-admin-server/internal/modules/firstrechargereward"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
"hyapp-admin-server/internal/modules/health"
"hyapp-admin-server/internal/modules/hostagencypolicy"
"hyapp-admin-server/internal/modules/hostorg"
"hyapp-admin-server/internal/modules/hostsalarysettlement"
"hyapp-admin-server/internal/modules/job"
"hyapp-admin-server/internal/modules/levelconfig"
"hyapp-admin-server/internal/modules/luckygift"
@ -27,11 +29,14 @@ import (
"hyapp-admin-server/internal/modules/redpacket"
"hyapp-admin-server/internal/modules/regionblock"
"hyapp-admin-server/internal/modules/registrationreward"
reportmodule "hyapp-admin-server/internal/modules/report"
resourcemodule "hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/roomtreasure"
"hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/sevendaycheckin"
"hyapp-admin-server/internal/modules/teamsalarypolicy"
"hyapp-admin-server/internal/modules/teamsalarysettlement"
"hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/modules/userleaderboard"
"hyapp-admin-server/internal/modules/vipconfig"
@ -42,38 +47,43 @@ import (
)
type Handlers struct {
AdminUser *adminuser.Handler
AchievementConfig *achievementconfig.Handler
AppConfig *appconfig.Handler
AppRegistry *appregistry.Handler
AppUser *appuser.Handler
Audit *audit.Handler
Auth *authroutes.Handler
CoinLedger *coinledger.Handler
CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
FirstRechargeReward *firstrechargereward.Handler
Game *gamemanagement.Handler
Health *health.Handler
HostOrg *hostorg.Handler
Job *job.Handler
LevelConfig *levelconfig.Handler
LuckyGift *luckygift.Handler
Menu *menu.Handler
Payment *payment.Handler
RBAC *rbac.Handler
RedPacket *redpacket.Handler
RegistrationReward *registrationreward.Handler
RegionBlock *regionblock.Handler
Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler
RoomTreasure *roomtreasure.Handler
Search *search.Handler
SevenDayCheckIn *sevendaycheckin.Handler
Upload *upload.Handler
UserLeaderboard *userleaderboard.Handler
VIPConfig *vipconfig.Handler
AdminUser *adminuser.Handler
AchievementConfig *achievementconfig.Handler
AppConfig *appconfig.Handler
AppRegistry *appregistry.Handler
AppUser *appuser.Handler
Audit *audit.Handler
Auth *authroutes.Handler
CoinLedger *coinledger.Handler
CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
FirstRechargeReward *firstrechargereward.Handler
Game *gamemanagement.Handler
Health *health.Handler
HostAgencyPolicy *hostagencypolicy.Handler
HostOrg *hostorg.Handler
HostSalarySettlement *hostsalarysettlement.Handler
Job *job.Handler
LevelConfig *levelconfig.Handler
LuckyGift *luckygift.Handler
Menu *menu.Handler
Payment *payment.Handler
RBAC *rbac.Handler
RedPacket *redpacket.Handler
Report *reportmodule.Handler
RegistrationReward *registrationreward.Handler
RegionBlock *regionblock.Handler
Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler
RoomTreasure *roomtreasure.Handler
Search *search.Handler
SevenDayCheckIn *sevendaycheckin.Handler
TeamSalaryPolicy *teamsalarypolicy.Handler
TeamSalarySettlement *teamsalarysettlement.Handler
Upload *upload.Handler
UserLeaderboard *userleaderboard.Handler
VIPConfig *vipconfig.Handler
}
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
@ -93,6 +103,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
menu.RegisterRoutes(protected, h.Menu)
rbac.RegisterRoutes(protected, h.RBAC)
redpacket.RegisterRoutes(protected, h.RedPacket)
reportmodule.RegisterRoutes(protected, h.Report)
resourcemodule.RegisterRoutes(protected, h.Resource)
adminuser.RegisterRoutes(protected, h.AdminUser)
appuser.RegisterRoutes(protected, h.AppUser)
@ -107,6 +118,8 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
roomtreasure.RegisterRoutes(protected, h.RoomTreasure)
dashboard.RegisterRoutes(protected, h.Dashboard)
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg)
audit.RegisterRoutes(protected, h.Audit)
payment.RegisterRoutes(protected, h.Payment)
@ -116,6 +129,8 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
upload.RegisterRoutes(protected, h.Upload)
search.RegisterRoutes(protected, h.Search)
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy)
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
vipconfig.RegisterRoutes(protected, h.VIPConfig)

View File

@ -0,0 +1,88 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policies (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
name VARCHAR(120) NOT NULL COMMENT '政策名称',
region_id BIGINT NOT NULL COMMENT '适用区域 ID',
status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态active/disabled',
settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式daily/half_month',
settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式automatic/manual',
gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例',
residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间0 表示长期有效',
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '后台备注',
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_host_agency_salary_policy_name (app_code, region_id, name),
INDEX idx_admin_host_agency_salary_policy_region (app_code, region_id, status, effective_from_ms, effective_to_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策表';
CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policy_levels (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
level_no INT NOT NULL COMMENT '等级',
required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石',
host_salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '主播累计美元工资',
host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计金币奖励',
agency_salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '代理累计美元工资',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_host_agency_salary_level (policy_id, level_no),
INDEX idx_admin_host_agency_salary_level_policy (policy_id, required_diamonds)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级表';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('主播代理工资政策查看', 'host-agency-policy:view', 'menu', '', @now_ms, @now_ms),
('主播代理工资政策创建', 'host-agency-policy:create', 'button', '', @now_ms, @now_ms),
('主播代理工资政策更新', 'host-agency-policy:update', 'button', '', @now_ms, @now_ms),
('主播代理工资政策删除', 'host-agency-policy:delete', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '工资政策', 'host-agency-policy', '/host/salary-policies', 'wallet', 'host-agency-policy:view', 85, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'host-org'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus
SET sort = 86, updated_at_ms = @now_ms
WHERE code = 'host-org-coin-sellers' AND sort < 86;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN (
'host-agency-policy:view',
'host-agency-policy:create',
'host-agency-policy:update',
'host-agency-policy:delete'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'host-agency-policy:view';

View File

@ -0,0 +1,43 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('举报列表查看', 'report: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) VALUES
(NULL, '运营管理', 'operations', '', 'operations', '', 68, TRUE, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
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;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '举报列表', 'operation-reports', '/operations/reports', 'flag', 'report:view', 71, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'operations'
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;
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', 'auditor', 'readonly')
AND admin_permission.code = 'report:view';

View File

@ -0,0 +1,83 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'publish_status') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN publish_status VARCHAR(24) NOT NULL DEFAULT ''draft'' COMMENT ''发布状态draft/published/failed'' AFTER updated_by_admin_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'publish_error') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN publish_error VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''最近一次发布失败原因'' AFTER publish_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'published_at_ms') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''最近一次成功发布时间UTC epoch ms'' AFTER publish_error',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'published_by_admin_id') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN published_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最近一次成功发布管理员 ID'' AFTER published_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('主播代理工资政策发布', 'host-agency-policy:publish', 'button', '', @now_ms, @now_ms),
('主播工资结算记录查看', 'host-salary-settlement:view', 'menu', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '结算记录', 'host-salary-settlement', '/host/salary-settlements', 'receipt', 'host-salary-settlement:view', 86, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'host-org'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus
SET sort = 87, updated_at_ms = @now_ms
WHERE code = 'host-org-coin-sellers' AND sort < 87;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN (
'host-agency-policy:publish',
'host-salary-settlement:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'host-salary-settlement:view';

View File

@ -0,0 +1,24 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- 触发方式是自动任务和人工结算的配置边界;老环境执行过 027 时需要幂等补列。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'settlement_trigger_mode') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT ''automatic'' COMMENT ''结算触发方式automatic/manual'' AFTER settlement_mode',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 产品菜单统一叫“工资结算”,页面可同时承载待结算与历史记录查询。
UPDATE admin_menus
SET title = '工资结算',
updated_at_ms = @now_ms
WHERE code = 'host-salary-settlement';
UPDATE admin_permissions
SET name = '工资结算查看',
updated_at_ms = @now_ms
WHERE code = 'host-salary-settlement:view';

View File

@ -0,0 +1,67 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_team_salary_policies (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
policy_type VARCHAR(24) NOT NULL COMMENT '政策类型bd/admin',
name VARCHAR(120) NOT NULL COMMENT '政策名称',
region_id BIGINT NOT NULL COMMENT '适用区域 ID',
status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态active/disabled',
settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式automatic/manual',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间0 表示长期有效',
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '后台备注',
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_team_salary_policy_name (app_code, policy_type, region_id, name),
INDEX idx_admin_team_salary_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资政策表';
CREATE TABLE IF NOT EXISTS admin_team_salary_policy_levels (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
level_no INT NOT NULL COMMENT '等级',
threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '收入门槛美元BD 为下属主播工资合计Admin 为下属 BD 工资合计',
rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000 COMMENT '提成比例百分比,例如 8 表示 8%',
salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '达到该等级后的累计应得工资',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_team_salary_level (policy_id, level_no),
INDEX idx_admin_team_salary_level_policy (policy_id, threshold_usd)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资政策等级表';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('BD/Admin 工资政策查看', 'team-salary-policy:view', 'button', '', @now_ms, @now_ms),
('BD/Admin 工资政策创建', 'team-salary-policy:create', 'button', '', @now_ms, @now_ms),
('BD/Admin 工资政策更新', 'team-salary-policy:update', 'button', '', @now_ms, @now_ms),
('BD/Admin 工资政策删除', 'team-salary-policy:delete', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN (
'team-salary-policy:view',
'team-salary-policy:create',
'team-salary-policy:update',
'team-salary-policy:delete'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'team-salary-policy:view';

View File

@ -0,0 +1,12 @@
SET @now_ms := UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
INSERT IGNORE INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms)
VALUES
('工资手动结算', 'host-salary-settlement:settle', 'button', '允许在工资结算菜单批量手动结算 Host/Agency/BD/Admin 工资', @now_ms, @now_ms);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code = 'host-salary-settlement:settle';

View File

@ -235,7 +235,7 @@ func New(cfg config.Config) (*App, error) {
if err != nil || !ok {
return err
}
return broadcastSvc.ConsumeRedPacketCreatedEvent(appcode.WithContext(ctx, event.AppCode), event)
return broadcastSvc.ConsumeRedPacketWalletEvent(appcode.WithContext(ctx, event.AppCode), event)
}); err != nil {
_ = consumer.Shutdown()
shutdownConsumers(mqConsumers)
@ -436,25 +436,32 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha
}, true, nil
}
func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketCreatedEvent, bool, error) {
func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWalletEvent, bool, error) {
message, err := walletmq.DecodeWalletOutboxMessage(body)
if err != nil {
return broadcastdomain.RedPacketCreatedEvent{}, false, err
return broadcastdomain.RedPacketWalletEvent{}, false, err
}
if message.EventType != "WalletRedPacketCreated" {
return broadcastdomain.RedPacketCreatedEvent{}, false, nil
if message.EventType != "WalletRedPacketCreated" && message.EventType != "WalletRedPacketClaimed" && message.EventType != "WalletRedPacketRefunded" {
return broadcastdomain.RedPacketWalletEvent{}, false, nil
}
regionID := redPacketRegionIDFromWalletPayload(message.PayloadJSON)
if regionID <= 0 {
return broadcastdomain.RedPacketCreatedEvent{}, false, nil
packetID, packetType, roomID, regionID, status, openAtMS, expiresAtMS := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
if packetID == "" {
return broadcastdomain.RedPacketWalletEvent{}, false, nil
}
return broadcastdomain.RedPacketCreatedEvent{
return broadcastdomain.RedPacketWalletEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
EventType: message.EventType,
TransactionID: message.TransactionID,
CommandID: message.CommandID,
PayloadJSON: message.PayloadJSON,
PacketID: packetID,
PacketType: packetType,
RoomID: roomID,
RegionID: regionID,
Status: status,
OpenAtMS: openAtMS,
ExpiresAtMS: expiresAtMS,
CreatedAtMS: message.OccurredAtMS,
}, true, nil
}
@ -480,19 +487,41 @@ func rechargeFactFromWalletPayload(payload string) (int64, string) {
return sequence, rechargeType
}
func redPacketRegionIDFromWalletPayload(payload string) int64 {
func redPacketFieldsFromWalletPayload(payload string, eventType string) (string, string, string, int64, string, int64, int64) {
var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
return 0
return "", "", "", 0, "", 0, 0
}
switch value := decoded["region_id"].(type) {
packetID := stringFromDecoded(decoded, "packet_id")
if packetID == "" {
packetID = stringFromDecoded(decoded, "packetId")
}
packetType := stringFromDecoded(decoded, "packet_type")
roomID := stringFromDecoded(decoded, "room_id")
regionID := int64FromDecoded(decoded, "region_id")
status := stringFromDecoded(decoded, "status")
openAtMS := int64FromDecoded(decoded, "open_at_ms")
expiresAtMS := int64FromDecoded(decoded, "expires_at_ms")
if eventType == "WalletRedPacketClaimed" || eventType == "WalletRedPacketRefunded" {
status = stringFromDecoded(decoded, "packet_status")
}
return packetID, packetType, roomID, regionID, status, openAtMS, expiresAtMS
}
func stringFromDecoded(decoded map[string]any, key string) string {
value, _ := decoded[key].(string)
return value
}
func int64FromDecoded(decoded map[string]any, key string) int64 {
switch value := decoded[key].(type) {
case float64:
return int64(value)
case int64:
return value
case json.Number:
regionID, _ := value.Int64()
return regionID
parsed, _ := value.Int64()
return parsed
default:
return 0
}

View File

@ -7,6 +7,8 @@ const (
ScopeGlobal = "global"
// ScopeRegion 表示用户所在区域群;贵重礼物、区域红包、区域运营通知默认走这个范围。
ScopeRegion = "region"
// ScopeRoom 表示当前语音房 IM 群。
ScopeRoom = "room"
// TypeSuperGift 是贵重礼物播报,来源于 room-service 已提交的 RoomGiftSent 事实。
TypeSuperGift = "super_gift"
@ -74,13 +76,23 @@ type ConsumeRoomEventResult struct {
BroadcastCreated bool
}
// RedPacketCreatedEvent 是 wallet-service 已提交的红包创建事实。
type RedPacketCreatedEvent struct {
// RedPacketWalletEvent 是 wallet-service 已提交的红包资金事实。
type RedPacketWalletEvent struct {
AppCode string
EventID string
EventType string
TransactionID string
CommandID string
PayloadJSON string
PacketID string
PacketType string
RoomID string
RegionID int64
Status string
OpenAtMS int64
ExpiresAtMS int64
CreatedAtMS int64
}
// RedPacketCreatedEvent keeps older tests and callers compiling while wallet red-packet facts are generalized.
type RedPacketCreatedEvent = RedPacketWalletEvent

View File

@ -2,24 +2,110 @@ package broadcast
import (
"context"
"encoding/json"
"strings"
"hyapp/pkg/appcode"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
// ConsumeRedPacketCreatedEvent converts one MQ-delivered wallet red-packet fact into a broadcast outbox.
func (s *Service) ConsumeRedPacketCreatedEvent(ctx context.Context, event broadcastdomain.RedPacketCreatedEvent) error {
// ConsumeRedPacketWalletEvent converts one MQ-delivered wallet red-packet fact into IM outbox records.
func (s *Service) ConsumeRedPacketWalletEvent(ctx context.Context, event broadcastdomain.RedPacketWalletEvent) error {
if s == nil {
return nil
}
if event.RegionID <= 0 {
ctx = appcode.WithContext(ctx, event.AppCode)
switch event.EventType {
case "WalletRedPacketCreated":
return s.consumeRedPacketCreated(ctx, event)
case "WalletRedPacketClaimed":
if strings.EqualFold(event.Status, "finished") {
return s.publishRedPacketRoomEvent(ctx, event, "STATUS_CHANGED", event.CreatedAtMS)
}
case "WalletRedPacketRefunded":
return s.publishRedPacketRoomEvent(ctx, event, "STATUS_CHANGED", event.CreatedAtMS)
}
return nil
}
func (s *Service) ConsumeRedPacketCreatedEvent(ctx context.Context, event broadcastdomain.RedPacketWalletEvent) error {
if event.EventType == "" {
event.EventType = "WalletRedPacketCreated"
}
return s.ConsumeRedPacketWalletEvent(ctx, event)
}
func (s *Service) consumeRedPacketCreated(ctx context.Context, event broadcastdomain.RedPacketWalletEvent) error {
if event.RoomID == "" {
return nil
}
_, err := s.PublishRegionBroadcast(appcode.WithContext(ctx, event.AppCode), PublishInput{
if err := s.publishRedPacketRoomEvent(ctx, event, "CREATED", event.CreatedAtMS); err != nil {
return err
}
if strings.EqualFold(event.PacketType, "delayed") && event.RegionID > 0 {
if _, err := s.PublishRegionBroadcast(ctx, PublishInput{
EventID: event.EventID + ":region_created",
BroadcastType: broadcastdomain.TypeRedPacket,
RegionID: event.RegionID,
PayloadJSON: redPacketIMPayload(event, "CREATED", "REGION"),
}); err != nil {
return err
}
}
if strings.EqualFold(event.PacketType, "delayed") && event.OpenAtMS > 0 {
_, err := s.PublishRoomBroadcast(ctx, event.RoomID, PublishInput{
EventID: event.EventID + ":open_due",
BroadcastType: broadcastdomain.TypeRedPacket,
RegionID: event.RegionID,
PayloadJSON: redPacketIMPayload(event, "OPEN_DUE", "ROOM"),
DeliverAtMS: event.OpenAtMS,
})
return err
}
return nil
}
func (s *Service) publishRedPacketRoomEvent(ctx context.Context, event broadcastdomain.RedPacketWalletEvent, eventName string, fallbackDeliverAtMS int64) error {
if event.RoomID == "" {
return nil
}
_, err := s.PublishRoomBroadcast(ctx, event.RoomID, PublishInput{
EventID: event.EventID,
BroadcastType: broadcastdomain.TypeRedPacket,
RegionID: event.RegionID,
PayloadJSON: event.PayloadJSON,
PayloadJSON: redPacketIMPayload(event, eventName, "ROOM"),
DeliverAtMS: fallbackDeliverAtMS,
})
return err
}
func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName string, scope string) string {
packetMode := "IMMEDIATE"
if strings.EqualFold(event.PacketType, "delayed") {
packetMode = "DELAYED"
}
status := strings.ToUpper(strings.TrimSpace(event.Status))
if status == "" {
status = "ACTIVE"
}
payload := map[string]any{
"type": "ROOM_RED_PACKET",
"data": map[string]any{
"event": eventName,
"scope": scope,
"packetNo": event.PacketID,
"roomId": event.RoomID,
"regionId": event.RegionID,
"packetMode": packetMode,
"status": status,
"claimStartTime": event.OpenAtMS,
"expireTime": event.ExpiresAtMS,
"serverTime": event.CreatedAtMS,
},
}
encoded, err := json.Marshal(payload)
if err != nil {
return `{"type":"ROOM_RED_PACKET","data":{}}`
}
return string(encoded)
}

View File

@ -65,6 +65,7 @@ type PublishInput struct {
BroadcastType string
RegionID int64
PayloadJSON string
DeliverAtMS int64
}
// Service 拥有播报群建群补偿、播报入队和 outbox 投递决策。
@ -146,6 +147,14 @@ func (s *Service) PublishRegionBroadcast(ctx context.Context, input PublishInput
return s.enqueue(ctx, broadcastdomain.ScopeRegion, groupID, input)
}
func (s *Service) PublishRoomBroadcast(ctx context.Context, roomID string, input PublishInput) (broadcastdomain.PublishResult, error) {
roomID = strings.TrimSpace(roomID)
if roomID == "" {
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "room_id is required")
}
return s.enqueue(ctx, broadcastdomain.ScopeRoom, roomID, input)
}
// PublishGlobalBroadcast 把全局播报写入持久化 outbox。
// 全局播报和区域播报共用同一张表scope/group_id 决定最终投递目标。
func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
@ -209,9 +218,13 @@ func (s *Service) enqueue(ctx context.Context, scope string, groupID string, inp
BroadcastType: input.BroadcastType,
PayloadJSON: payloadJSON,
Status: broadcastdomain.StatusPending,
NextRetryAtMS: nowMS,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
if input.DeliverAtMS > nowMS {
record.NextRetryAtMS = input.DeliverAtMS
}
created, saved, err := s.repository.SaveBroadcastOutbox(ctx, record)
if err != nil {
return broadcastdomain.PublishResult{}, err

View File

@ -14,6 +14,7 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
@ -35,6 +36,7 @@ type App struct {
userConn *grpc.ClientConn
activityConn *grpc.ClientConn
gameConn *grpc.ClientConn
walletConn *grpc.ClientConn
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
@ -78,6 +80,15 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close()
return nil, err
}
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = gameConn.Close()
_ = activityConn.Close()
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service")))
health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{
@ -87,6 +98,7 @@ func New(cfg config.Config) (*App, error) {
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = walletConn.Close()
_ = gameConn.Close()
_ = activityConn.Close()
_ = userConn.Close()
@ -99,14 +111,18 @@ func New(cfg config.Config) (*App, error) {
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
"mic_open_session_compensation": userCron.CompensateMicOpenSessions,
"message_fanout": activityCron.ProcessMessageFanoutBatch,
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
"mic_open_session_compensation": userCron.CompensateMicOpenSessions,
"message_fanout": activityCron.ProcessMessageFanoutBatch,
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
"host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch,
})
return &App{
@ -119,6 +135,7 @@ func New(cfg config.Config) (*App, error) {
userConn: userConn,
activityConn: activityConn,
gameConn: gameConn,
walletConn: walletConn,
workerCtx: workerCtx,
workerCancel: workerCancel,
}, nil
@ -170,6 +187,9 @@ func (a *App) Close() {
if a.gameConn != nil {
_ = a.gameConn.Close()
}
if a.walletConn != nil {
_ = a.walletConn.Close()
}
})
}

View File

@ -172,6 +172,30 @@ func defaultTasks() map[string]TaskConfig {
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"host_salary_daily_settlement": {
Enabled: true,
Interval: "24h",
Timeout: "30s",
LockTTL: "2m",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
},
"host_salary_half_month_settlement": {
Enabled: true,
Interval: "24h",
Timeout: "30s",
LockTTL: "2m",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
},
"host_salary_month_end": {
Enabled: true,
Interval: "24h",
Timeout: "60s",
LockTTL: "5m",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
},
"mic_open_session_compensation": {
Enabled: true,
Interval: "60s",

View File

@ -0,0 +1,20 @@
package config
import "testing"
func TestDefaultHostSalaryCronTasksAreEnabled(t *testing.T) {
cfg := Default()
for _, name := range []string{
"host_salary_daily_settlement",
"host_salary_half_month_settlement",
"host_salary_month_end",
} {
task, ok := cfg.Tasks[name]
if !ok {
t.Fatalf("host salary cron task %s missing", name)
}
if !task.Enabled {
t.Fatalf("host salary cron task %s should be enabled globally; policy settlement_mode decides who is processed", name)
}
}
}

View File

@ -0,0 +1,80 @@
package integration
import (
"context"
"time"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/services/cron-service/internal/scheduler"
)
// WalletCronClient delegates wallet-owned salary settlement batches to wallet-service.
type WalletCronClient struct {
client walletv1.WalletCronServiceClient
}
var walletCronNowUTC = func() time.Time {
return time.Now().UTC()
}
// NewWalletCronClient creates scheduler handlers backed by wallet-service gRPC.
func NewWalletCronClient(client walletv1.WalletCronServiceClient) *WalletCronClient {
return &WalletCronClient{client: client}
}
func (c *WalletCronClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessHostSalaryDailySettlementBatch(ctx, walletCronBatchRequest(req))
return walletCronBatchResult(resp), err
}
func (c *WalletCronClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
if !isHostSalaryHalfMonthDue(walletCronNowUTC()) {
// 半月结是否适用于某个主播由后台工资政策 settlement_mode 决定cron 这里只负责在月中打开一次全局触发窗口。
return scheduler.BatchResult{}, nil
}
resp, err := c.client.ProcessHostSalaryHalfMonthSettlementBatch(ctx, walletCronBatchRequest(req))
return walletCronBatchResult(resp), err
}
func (c *WalletCronClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
if !isHostSalaryMonthEndDue(walletCronNowUTC()) {
// 月末批次负责最终等级差额、剩余钻石折美元和周期关闭;非月末触发只记录一次空跑,不进入 wallet 账务。
return scheduler.BatchResult{}, nil
}
resp, err := c.client.ProcessHostSalaryMonthEndBatch(ctx, walletCronBatchRequest(req))
return walletCronBatchResult(resp), err
}
func isHostSalaryHalfMonthDue(now time.Time) bool {
return now.UTC().Day() == 15
}
func isHostSalaryMonthEndDue(now time.Time) bool {
utc := now.UTC()
return utc.AddDate(0, 0, 1).Day() == 1
}
func walletCronBatchRequest(req scheduler.BatchRequest) *walletv1.CronBatchRequest {
// scheduler 的 RunID 同时作为请求幂等标识传给 walletwallet 内部会继续按主播和周期生成账务幂等键。
return &walletv1.CronBatchRequest{
RequestId: req.RunID,
AppCode: req.AppCode,
RunId: req.RunID,
WorkerId: req.WorkerID,
BatchSize: int32(req.BatchSize),
LockTtlMs: req.LockTTL.Milliseconds(),
}
}
func walletCronBatchResult(resp *walletv1.CronBatchResponse) scheduler.BatchResult {
if resp == nil {
return scheduler.BatchResult{}
}
return scheduler.BatchResult{
ClaimedCount: int(resp.GetClaimedCount()),
ProcessedCount: int(resp.GetProcessedCount()),
SuccessCount: int(resp.GetSuccessCount()),
FailureCount: int(resp.GetFailureCount()),
HasMore: resp.GetHasMore(),
}
}

View File

@ -0,0 +1,30 @@
package integration
import (
"testing"
"time"
)
func TestHostSalarySettlementDueDates(t *testing.T) {
tests := []struct {
name string
now time.Time
wantHalfMonth bool
wantMonthEnd bool
}{
{name: "normal day", now: time.Date(2026, time.May, 14, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: false},
{name: "half month day", now: time.Date(2026, time.May, 15, 10, 0, 0, 0, time.UTC), wantHalfMonth: true, wantMonthEnd: false},
{name: "month end in 31 day month", now: time.Date(2026, time.May, 31, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: true},
{name: "month end in 28 day month", now: time.Date(2026, time.February, 28, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isHostSalaryHalfMonthDue(tt.now); got != tt.wantHalfMonth {
t.Fatalf("half-month due mismatch: got %v want %v", got, tt.wantHalfMonth)
}
if got := isHostSalaryMonthEndDue(tt.now); got != tt.wantMonthEnd {
t.Fatalf("month-end due mismatch: got %v want %v", got, tt.wantMonthEnd)
}
})
}
}

View File

@ -50,6 +50,7 @@ type UserSocialClient interface {
DeleteFriend(ctx context.Context, req *userv1.DeleteFriendRequest) (*userv1.DeleteFriendResponse, error)
ListFriends(ctx context.Context, req *userv1.ListFriendsRequest) (*userv1.ListFriendsResponse, error)
ListFriendApplications(ctx context.Context, req *userv1.ListFriendApplicationsRequest) (*userv1.ListFriendApplicationsResponse, error)
SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error)
}
// UserDeviceClient 抽象 gateway 对 user-service 设备 push token 的依赖。
@ -281,6 +282,10 @@ func (c *grpcUserSocialClient) ListFriendApplications(ctx context.Context, req *
return c.client.ListFriendApplications(ctx, req)
}
func (c *grpcUserSocialClient) SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
return c.client.SubmitReport(ctx, req)
}
func (c *grpcUserDeviceClient) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
return c.client.BindPushToken(ctx, req)
}

View File

@ -84,6 +84,7 @@ type UserHandlers struct {
ListMyResources http.HandlerFunc
EquipMyResource http.HandlerFunc
UserSocialAction http.HandlerFunc
SubmitReport http.HandlerFunc
}
type ManagerHandlers struct {
@ -175,6 +176,14 @@ type WalletHandlers struct {
CreateRoomRedPacket http.HandlerFunc
GetRedPacket http.HandlerFunc
ClaimRedPacket http.HandlerFunc
GetCurrentRegionIMGroup http.HandlerFunc
GetRedPacketIMGroup http.HandlerFunc
SendVoiceRoomRedPacket http.HandlerFunc
ListActiveRedPackets http.HandlerFunc
ClaimVoiceRoomRedPacket http.HandlerFunc
GetVoiceRoomRedPacket http.HandlerFunc
ListRedPacketClaims http.HandlerFunc
ListSentRedPackets http.HandlerFunc
}
type VIPHandlers struct {
@ -294,6 +303,7 @@ func (r routes) registerUserRoutes() {
r.profile("/users/me", "", h.GetMyProfile)
r.profile("/users/me/resources", "", h.ListMyResources)
r.profile("/users/me/resources/{resource_id}/equip", "", h.EquipMyResource)
r.profile("/reports", http.MethodPost, h.SubmitReport)
r.profile("/users/{user_id}/{action...}", "", h.UserSocialAction)
}
@ -394,6 +404,15 @@ func (r routes) registerWalletRoutes() {
r.profile("/rooms/{room_id}/red-packets/create", http.MethodPost, h.CreateRoomRedPacket)
r.profile("/red-packets/{packet_id}", http.MethodGet, h.GetRedPacket)
r.profile("/red-packets/{packet_id}/claim", http.MethodPost, h.ClaimRedPacket)
r.profile("/app/region-im-group/current", http.MethodGet, h.GetCurrentRegionIMGroup)
r.profile("/app/voice-room/red-packet/im-group", http.MethodGet, h.GetRedPacketIMGroup)
r.profile("/app/voice-room/red-packet/config", http.MethodGet, h.GetRedPacketConfig)
r.profile("/app/voice-room/red-packet/send", http.MethodPost, h.SendVoiceRoomRedPacket)
r.profile("/app/voice-room/red-packet/active", http.MethodGet, h.ListActiveRedPackets)
r.profile("/app/voice-room/red-packet/claim", http.MethodPost, h.ClaimVoiceRoomRedPacket)
r.profile("/app/voice-room/red-packet/detail", http.MethodGet, h.GetVoiceRoomRedPacket)
r.profile("/app/voice-room/red-packet/claim-records", http.MethodGet, h.ListRedPacketClaims)
r.profile("/app/voice-room/red-packet/sent-records", http.MethodGet, h.ListSentRedPackets)
}
func (r routes) registerVIPRoutes() {

View File

@ -1744,6 +1744,49 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
}
}
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host","target_user_id":43,"gift_id":"rose","gift_count":2}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 43 {
t.Fatalf("gateway must query target host profile before sending gift: %+v", hostClient.lastHost)
}
if roomClient.lastGift == nil || !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 || roomClient.lastGift.GetTargetAgencyOwnerUserId() != 30001 {
t.Fatalf("gateway must inject active host period scope: %+v", roomClient.lastGift)
}
}
func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-non-host","target_user_id":43,"gift_id":"rose","gift_count":2}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if roomClient.lastGift == nil || roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 0 {
t.Fatalf("non-host target must not receive host period scope: %+v", roomClient.lastGift)
}
}
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
@ -2851,6 +2894,8 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
OwnerUserId: 901,
RegionId: 30,
Name: "Admin Seed Agency",
Avatar: "https://cdn.example/agency.png",
HostCount: 3,
Status: "active",
JoinEnabled: true,
MaxHosts: 100,
@ -2861,7 +2906,7 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=99", nil)
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=150", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-host-agency-search")
recorder := httptest.NewRecorder()
@ -2871,7 +2916,7 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 20 {
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 100 {
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
}
var response httpkit.ResponseEnvelope
@ -2881,11 +2926,31 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
data := response.Data.(map[string]any)
items := data["items"].([]any)
first := items[0].(map[string]any)
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || data["page_size"] != float64(20) {
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || first["avatar"] != "https://cdn.example/agency.png" || first["host_count"] != float64(3) || data["page_size"] != float64(100) {
t.Fatalf("host agency search response mismatch: %+v", response)
}
}
func TestSearchHostAgenciesAllowsEmptyKeywordForCountryList(t *testing.T) {
hostClient := &fakeUserHostClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-host-agency-list")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "" || hostClient.lastSearchAgencies.GetPageSize() != 100 {
t.Fatalf("host agency list request mismatch: %+v", hostClient.lastSearchAgencies)
}
}
func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
{

View File

@ -16,6 +16,7 @@ type Handler struct {
roomQueryClient client.RoomQueryClient
userProfileClient client.UserProfileClient
userSocialClient client.UserSocialClient
userHostClient client.UserHostClient
walletClient client.WalletClient
rtcTokenConfig tencentrtc.TokenConfig
}
@ -26,6 +27,7 @@ type Config struct {
RoomQueryClient client.RoomQueryClient
UserProfileClient client.UserProfileClient
UserSocialClient client.UserSocialClient
UserHostClient client.UserHostClient
WalletClient client.WalletClient
RTCTokenConfig tencentrtc.TokenConfig
}
@ -37,6 +39,7 @@ func New(config Config) *Handler {
roomQueryClient: config.RoomQueryClient,
userProfileClient: config.UserProfileClient,
userSocialClient: config.UserSocialClient,
userHostClient: config.UserHostClient,
walletClient: config.WalletClient,
rtcTokenConfig: config.RTCTokenConfig,
}

View File

@ -1259,19 +1259,50 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs))
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
})
httpkit.Write(writer, request, resp, err)
}
func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
if targetUserID <= 0 || h.userHostClient == nil {
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed避免客户端伪造主播入账。
return false, 0, 0, nil
}
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err != nil {
if xerr.ReasonFromGRPC(err) == xerr.NotFound {
return false, 0, 0, nil
}
return false, 0, 0, err
}
profile := resp.GetHostProfile()
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
// 只有 active host 且带区域才能进入工资政策链路disabled/缺区域按非主播处理,不影响正常送礼。
return false, 0, 0, nil
}
return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil
}
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {

View File

@ -27,6 +27,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
RoomQueryClient: h.roomQueryClient,
UserProfileClient: h.userProfileClient,
UserSocialClient: h.userSocialClient,
UserHostClient: h.userHostClient,
WalletClient: h.walletClient,
RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
@ -39,6 +40,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
})
walletAPI := walletapi.New(walletapi.Config{
WalletClient: h.walletClient,
RoomGuardClient: h.roomGuardClient,
RoomQueryClient: h.roomQueryClient,
UserIdentityClient: h.userIdentityClient,
UserProfileClient: h.userProfileClient,

View File

@ -6,6 +6,7 @@ import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"net/http/httptest"
"strings"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
@ -23,6 +24,7 @@ type fakeUserSocialClient struct {
lastDelete *userv1.DeleteFriendRequest
lastFriends *userv1.ListFriendsRequest
lastFriendApps *userv1.ListFriendApplicationsRequest
lastReport *userv1.SubmitReportRequest
}
func (f *fakeUserSocialClient) RecordProfileVisit(_ context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
@ -75,6 +77,26 @@ func (f *fakeUserSocialClient) ListFriendApplications(_ context.Context, req *us
return &userv1.ListFriendApplicationsResponse{Applications: []*userv1.FriendApplication{{RequesterUserId: 10002, TargetUserId: req.GetUserId(), Status: "pending", CreatedAtMs: 9000, UpdatedAtMs: 9000}}, Total: 1}, nil
}
func (f *fakeUserSocialClient) SubmitReport(_ context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
f.lastReport = req
targetType := "user"
if req.GetRoomId() != "" {
targetType = "room"
}
return &userv1.SubmitReportResponse{Report: &userv1.UserReport{
ReportId: "rpt-test",
ReporterUserId: req.GetReporterUserId(),
TargetType: targetType,
UserId: req.GetUserId(),
RoomId: req.GetRoomId(),
ReportType: req.GetReportType(),
Reason: req.GetReason(),
ImageUrls: req.GetImageUrls(),
Status: "submitted",
CreatedAtMs: 1778000000000,
}}, nil
}
func TestFriendApplicationFlowRoutesUseBackendThenIMOwnedReminder(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})
@ -104,6 +126,28 @@ func TestFriendApplicationFlowRoutesUseBackendThenIMOwnedReminder(t *testing.T)
}
}
func TestSubmitReportRoutePassesAuthenticatedReporterAndSingleTarget(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})
handler.SetUserSocialClient(socialClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/reports", strings.NewReader(`{"user_id":"10002","report_type":"pornography","reason":"bad","image_urls":["https://cdn.example.com/a.png"]}`))
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 10001, true))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("submit report status = %d body=%s", response.Code, response.Body.String())
}
if socialClient.lastReport == nil || socialClient.lastReport.GetReporterUserId() != 10001 || socialClient.lastReport.GetUserId() != 10002 || socialClient.lastReport.GetRoomId() != "" || socialClient.lastReport.GetReportType() != "pornography" {
t.Fatalf("submit report request mismatch: %+v", socialClient.lastReport)
}
var envelope httpkit.ResponseEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || envelope.Code != httpkit.CodeOK {
t.Fatalf("invalid envelope: %+v err=%v", envelope, err)
}
}
func TestSocialListRoutesPassPagingAndDirection(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})

View File

@ -61,6 +61,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ListMyFriendApplications: h.listMyFriendApplications,
GetMyProfile: h.getMyProfile,
UserSocialAction: h.userSocialAction,
SubmitReport: h.submitReport,
}
}

View File

@ -17,6 +17,8 @@ type hostAgencyData struct {
RegionID int64 `json:"region_id"`
ParentBDUserID string `json:"parent_bd_user_id,omitempty"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
HostCount int32 `json:"host_count"`
Status string `json:"status"`
JoinEnabled bool `json:"join_enabled"`
MaxHosts int32 `json:"max_hosts"`
@ -48,17 +50,13 @@ func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.R
if keyword == "" {
keyword = strings.TrimSpace(request.URL.Query().Get("keyword"))
}
if keyword == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 100)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if pageSize > 20 {
pageSize = 20
if pageSize > 100 {
pageSize = 100
}
resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{
Meta: httpkit.UserMeta(request, ""),
@ -123,6 +121,8 @@ func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData {
RegionID: agency.GetRegionId(),
ParentBDUserID: int64String(agency.GetParentBdUserId()),
Name: agency.GetName(),
Avatar: agency.GetAvatar(),
HostCount: agency.GetHostCount(),
Status: agency.GetStatus(),
JoinEnabled: agency.GetJoinEnabled(),
MaxHosts: agency.GetMaxHosts(),

View File

@ -0,0 +1,94 @@
package userapi
import (
"encoding/json"
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type submitReportData struct {
ReportID string `json:"report_id"`
TargetType string `json:"target_type"`
UserID string `json:"user_id"`
RoomID string `json:"room_id"`
ReportType string `json:"report_type"`
Status string `json:"status"`
CreatedAtMs int64 `json:"created_at_ms"`
}
// submitReport 只把当前登录用户和举报目标透传到 user-service举报发起人不能来自客户端 body。
func (h *Handler) submitReport(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
UserID json.RawMessage `json:"user_id"`
RoomID string `json:"room_id"`
ReportType string `json:"report_type"`
Reason string `json:"reason"`
ImageURLs []string `json:"image_urls"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
userID, ok := optionalReportUserID(body.UserID)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.SubmitReport(request.Context(), &userv1.SubmitReportRequest{
Meta: httpkit.UserMeta(request, ""),
ReporterUserId: auth.UserIDFromContext(request.Context()),
UserId: userID,
RoomId: body.RoomID,
ReportType: body.ReportType,
Reason: body.Reason,
ImageUrls: body.ImageURLs,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, reportData(resp.GetReport()))
}
func optionalReportUserID(raw json.RawMessage) (int64, bool) {
if len(raw) == 0 || string(raw) == "null" {
return 0, true
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
text = strings.TrimSpace(text)
if text == "" {
return 0, true
}
value, err := strconv.ParseInt(text, 10, 64)
return value, err == nil && value > 0
}
var value int64
if err := json.Unmarshal(raw, &value); err != nil {
return 0, false
}
return value, value > 0
}
func reportData(report *userv1.UserReport) submitReportData {
if report == nil {
return submitReportData{}
}
return submitReportData{
ReportID: report.GetReportId(),
TargetType: report.GetTargetType(),
UserID: httpkit.UserIDString(report.GetUserId()),
RoomID: report.GetRoomId(),
ReportType: report.GetReportType(),
Status: report.GetStatus(),
CreatedAtMs: report.GetCreatedAtMs(),
}
}

View File

@ -11,6 +11,7 @@ import (
// It keeps wallet semantics behind wallet-service and only handles HTTP protocol conversion.
type Handler struct {
walletClient client.WalletClient
roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
@ -19,6 +20,7 @@ type Handler struct {
type Config struct {
WalletClient client.WalletClient
RoomGuardClient client.RoomGuardClient
RoomQueryClient client.RoomQueryClient
UserIdentityClient client.UserIdentityClient
UserProfileClient client.UserProfileClient
@ -28,6 +30,7 @@ type Config struct {
func New(config Config) *Handler {
return &Handler{
walletClient: config.WalletClient,
roomGuardClient: config.RoomGuardClient,
roomQueryClient: config.RoomQueryClient,
userIdentityClient: config.UserIdentityClient,
userProfileClient: config.UserProfileClient,
@ -52,6 +55,14 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
CreateRoomRedPacket: h.createRoomRedPacket,
GetRedPacket: h.getRedPacket,
ClaimRedPacket: h.claimRedPacket,
GetCurrentRegionIMGroup: h.getCurrentRegionIMGroup,
GetRedPacketIMGroup: h.getCurrentRegionIMGroup,
SendVoiceRoomRedPacket: h.sendVoiceRoomRedPacket,
ListActiveRedPackets: h.listActiveRedPackets,
ClaimVoiceRoomRedPacket: h.claimVoiceRoomRedPacket,
GetVoiceRoomRedPacket: h.getVoiceRoomRedPacket,
ListRedPacketClaims: h.listRedPacketClaims,
ListSentRedPackets: h.listSentRedPackets,
}
}

View File

@ -1,14 +1,18 @@
package walletapi
import (
"fmt"
"net/http"
"strconv"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/imgroup"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
@ -22,6 +26,7 @@ type redPacketConfigData struct {
ExpireSeconds int32 `json:"expire_seconds"`
DailySendLimit int32 `json:"daily_send_limit"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
RuleURL string `json:"rule_url"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
@ -113,6 +118,377 @@ func (b claimRedPacketRequestBody) normalizedCommandID() string {
return strings.TrimSpace(b.CommandIDCamel)
}
type voiceRoomRedPacketSendBody struct {
RoomID string `json:"roomId"`
RoomIDSnake string `json:"room_id"`
PacketMode string `json:"packetMode"`
PacketType string `json:"packet_type"`
TotalAmount int64 `json:"totalAmount"`
TotalAmountV2 int64 `json:"total_amount"`
TotalCount int32 `json:"totalCount"`
PacketCount int32 `json:"packet_count"`
RequestID string `json:"requestId"`
RequestIDV2 string `json:"request_id"`
}
func (b voiceRoomRedPacketSendBody) roomID() string {
if value := strings.TrimSpace(b.RoomID); value != "" {
return value
}
return strings.TrimSpace(b.RoomIDSnake)
}
func (b voiceRoomRedPacketSendBody) mode() string {
if value := strings.TrimSpace(b.PacketMode); value != "" {
return value
}
return strings.TrimSpace(b.PacketType)
}
func (b voiceRoomRedPacketSendBody) amount() int64 {
if b.TotalAmount > 0 {
return b.TotalAmount
}
return b.TotalAmountV2
}
func (b voiceRoomRedPacketSendBody) count() int32 {
if b.TotalCount > 0 {
return b.TotalCount
}
return b.PacketCount
}
func (b voiceRoomRedPacketSendBody) requestID() string {
if value := strings.TrimSpace(b.RequestID); value != "" {
return value
}
return strings.TrimSpace(b.RequestIDV2)
}
type voiceRoomRedPacketClaimBody struct {
PacketNo string `json:"packetNo"`
PacketID string `json:"packet_id"`
RequestID string `json:"requestId"`
}
func (b voiceRoomRedPacketClaimBody) packetNo() string {
if value := strings.TrimSpace(b.PacketNo); value != "" {
return value
}
return strings.TrimSpace(b.PacketID)
}
func (h *Handler) getCurrentRegionIMGroup(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
user, ok := h.currentUser(writer, request)
if !ok {
return
}
if user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
return
}
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(request.Context()), user.GetRegionId())
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
httpkit.WriteOK(writer, request, map[string]any{
"regionId": user.GetRegionId(),
"regionCode": user.GetRegionCode(),
"regionName": user.GetRegionName(),
"groupId": groupID,
"groupName": groupID,
})
}
func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.roomQueryClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body voiceRoomRedPacketSendBody
if !httpkit.Decode(writer, request, &body) {
return
}
roomID := body.roomID()
packetType := internalRedPacketType(body.mode())
requestID := body.requestID()
if !roomid.ValidStringID(roomID) || packetType == "" || body.amount() <= 0 || body.count() <= 0 || requestID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
user, ok := h.currentUser(writer, request)
if !ok {
return
}
if user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
return
}
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
regionID := snapshotResp.GetRoom().GetVisibleRegionId()
if regionID <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
return
}
if regionID != user.GetRegionId() {
httpkit.WriteError(writer, request, http.StatusForbidden, "voice_room_region_not_match", "permission denied")
return
}
commandID := fmt.Sprintf("red_packet_send:%d:%s", auth.UserIDFromContext(request.Context()), requestID)
resp, err := h.walletClient.CreateRedPacket(request.Context(), &walletv1.CreateRedPacketRequest{
RequestId: requestID,
AppCode: appcode.FromContext(request.Context()),
CommandId: commandID,
SenderUserId: auth.UserIDFromContext(request.Context()),
RoomId: roomID,
RegionId: regionID,
PacketType: packetType,
TotalAmount: body.amount(),
PacketCount: body.count(),
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"packet": voiceRoomPacketFromProto(resp.GetPacket()),
"balanceAfter": resp.GetBalanceAfter(),
"serverTime": resp.GetServerTimeMs(),
})
}
func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.URL.Query().Get("roomId"))
if roomID == "" {
roomID = strings.TrimSpace(request.URL.Query().Get("room_id"))
}
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListRedPackets(request.Context(), &walletv1.ListRedPacketsRequest{
AppCode: appcode.FromContext(request.Context()),
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
Page: 1,
PageSize: 100,
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
packets := make([]map[string]any, 0, len(resp.GetPackets()))
for _, packet := range resp.GetPackets() {
if packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open" {
packets = append(packets, voiceRoomPacketFromProto(packet))
}
}
httpkit.WriteOK(writer, request, map[string]any{
"packets": packets,
"serverTime": resp.GetServerTimeMs(),
})
}
func (h *Handler) claimVoiceRoomRedPacket(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.roomGuardClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body voiceRoomRedPacketClaimBody
if !httpkit.Decode(writer, request, &body) {
return
}
packetNo := body.packetNo()
if packetNo == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
packetResp, err := h.walletClient.GetRedPacket(request.Context(), &walletv1.GetRedPacketRequest{
AppCode: appcode.FromContext(request.Context()),
PacketId: packetNo,
ViewerUserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
packet := packetResp.GetPacket()
if !h.verifyRedPacketRoomPresence(writer, request, packet.GetRoomId()) {
return
}
user, ok := h.currentUser(writer, request)
if !ok {
return
}
if user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
return
}
if packet.GetRegionId() > 0 && packet.GetRegionId() != user.GetRegionId() {
httpkit.WriteError(writer, request, http.StatusForbidden, "voice_room_region_not_match", "permission denied")
return
}
commandID := fmt.Sprintf("red_packet_claim:%s:%d", packetNo, auth.UserIDFromContext(request.Context()))
resp, err := h.walletClient.ClaimRedPacket(request.Context(), &walletv1.ClaimRedPacketRequest{
AppCode: appcode.FromContext(request.Context()),
CommandId: commandID,
PacketId: packetNo,
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"claim": voiceRoomClaimFromProto(resp.GetClaim()),
"packet": voiceRoomPacketFromProto(resp.GetPacket()),
"balanceAfter": resp.GetBalanceAfter(),
"serverTime": resp.GetServerTimeMs(),
})
}
func (h *Handler) getVoiceRoomRedPacket(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
packetNo := strings.TrimSpace(request.URL.Query().Get("packetNo"))
if packetNo == "" {
packetNo = strings.TrimSpace(request.URL.Query().Get("packet_id"))
}
if packetNo == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.GetRedPacket(request.Context(), &walletv1.GetRedPacketRequest{
AppCode: appcode.FromContext(request.Context()),
PacketId: packetNo,
ViewerUserId: auth.UserIDFromContext(request.Context()),
IncludeClaims: true,
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"packet": voiceRoomPacketFromProto(resp.GetPacket()),
"serverTime": resp.GetServerTimeMs(),
})
}
func (h *Handler) listRedPacketClaims(writer http.ResponseWriter, request *http.Request) {
packetNo := strings.TrimSpace(request.URL.Query().Get("packetNo"))
if packetNo == "" {
packetNo = strings.TrimSpace(request.URL.Query().Get("packet_id"))
}
if packetNo == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "pageSize", 20)
if !ok {
pageSize, ok = httpkit.PositiveInt32Query(request, "page_size", 20)
}
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.GetRedPacket(request.Context(), &walletv1.GetRedPacketRequest{
AppCode: appcode.FromContext(request.Context()),
PacketId: packetNo,
ViewerUserId: auth.UserIDFromContext(request.Context()),
IncludeClaims: true,
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
all := resp.GetPacket().GetClaims()
start := int((page - 1) * pageSize)
end := start + int(pageSize)
if start > len(all) {
start = len(all)
}
if end > len(all) {
end = len(all)
}
items := make([]map[string]any, 0, end-start)
for _, claim := range all[start:end] {
items = append(items, voiceRoomClaimFromProto(claim))
}
httpkit.WriteOK(writer, request, map[string]any{
"items": items,
"total": len(all),
"page": page,
"pageSize": pageSize,
"serverTime": resp.GetServerTimeMs(),
})
}
func (h *Handler) listSentRedPackets(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "pageSize", 20)
if !ok {
pageSize, ok = httpkit.PositiveInt32Query(request, "page_size", 20)
}
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListRedPackets(request.Context(), &walletv1.ListRedPacketsRequest{
AppCode: appcode.FromContext(request.Context()),
SenderUserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
return
}
items := make([]map[string]any, 0, len(resp.GetPackets()))
for _, packet := range resp.GetPackets() {
items = append(items, voiceRoomPacketFromProto(packet))
}
httpkit.WriteOK(writer, request, map[string]any{
"items": items,
"total": resp.GetTotal(),
"page": page,
"pageSize": pageSize,
"serverTime": resp.GetServerTimeMs(),
})
}
func (h *Handler) getRedPacketConfig(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -305,6 +681,7 @@ func redPacketConfigFromProto(config *walletv1.RedPacketConfig) redPacketConfigD
ExpireSeconds: config.GetExpireSeconds(),
DailySendLimit: config.GetDailySendLimit(),
UpdatedByAdminID: config.GetUpdatedByAdminId(),
RuleURL: config.GetRuleUrl(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
}
@ -376,3 +753,165 @@ func boolQuery(request *http.Request, name string) bool {
parsed, err := strconv.ParseBool(value)
return err == nil && parsed
}
func (h *Handler) currentUser(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) {
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return nil, false
}
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return nil, false
}
user := resp.GetUser()
if user == nil {
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return nil, false
}
return user, true
}
func (h *Handler) verifyRedPacketRoomPresence(writer http.ResponseWriter, request *http.Request, roomID string) bool {
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_red_packet_room_presence_required", "conflict")
return false
}
resp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{
RoomId: roomID,
UserId: auth.UserIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return false
}
if !resp.GetPresent() {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_red_packet_room_presence_required", "conflict")
return false
}
return true
}
func writeRedPacketRPCError(writer http.ResponseWriter, request *http.Request, err error) {
if err == nil {
return
}
reason := xerr.ReasonFromGRPC(err)
statusCode, code, message := httpkit.MapReasonToHTTP(reason)
switch reason {
case xerr.RedPacketDisabled:
code = "voice_room_red_packet_disabled"
case xerr.RedPacketInvalidAmountTier, xerr.RedPacketAmountTooSmall:
code = "voice_room_red_packet_amount_invalid"
case xerr.RedPacketInvalidCountTier:
code = "voice_room_red_packet_count_invalid"
case xerr.RedPacketDailyLimitReached:
code = "voice_room_red_packet_daily_limit_exceeded"
case xerr.InsufficientBalance:
code = "voice_room_red_packet_insufficient_balance"
case xerr.NotFound:
code = "voice_room_red_packet_not_found"
case xerr.RedPacketNotOpen:
code = "voice_room_red_packet_not_started"
case xerr.RedPacketExpired:
code = "voice_room_red_packet_expired"
case xerr.RedPacketSoldOut:
code = "voice_room_red_packet_finished"
case xerr.LedgerConflict:
code = "claim_processing"
}
httpkit.WriteError(writer, request, statusCode, code, message)
}
func internalRedPacketType(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "IMMEDIATE":
return "normal"
case "DELAYED":
return "delayed"
}
switch strings.ToLower(strings.TrimSpace(mode)) {
case "normal":
return "normal"
case "delayed":
return "delayed"
default:
return ""
}
}
func publicRedPacketMode(packetType string) string {
switch strings.ToLower(strings.TrimSpace(packetType)) {
case "normal":
return "IMMEDIATE"
case "delayed":
return "DELAYED"
default:
return strings.ToUpper(strings.TrimSpace(packetType))
}
}
func publicRedPacketStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "waiting_open":
return "WAITING_OPEN"
case "active":
return "ACTIVE"
case "finished":
return "FINISHED"
case "refunded":
return "REFUNDED"
default:
return strings.ToUpper(strings.TrimSpace(status))
}
}
func voiceRoomPacketFromProto(packet *walletv1.RedPacket) map[string]any {
if packet == nil {
return map[string]any{}
}
claims := make([]map[string]any, 0, len(packet.GetClaims()))
for _, claim := range packet.GetClaims() {
claims = append(claims, voiceRoomClaimFromProto(claim))
}
return map[string]any{
"packetNo": packet.GetPacketId(),
"roomId": packet.GetRoomId(),
"regionId": packet.GetRegionId(),
"senderUserId": packet.GetSenderUserId(),
"packetMode": publicRedPacketMode(packet.GetPacketType()),
"totalAmount": packet.GetTotalAmount(),
"totalCount": packet.GetPacketCount(),
"remainingAmount": packet.GetRemainingAmount(),
"remainingCount": packet.GetRemainingCount(),
"status": publicRedPacketStatus(packet.GetStatus()),
"claimStartTime": packet.GetOpenAtMs(),
"expireTime": packet.GetExpiresAtMs(),
"createdAt": packet.GetCreatedAtMs(),
"claimedByMe": packet.GetClaimedByViewer(),
"myClaimAmount": packet.GetViewerClaimAmount(),
"refundAmount": packet.GetRefundAmount(),
"refundStatus": packet.GetRefundStatus(),
"refundedAt": packet.GetRefundedAtMs(),
"claims": claims,
}
}
func voiceRoomClaimFromProto(claim *walletv1.RedPacketClaim) map[string]any {
if claim == nil {
return map[string]any{}
}
return map[string]any{
"claimId": claim.GetClaimId(),
"packetNo": claim.GetPacketId(),
"userId": claim.GetUserId(),
"amount": claim.GetAmount(),
"status": claim.GetStatus(),
"createdAt": claim.GetCreatedAtMs(),
}
}

View File

@ -368,6 +368,12 @@ type SendGift struct {
PoolID string `json:"pool_id,omitempty"`
// GiftCount 是礼物数量,必须为正数。
GiftCount int32 `json:"gift_count"`
// TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。
TargetIsHost bool `json:"target_is_host,omitempty"`
// TargetHostRegionID 是工资政策匹配区域room-service 不自行推导主播身份或区域。
TargetHostRegionID int64 `json:"target_host_region_id,omitempty"`
// TargetAgencyOwnerUserID 是 gateway 从 user-service active host profile 注入的代理收款人快照。
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
// BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
BillingReceiptID string `json:"billing_receipt_id,omitempty"`
// CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。
@ -380,6 +386,10 @@ type SendGift struct {
PriceVersion string `json:"price_version,omitempty"`
// GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。
GiftTypeCode string `json:"gift_type_code,omitempty"`
// HostPeriodDiamondAdded 是 wallet-service 给主播周期钻石账户本次入账的钻石数。
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added,omitempty"`
// HostPeriodCycleKey 是本次周期钻石入账所属 UTC 月周期。
HostPeriodCycleKey string `json:"host_period_cycle_key,omitempty"`
// TreasureTouched 表示本次送礼已经计算过宝箱状态,恢复时直接使用下列确定性快照。
TreasureTouched bool `json:"treasure_touched,omitempty"`
// TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。
@ -473,6 +483,8 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "heat_value")
delete(values, "price_version")
delete(values, "gift_type_code")
delete(values, "host_period_diamond_added")
delete(values, "host_period_cycle_key")
delete(values, "treasure_touched")
delete(values, "treasure_added_energy")
delete(values, "treasure_effective_energy")

View File

@ -30,6 +30,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
GiftID: req.GetGiftId(),
PoolID: strings.TrimSpace(req.GetPoolId()),
GiftCount: req.GetGiftCount(),
TargetIsHost: req.GetTargetIsHost(),
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
}
if cmd.TargetType == "" {
cmd.TargetType = "user"
@ -97,14 +101,17 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
walletStartedAt := time.Now()
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
CommandId: cmd.ID(),
RoomId: cmd.RoomID(),
SenderUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
AppCode: appcode.FromContext(ctx),
RegionId: roomMeta.VisibleRegionID,
CommandId: cmd.ID(),
RoomId: cmd.RoomID(),
SenderUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
AppCode: appcode.FromContext(ctx),
RegionId: roomMeta.VisibleRegionID,
TargetIsHost: cmd.TargetIsHost,
TargetHostRegionId: cmd.TargetHostRegionID,
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
})
walletDebitMS := elapsedMS(walletStartedAt)
if err != nil {
@ -121,6 +128,8 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.HeatValue = heatValue
settledCommand.PriceVersion = billing.GetPriceVersion()
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded()
settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey()
if !luckyEnabled {
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
@ -255,13 +264,15 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
RoomHeat: current.Heat,
RoomVersion: current.Version,
Attributes: map[string]string{
"gift_id": cmd.GiftID,
"pool_id": cmd.PoolID,
"gift_count": fmt.Sprintf("%d", cmd.GiftCount),
"billing_receipt_id": billing.GetBillingReceiptId(),
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
"gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded),
"price_version": settledCommand.PriceVersion,
"gift_id": cmd.GiftID,
"pool_id": cmd.PoolID,
"gift_count": fmt.Sprintf("%d", cmd.GiftCount),
"billing_receipt_id": billing.GetBillingReceiptId(),
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
"gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded),
"price_version": settledCommand.PriceVersion,
"host_period_diamond_added": fmt.Sprintf("%d", settledCommand.HostPeriodDiamondAdded),
"host_period_cycle_key": settledCommand.HostPeriodCycleKey,
},
},
}, records, nil

View File

@ -93,3 +93,42 @@ func TestListRoomOnlineUsersReturnsOwnerAdminRoleAndGiftValue(t *testing.T) {
}
t.Logf("online users response items: %s", payload)
}
func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-host-period",
CoinSpent: 14,
GiftPointAdded: 6,
HeatValue: 22,
GiftTypeCode: "normal",
HostPeriodDiamondAdded: 14,
HostPeriodCycleKey: "2026-05",
}}}
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)}
svc := newTreasureTestService(t, repository, wallet, now)
roomID := "room-host-period-scope"
senderID := int64(2001)
hostID := int64(2002)
createTreasureRoom(t, ctx, svc, roomID, senderID, 9001)
joinTreasureRoom(t, ctx, svc, roomID, hostID)
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: treasureMeta(roomID, senderID, "host-period-scope"),
TargetType: "user",
TargetUserId: hostID,
GiftId: "gift-host-period",
GiftCount: 2,
TargetIsHost: true,
TargetHostRegionId: 8801,
TargetAgencyOwnerUserId: 30001,
}); err != nil {
t.Fatalf("send host period gift failed: %v", err)
}
if wallet.lastDebit == nil || !wallet.lastDebit.GetTargetIsHost() || wallet.lastDebit.GetTargetHostRegionId() != 8801 || wallet.lastDebit.GetTargetAgencyOwnerUserId() != 30001 {
t.Fatalf("room-service must pass host period scope to wallet: %+v", wallet.lastDebit)
}
}

View File

@ -29,11 +29,13 @@ func (c *fixedRoomTreasureClock) Now() time.Time {
}
type treasureTestWallet struct {
debits []*walletv1.DebitGiftResponse
grants []*walletv1.GrantResourceGroupRequest
debits []*walletv1.DebitGiftResponse
grants []*walletv1.GrantResourceGroupRequest
lastDebit *walletv1.DebitGiftRequest
}
func (w *treasureTestWallet) DebitGift(_ context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
func (w *treasureTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.lastDebit = req
if len(w.debits) == 0 {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil
}

View File

@ -146,6 +146,28 @@ CREATE TABLE IF NOT EXISTS user_status_change_logs (
KEY idx_user_status_logs_request (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户状态变更日志表';
CREATE TABLE IF NOT EXISTS user_reports (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
report_id VARCHAR(96) NOT NULL COMMENT '举报单业务 ID',
reporter_user_id BIGINT NOT NULL COMMENT '举报发起人用户 ID',
target_type VARCHAR(16) NOT NULL COMMENT '举报目标类型user/room',
target_user_id BIGINT NULL COMMENT '被举报用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '被举报房间 ID',
report_type VARCHAR(64) NOT NULL COMMENT '举报类型',
reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '举报理由',
image_urls_json JSON NOT NULL COMMENT '举报图片 URL 数组',
status VARCHAR(32) NOT NULL COMMENT '处理状态',
request_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '链路请求 ID用于排查问题',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, report_id),
KEY idx_user_reports_reporter (app_code, reporter_user_id, created_at_ms),
KEY idx_user_reports_target_user (app_code, target_user_id, created_at_ms),
KEY idx_user_reports_room (app_code, room_id, created_at_ms),
KEY idx_user_reports_status (app_code, status, created_at_ms),
KEY idx_user_reports_request (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户举报表';
CREATE TABLE IF NOT EXISTS display_user_id_sequences (
app_code VARCHAR(32) NOT NULL PRIMARY KEY COMMENT '应用编码,用于多租户隔离',
next_value BIGINT NOT NULL COMMENT '下一个值',

View File

@ -135,10 +135,12 @@ type HostProfile struct {
RegionID int64
CurrentAgencyID int64
CurrentMembershipID int64
Source string
FirstBecameHostAtMs int64
CreatedAtMs int64
UpdatedAtMs int64
// CurrentAgencyOwnerUserID 是当前 Agency 的 owner 用户 ID供 wallet 在送礼时固化代理工资收款人。
CurrentAgencyOwnerUserID int64
Source string
FirstBecameHostAtMs int64
CreatedAtMs int64
UpdatedAtMs int64
}
// Agency 是主播组织事实,由一个用户拥有并归属于 BD。
@ -148,6 +150,8 @@ type Agency struct {
RegionID int64
ParentBDUserID int64
Name string
OwnerAvatar string
ActiveHostCount int32
Status string
JoinEnabled bool
MaxHosts int32

View File

@ -192,6 +192,12 @@ const (
FriendApplicationStatusPending = "pending"
// FriendApplicationStatusAccepted 表示好友申请已同意。
FriendApplicationStatusAccepted = "accepted"
// ReportTargetUser 表示举报目标是单个用户。
ReportTargetUser = "user"
// ReportTargetRoom 表示举报目标是单个房间。
ReportTargetRoom = "room"
// ReportStatusSubmitted 表示举报单已提交,等待后台治理流程处理。
ReportStatusSubmitted = "submitted"
)
// ProfileVisitRecord 是访问某个用户主页的去重记录。
@ -225,6 +231,36 @@ type FriendApplication struct {
UpdatedAtMs int64
}
// Report 是 App 用户提交的治理举报事实。
type Report struct {
// AppCode 是举报所属 App后台治理只在同 App 范围内处理。
AppCode string
// ReportID 是举报单不可变业务 ID。
ReportID string
// ReporterUserID 是举报发起人,只能来自已认证 token。
ReporterUserID int64
// TargetType 区分 user/room避免靠空字段猜测举报对象。
TargetType string
// UserID 是被举报用户 ID房间举报时为 0。
UserID int64
// RoomID 是被举报房间 ID用户举报时为空。
RoomID string
// ReportType 是客户端选择的稳定举报类型枚举。
ReportType string
// Reason 是用户补充的举报理由。
Reason string
// ImageURLs 是用户上传后的举报图片 URL 快照。
ImageURLs []string
// Status 是举报单当前处理状态。
Status string
// RequestID 是提交举报时的 gateway 链路追踪 ID。
RequestID string
// CreatedAtMs 是举报创建时间UTC epoch ms。
CreatedAtMs int64
// UpdatedAtMs 是举报最后更新时间UTC epoch ms。
UpdatedAtMs int64
}
// BusinessUserLookupItem 是带业务场景权限裁剪后的用户查询结果。
// 它只包含选择目标用户所需字段,不能扩展手机号、登录身份或钱包资产。
type BusinessUserLookupItem struct {

View File

@ -1,6 +1,6 @@
package host
// SearchAgenciesCommand 描述同区域 Agency 搜索条件。
// SearchAgenciesCommand 描述同国家 Agency 搜索条件。
type SearchAgenciesCommand struct {
UserID int64
Keyword string

View File

@ -86,7 +86,7 @@ func WithClock(now func() time.Time) Option {
}
}
// SearchAgencies 返回当前用户同区域可加入的有效 Agency。
// SearchAgencies 返回当前用户同国家可加入的有效 Agency。
func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword string, pageSize int32) ([]hostdomain.Agency, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
@ -100,7 +100,7 @@ func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword stri
limit = 50
}
// 业务层只负责输入归一化;用户区域过滤由持久化层读取 users 当前事实完成。
// 业务层只负责输入归一化;用户国家过滤由持久化层读取 users 当前事实完成。
return s.repository.SearchAgencies(ctx, SearchAgenciesCommand{
UserID: userID,
Keyword: strings.TrimSpace(keyword),

View File

@ -31,13 +31,19 @@ func newHostService(repository *mysqltest.Repository, firstID int64, nowMs int64
}
func seedActiveUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) {
t.Helper()
seedActiveUserInCountry(t, repository, userID, regionID, "CN", "")
}
func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64, country string, avatar string) {
t.Helper()
repository.PutUser(userdomain.User{
UserID: userID,
DefaultDisplayUserID: displayID(userID),
CurrentDisplayUserID: displayID(userID),
Country: "CN",
Country: country,
RegionID: regionID,
Avatar: avatar,
ProfileCompleted: true,
ProfileCompletedAtMs: 1,
OnboardingStatus: userdomain.OnboardingStatusCompleted,
@ -360,8 +366,9 @@ func TestAdminCreateBDLeaderRejectsDisabledRegion(t *testing.T) {
func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 901, 30)
seedActiveUserInCountry(t, repository, 901, 30, "CN", "https://cdn.example/owner-901.png")
seedActiveUser(t, repository, 902, 30)
seedActiveUserInCountry(t, repository, 903, 30, "US", "")
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 900,
Role: hostdomain.BDRoleLeader,
@ -393,7 +400,27 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
if created.Membership.MembershipType != hostdomain.MembershipTypeOwner || created.Membership.Status != hostdomain.MembershipStatusActive {
t.Fatalf("owner membership mismatch: %+v", created.Membership)
}
agencies, err := svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
if _, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
CommandID: "admin-create-agency-903",
AdminUserID: 1,
OwnerUserID: 903,
ParentBDUserID: 900,
Name: "Other Country Agency",
JoinEnabled: true,
MaxHosts: 100,
Reason: "seed agency",
RequestID: "req-agency-2",
}); err != nil {
t.Fatalf("CreateAgency in other country failed: %v", err)
}
agencies, err := svc.SearchAgencies(ctx, 902, "", 20)
if err != nil {
t.Fatalf("SearchAgencies list failed: %v", err)
}
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID || agencies[0].OwnerAvatar != "https://cdn.example/owner-901.png" || agencies[0].ActiveHostCount != 1 {
t.Fatalf("created agency list should be scoped by country with display fields: %+v", agencies)
}
agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
if err != nil {
t.Fatalf("SearchAgencies failed: %v", err)
}

View File

@ -105,6 +105,7 @@ type fakeModerationRepository struct {
user userdomain.User
sessionIDs []string
lastCommand UserStatusCommand
lastReport userdomain.Report
}
func (r *fakeModerationRepository) SetUserStatus(_ context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error) {
@ -161,6 +162,11 @@ func (r *fakeModerationRepository) ListFriendApplications(context.Context, int64
return nil, 0, nil
}
func (r *fakeModerationRepository) SubmitReport(_ context.Context, report userdomain.Report) (userdomain.Report, error) {
r.lastReport = report
return report, nil
}
func (r *fakeModerationRepository) BusinessUserLookup(context.Context, string, bool, int32) ([]userdomain.BusinessUserLookupItem, error) {
return nil, nil
}

View File

@ -0,0 +1,117 @@
package user
import (
"context"
"net/url"
"strings"
"unicode/utf8"
"hyapp/pkg/idgen"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
maxReportReasonRunes = 512
maxReportImages = 9
maxReportImageURLLen = 1024
)
var validReportTypes = map[string]struct{}{
"politics": {},
"pornography": {},
"graphic_violence": {},
"verbal_abuse": {},
"fraud": {},
"illegal_content": {},
"minors": {},
"in_person_transactions": {},
"other": {},
}
// SubmitReport 校验 App 举报目标和证据 URL并把不可变举报事实交给 user repository 持久化。
func (s *Service) SubmitReport(ctx context.Context, reporterUserID int64, userID int64, roomID string, reportType string, reason string, imageURLs []string, requestID string) (userdomain.Report, error) {
if s.userRepository == nil {
return userdomain.Report{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
reporterUserID, userID, roomID, reportType, reason, imageURLs, targetType, err := normalizeReportInput(reporterUserID, userID, roomID, reportType, reason, imageURLs)
if err != nil {
return userdomain.Report{}, err
}
nowMs := s.now().UnixMilli()
report := userdomain.Report{
ReportID: idgen.New("rpt"),
ReporterUserID: reporterUserID,
TargetType: targetType,
UserID: userID,
RoomID: roomID,
ReportType: reportType,
Reason: reason,
ImageURLs: imageURLs,
Status: userdomain.ReportStatusSubmitted,
RequestID: strings.TrimSpace(requestID),
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
return s.userRepository.SubmitReport(ctx, report)
}
func normalizeReportInput(reporterUserID int64, userID int64, roomID string, reportType string, reason string, imageURLs []string) (int64, int64, string, string, string, []string, string, error) {
roomID = strings.TrimSpace(roomID)
reportType = strings.ToLower(strings.TrimSpace(reportType))
reason = strings.TrimSpace(reason)
if reporterUserID <= 0 {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "reporter_user_id is required")
}
hasUser := userID > 0
hasRoom := roomID != ""
if hasUser == hasRoom {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "user_id and room_id must be exactly one")
}
if hasUser && userID == reporterUserID {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "cannot report self")
}
targetType := userdomain.ReportTargetUser
if hasRoom {
if !roomid.ValidStringID(roomID) {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
targetType = userdomain.ReportTargetRoom
}
if _, ok := validReportTypes[reportType]; !ok {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "report_type is invalid")
}
if utf8.RuneCountInString(reason) > maxReportReasonRunes {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "reason is too long")
}
normalizedImages, err := normalizeReportImageURLs(imageURLs)
if err != nil {
return 0, 0, "", "", "", nil, "", err
}
return reporterUserID, userID, roomID, reportType, reason, normalizedImages, targetType, nil
}
func normalizeReportImageURLs(imageURLs []string) ([]string, error) {
if len(imageURLs) > maxReportImages {
return nil, xerr.New(xerr.InvalidArgument, "too many report images")
}
normalized := make([]string, 0, len(imageURLs))
for _, raw := range imageURLs {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if len(value) > maxReportImageURLLen {
return nil, xerr.New(xerr.InvalidArgument, "image url is too long")
}
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, xerr.New(xerr.InvalidArgument, "image url is invalid")
}
normalized = append(normalized, value)
}
return normalized, nil
}

View File

@ -0,0 +1,47 @@
package user
import (
"context"
"testing"
"time"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
func TestSubmitReportAcceptsExactlyOneTarget(t *testing.T) {
repository := &fakeModerationRepository{}
svc := New(repository, WithClock(func() time.Time { return time.UnixMilli(1_778_000_000_000).UTC() }))
ctx := appcode.WithContext(context.Background(), "lalu")
report, err := svc.SubmitReport(ctx, 10001, 10002, "", " Pornography ", " reason ", []string{" https://cdn.example.com/report.png "}, "req-report")
if err != nil {
t.Fatalf("SubmitReport failed: %v", err)
}
if report.ReportID == "" || report.TargetType != userdomain.ReportTargetUser || report.ReportType != "pornography" || report.Reason != "reason" || len(report.ImageURLs) != 1 {
t.Fatalf("report normalization mismatch: %+v", report)
}
if repository.lastReport.ReporterUserID != 10001 || repository.lastReport.UserID != 10002 || repository.lastReport.RequestID != "req-report" || repository.lastReport.CreatedAtMs != 1_778_000_000_000 {
t.Fatalf("repository report mismatch: %+v", repository.lastReport)
}
}
func TestSubmitReportRejectsMissingOrMixedTarget(t *testing.T) {
svc := New(&fakeModerationRepository{})
ctx := appcode.WithContext(context.Background(), "lalu")
tests := []struct {
name string
userID int64
roomID string
}{
{name: "missing target"},
{name: "mixed target", userID: 10002, roomID: "room_1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := svc.SubmitReport(ctx, 10001, test.userID, test.roomID, "fraud", "", nil, "req-report"); err == nil {
t.Fatalf("SubmitReport must reject target combination user=%d room=%q", test.userID, test.roomID)
}
})
}
}

View File

@ -37,6 +37,8 @@ type UserRepository interface {
ListFriends(ctx context.Context, userID int64, page int32, pageSize int32, cursorUpdatedAtMS int64, cursorUserID int64) ([]userdomain.FriendRecord, int64, error)
// ListFriendApplications 分页读取好友申请。
ListFriendApplications(ctx context.Context, userID int64, direction string, page int32, pageSize int32) ([]userdomain.FriendApplication, int64, error)
// SubmitReport 持久化 App 用户提交的治理举报单。
SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error)
// BusinessUserLookup 按已授权业务场景查询目标用户,返回字段必须保持最小化。
BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error)
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。

View File

@ -31,8 +31,16 @@ const (
// 可空外键在存储边界统一转成 0让领域结构体保持普通值类型。
hostProfileColumns = `
user_id, status, region_id, COALESCE(current_agency_id, 0),
COALESCE(current_membership_id, 0), source, first_became_host_at_ms,
created_at_ms, updated_at_ms`
COALESCE(current_membership_id, 0),
COALESCE((
SELECT owner_user_id
FROM agencies
WHERE agencies.app_code = host_profiles.app_code
AND agencies.agency_id = host_profiles.current_agency_id
AND agencies.status = 'active'
LIMIT 1
), 0),
source, first_became_host_at_ms, created_at_ms, updated_at_ms`
agencyColumns = `
agency_id, owner_user_id, region_id, parent_bd_user_id, name, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms`
@ -97,6 +105,27 @@ func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64,
return regionID, nil
}
func (r *Repository) userCountry(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (string, error) {
// App 侧 Agency 推荐按用户当前国家收敛;国家来自用户资料事实,不能由客户端传入。
query := "SELECT COALESCE(country, ''), status FROM users WHERE app_code = ? AND user_id = ?"
if strings.TrimSpace(lockClause) != "" {
query += " " + lockClause
}
var country string
var status string
err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&country, &status)
if err == sql.ErrNoRows {
return "", xerr.New(xerr.NotFound, "user not found")
}
if err != nil {
return "", err
}
if status != "active" {
return "", xerr.New(xerr.UserDisabled, "user is not active")
}
return strings.TrimSpace(country), nil
}
func requireActiveRegion(ctx context.Context, q sqlQueryer, regionID int64) error {
if regionID <= 0 {
return xerr.New(xerr.InvalidArgument, "region_id is required")

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"hyapp/pkg/appcode"
@ -12,33 +13,68 @@ import (
hostservice "hyapp/services/user-service/internal/service/host"
)
// SearchAgencies 返回用户当前区域内可加入的有效 Agency。
// SearchAgencies 返回用户当前国家内可加入的有效 Agency。
func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.SearchAgenciesCommand) ([]hostdomain.Agency, error) {
regionID, err := r.userRegion(ctx, r.db, command.UserID, "")
country, err := r.userCountry(ctx, r.db, command.UserID, "")
if err != nil {
return nil, err
}
if regionID <= 0 {
// 区域是 Agency 搜索和申请的硬边界,缺失时不能退化成全局搜索
return nil, xerr.New(xerr.InvalidArgument, "user region is required")
if country == "" {
// 国家是 App 推荐列表的硬边界,缺失时不能退化成跨国或全局列表
return nil, xerr.New(xerr.InvalidArgument, "user country is required")
}
query := fmt.Sprintf(`
SELECT %s
FROM agencies
WHERE app_code = ? AND region_id = ? AND status = ? AND join_enabled = TRUE
`, agencyColumns)
args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive}
query := `
SELECT
a.agency_id,
a.owner_user_id,
a.region_id,
a.parent_bd_user_id,
a.name,
a.status,
a.join_enabled,
a.max_hosts,
a.created_by_user_id,
a.created_at_ms,
a.updated_at_ms,
COALESCE(owner.avatar, ''),
COUNT(active_member.membership_id)
FROM agencies a
INNER JOIN users owner
ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
LEFT JOIN agency_memberships active_member
ON active_member.app_code = a.app_code
AND active_member.agency_id = a.agency_id
AND active_member.status = ?
WHERE a.app_code = ?
AND owner.country = ?
AND owner.status = 'active'
AND a.status = ?
AND a.join_enabled = TRUE
`
args := []any{hostdomain.MembershipStatusActive, appcode.FromContext(ctx), country, hostdomain.AgencyStatusActive}
if strings.TrimSpace(command.Keyword) != "" {
keyword := strings.TrimSpace(command.Keyword)
query += ` AND (name LIKE ? OR owner_user_id IN (
SELECT user_id
FROM users
WHERE app_code = ? AND current_display_user_id = ?
))`
args = append(args, "%"+keyword+"%", appcode.FromContext(ctx), keyword)
query += ` AND (a.name LIKE ? OR a.agency_id = ? OR owner.current_display_user_id = ?)`
args = append(args, "%"+keyword+"%", int64FromKeyword(keyword), keyword)
}
query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?"
query += `
GROUP BY
a.agency_id,
a.owner_user_id,
a.region_id,
a.parent_bd_user_id,
a.name,
a.status,
a.join_enabled,
a.max_hosts,
a.created_by_user_id,
a.created_at_ms,
a.updated_at_ms,
owner.avatar
ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ?
`
args = append(args, command.PageSize)
rows, err := r.db.QueryContext(ctx, query, args...)
@ -49,7 +85,7 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
agencies := make([]hostdomain.Agency, 0, command.PageSize)
for rows.Next() {
agency, err := scanAgency(rows)
agency, err := scanSearchAgency(rows)
if err != nil {
return nil, err
}
@ -314,6 +350,7 @@ func scanHostProfile(scanner rowScanner) (hostdomain.HostProfile, error) {
&profile.RegionID,
&profile.CurrentAgencyID,
&profile.CurrentMembershipID,
&profile.CurrentAgencyOwnerUserID,
&profile.Source,
&profile.FirstBecameHostAtMs,
&profile.CreatedAtMs,
@ -382,6 +419,34 @@ func scanAgency(scanner rowScanner) (hostdomain.Agency, error) {
return agency, err
}
func scanSearchAgency(scanner rowScanner) (hostdomain.Agency, error) {
var agency hostdomain.Agency
err := scanner.Scan(
&agency.AgencyID,
&agency.OwnerUserID,
&agency.RegionID,
&agency.ParentBDUserID,
&agency.Name,
&agency.Status,
&agency.JoinEnabled,
&agency.MaxHosts,
&agency.CreatedByUserID,
&agency.CreatedAtMs,
&agency.UpdatedAtMs,
&agency.OwnerAvatar,
&agency.ActiveHostCount,
)
return agency, err
}
func int64FromKeyword(keyword string) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(keyword), 10, 64)
if err != nil {
return 0
}
return value
}
func queryBDProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.BDProfile, error) {
clause, args = appScopedClause(ctx, clause, args...)
profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_profiles %s`, bdProfileColumns, clause), args...))

View File

@ -0,0 +1,51 @@
package user
import (
"context"
"encoding/json"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// SubmitReport 持久化举报事实。用户目标会校验同 App 存在;房间目标只记录 room_id不回查 room-service 明细表。
func (r *Repository) SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.Report{}, err
}
defer tx.Rollback()
appCode := appcode.FromContext(ctx)
if err := ensureSocialUser(ctx, tx, appCode, report.ReporterUserID); err != nil {
return userdomain.Report{}, err
}
if report.UserID > 0 {
if err := ensureSocialUser(ctx, tx, appCode, report.UserID); err != nil {
return userdomain.Report{}, err
}
}
imageURLsJSON, err := json.Marshal(report.ImageURLs)
if err != nil {
return userdomain.Report{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_reports (
app_code, report_id, reporter_user_id, target_type, target_user_id, room_id,
report_type, reason, image_urls_json, status, request_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, report.ReportID, report.ReporterUserID, report.TargetType, nullableReportUserID(report.UserID), report.RoomID,
report.ReportType, report.Reason, string(imageURLsJSON), report.Status, report.RequestID, report.CreatedAtMs, report.UpdatedAtMs,
); err != nil {
return userdomain.Report{}, err
}
report.AppCode = appCode
return report, tx.Commit()
}
func nullableReportUserID(userID int64) any {
if userID <= 0 {
return nil
}
return userID
}

View File

@ -111,6 +111,10 @@ func (r *Repository) ListFriendApplications(ctx context.Context, userID int64, d
return r.Repository.UserRepository().ListFriendApplications(ctx, userID, direction, page, pageSize)
}
func (r *Repository) SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error) {
return r.Repository.UserRepository().SubmitReport(ctx, report)
}
// BusinessUserLookup 让测试 wrapper 继续满足业务场景用户查询接口。
func (r *Repository) BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
return r.Repository.UserRepository().BusinessUserLookup(ctx, keyword, numericKeyword, pageSize)

View File

@ -176,6 +176,21 @@ func toProtoFriendApplication(application userdomain.FriendApplication) *userv1.
}
}
func toProtoUserReport(report userdomain.Report) *userv1.UserReport {
return &userv1.UserReport{
ReportId: report.ReportID,
ReporterUserId: report.ReporterUserID,
TargetType: report.TargetType,
UserId: report.UserID,
RoomId: report.RoomID,
ReportType: report.ReportType,
Reason: report.Reason,
ImageUrls: append([]string{}, report.ImageURLs...),
Status: report.Status,
CreatedAtMs: report.CreatedAtMs,
}
}
// toProtoCountry 把国家领域对象转换为管理 RPC 响应。
func toProtoCountry(country userdomain.Country) *userv1.Country {
return &userv1.Country{
@ -278,15 +293,16 @@ func toProtoHostProfile(profile hostdomain.HostProfile) *userv1.HostProfile {
return nil
}
return &userv1.HostProfile{
UserId: profile.UserID,
Status: profile.Status,
RegionId: profile.RegionID,
CurrentAgencyId: profile.CurrentAgencyID,
CurrentMembershipId: profile.CurrentMembershipID,
Source: profile.Source,
FirstBecameHostAtMs: profile.FirstBecameHostAtMs,
CreatedAtMs: profile.CreatedAtMs,
UpdatedAtMs: profile.UpdatedAtMs,
UserId: profile.UserID,
Status: profile.Status,
RegionId: profile.RegionID,
CurrentAgencyId: profile.CurrentAgencyID,
CurrentMembershipId: profile.CurrentMembershipID,
CurrentAgencyOwnerUserId: profile.CurrentAgencyOwnerUserID,
Source: profile.Source,
FirstBecameHostAtMs: profile.FirstBecameHostAtMs,
CreatedAtMs: profile.CreatedAtMs,
UpdatedAtMs: profile.UpdatedAtMs,
}
}
@ -307,6 +323,8 @@ func toProtoAgency(agency hostdomain.Agency) *userv1.Agency {
CreatedByUserId: agency.CreatedByUserID,
CreatedAtMs: agency.CreatedAtMs,
UpdatedAtMs: agency.UpdatedAtMs,
Avatar: agency.OwnerAvatar,
HostCount: agency.ActiveHostCount,
}
}

View File

@ -12,7 +12,7 @@ import (
// 这个文件保持薄传输层:只做 proto 与业务层的数据转换和错误映射。
// 角色授权、区域校验、幂等和事务都在业务层/持久化层,避免不同入口行为分叉。
// SearchAgencies 查询当前用户同区域可加入 Agency。
// SearchAgencies 查询当前用户同国家可加入 Agency。
func (s *Server) SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))

View File

@ -408,6 +408,16 @@ func (s *Server) ListFriendApplications(ctx context.Context, req *userv1.ListFri
return resp, nil
}
// SubmitReport 记录 App 用户提交的用户或房间举报。
func (s *Server) SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
report, err := s.userSvc.SubmitReport(ctx, req.GetReporterUserId(), req.GetUserId(), req.GetRoomId(), req.GetReportType(), req.GetReason(), req.GetImageUrls(), req.GetMeta().GetRequestId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.SubmitReportResponse{Report: toProtoUserReport(report)}, nil
}
// BatchGetUsers 批量返回用户主状态,缺失用户不填充占位对象。
func (s *Server) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -78,6 +78,210 @@ CREATE TABLE IF NOT EXISTS wallet_outbox (
KEY idx_wallet_outbox_tx (app_code, transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包事件 outbox 表';
-- 主播工资周期钻石账户:只记录政策结算用累计值,不作为用户可消费或可提现的钱包资产。
-- cycle_key 当前固定为 UTC yyyy-MM后续日结/半月结不清空该账户,月末清算任务按策略处理。
CREATE TABLE IF NOT EXISTS host_period_diamond_accounts (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键UTC yyyy-MM',
region_id BIGINT NOT NULL COMMENT '主播身份所属区域 ID用于匹配区域工资政策',
agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼入账时主播归属 Agency 的 owner 用户 ID0 表示无代理收款人',
total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计钻石',
gift_diamond_total BIGINT NOT NULL DEFAULT 0 COMMENT '送礼产生的周期钻石累计',
version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
first_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本周期首次收礼时间UTC epoch ms',
last_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本周期最后收礼时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, cycle_key),
KEY idx_host_period_diamond_region_cycle (app_code, region_id, cycle_key),
KEY idx_host_period_diamond_cycle_total (app_code, cycle_key, total_diamonds)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石账户表';
-- 主播工资周期钻石流水:每笔成功送礼只在 target 为 active host 时写入,用于审计和补偿重建周期账户。
CREATE TABLE IF NOT EXISTS host_period_diamond_entries (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '流水 ID',
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
command_id VARCHAR(128) NOT NULL COMMENT '业务命令幂等 ID',
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键UTC yyyy-MM',
region_id BIGINT NOT NULL COMMENT '主播身份所属区域 ID',
agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '本次送礼入账使用的代理收款用户快照',
diamond_delta BIGINT NOT NULL COMMENT '本次增加钻石',
diamond_after BIGINT NOT NULL COMMENT '本次入账后周期累计钻石',
gift_charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '礼物扣费资产类型快照',
gift_charge_amount BIGINT NOT NULL DEFAULT 0 COMMENT '礼物扣费金额快照',
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID',
gift_count INT NOT NULL DEFAULT 0 COMMENT '礼物数量',
sender_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
UNIQUE KEY uk_host_period_diamond_tx (app_code, transaction_id, user_id, cycle_key),
KEY idx_host_period_diamond_entries_user_cycle (app_code, user_id, cycle_key, created_at_ms),
KEY idx_host_period_diamond_entries_region_cycle (app_code, region_id, cycle_key, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石流水表';
-- 运行侧工资政策表:字段与 admin_host_agency_salary_policies 对齐wallet-service 结算只读取这里的已发布快照。
-- 后台配置仍由 admin-server 管理;同步/导入时必须整份写入 policy + levels结算事务不直接读 admin 库。
CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID来自后台配置',
name VARCHAR(120) NOT NULL DEFAULT '' COMMENT '政策名称',
region_id BIGINT NOT NULL COMMENT '适用区域 ID',
status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态active/disabled',
settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式daily/half_month',
settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式automatic/manual',
gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例快照',
residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间0 表示长期有效',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, policy_id),
KEY idx_host_agency_salary_policy_active (app_code, region_id, status, settlement_mode, settlement_trigger_mode, effective_from_ms, effective_to_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策运行快照表';
CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
level_no INT NOT NULL COMMENT '等级',
required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石',
host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计美元工资,单位美分',
host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计金币奖励',
agency_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '代理累计美元工资,单位美分',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, policy_id, level_no),
KEY idx_host_agency_salary_policy_level_required (app_code, policy_id, status, required_diamonds)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级运行快照表';
-- 结算进度按主播+月份独立记录累计已发值;日结/半月结只更新累计,月底清算只打 cleared 标记。
-- 这里不覆盖 host_period_diamond_accounts.total_diamonds历史累计钻石必须可审计和可重放。
CREATE TABLE IF NOT EXISTS host_salary_settlement_progress (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键UTC yyyy-MM',
settled_level_no INT NOT NULL DEFAULT 0 COMMENT '已结算到的最高等级',
settled_host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发主播美元工资累计,单位美分',
settled_host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '已发主播金币奖励累计',
settled_agency_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发代理美元工资累计,单位美分',
last_settled_total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '上次结算任务已处理到的周期累计钻石,避免未升档时重复空跑',
residual_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '月底剩余钻石已转主播美元金额,单位美分',
month_end_cleared_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '月底清算完成时间0 表示未清算',
last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的政策 ID',
version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, cycle_key),
KEY idx_host_salary_settlement_progress_cycle (app_code, cycle_key, month_end_cleared_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期结算进度表';
CREATE TABLE IF NOT EXISTS host_salary_settlement_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
settlement_id VARCHAR(128) NOT NULL COMMENT '结算记录 ID',
command_id VARCHAR(128) NOT NULL COMMENT '钱包交易幂等命令 ID',
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
settlement_type VARCHAR(24) NOT NULL COMMENT '结算类型daily/half_month/month_end',
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '代理收款用户 ID',
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键UTC yyyy-MM',
policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的政策 ID',
level_no INT NOT NULL DEFAULT 0 COMMENT '本次结算后的等级',
total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '结算时周期累计钻石',
host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次主播美元工资增量,单位美分',
host_coin_reward_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次主播金币奖励增量',
agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次代理美元工资增量,单位美分',
residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次月底剩余钻石转美元增量,单位美分',
status VARCHAR(24) NOT NULL DEFAULT 'succeeded' COMMENT '状态succeeded/skipped/failed',
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过或失败原因',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_host_salary_settlement_command (app_code, command_id),
KEY idx_host_salary_settlement_user_cycle (app_code, user_id, cycle_key, created_at_ms),
KEY idx_host_salary_settlement_agency_cycle (app_code, agency_owner_user_id, cycle_key, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资结算记录表';
CREATE TABLE IF NOT EXISTS team_salary_settlement_progress (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
policy_type VARCHAR(24) NOT NULL COMMENT '团队工资类型bd/admin',
user_id BIGINT NOT NULL COMMENT '收款用户 IDBD 或 Admin',
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键UTC yyyy-MM',
region_id BIGINT NOT NULL COMMENT '结算时使用的区域 ID',
settled_level_no INT NOT NULL DEFAULT 0 COMMENT '已结算到的最高等级',
settled_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发团队工资累计,单位美分',
last_settled_income_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '上次已处理收入累计,单位美分,用于避免未升档重复空跑',
last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的政策 ID',
month_closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完整月结算关闭时间0 表示未关闭',
version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, policy_type, user_id, cycle_key),
KEY idx_team_salary_progress_cycle (app_code, policy_type, cycle_key, region_id, month_closed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资周期结算进度表';
CREATE TABLE IF NOT EXISTS team_salary_settlement_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
settlement_id VARCHAR(128) NOT NULL COMMENT '结算记录 ID',
command_id VARCHAR(128) NOT NULL COMMENT '钱包交易幂等命令 ID',
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
policy_type VARCHAR(24) NOT NULL COMMENT '团队工资类型bd/admin',
trigger_mode VARCHAR(24) NOT NULL DEFAULT 'manual' COMMENT '触发方式automatic/manual',
user_id BIGINT NOT NULL COMMENT '收款用户 IDBD 或 Admin',
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键UTC yyyy-MM',
region_id BIGINT NOT NULL COMMENT '结算时使用的区域 ID',
policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的政策 ID',
level_no INT NOT NULL DEFAULT 0 COMMENT '本次结算后的等级',
income_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '结算时团队收入累计,单位美分',
salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次团队美元工资增量,单位美分',
status VARCHAR(24) NOT NULL DEFAULT 'succeeded' COMMENT '状态succeeded/skipped/failed',
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过或失败原因',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_team_salary_settlement_command (app_code, command_id),
KEY idx_team_salary_records_user_cycle (app_code, policy_type, user_id, cycle_key, created_at_ms),
KEY idx_team_salary_records_cycle (app_code, policy_type, cycle_key, region_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资结算记录表';
-- 老环境已建过主播周期账户时CREATE TABLE IF NOT EXISTS 不会补新字段;结算前必须有代理收款快照。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_period_diamond_accounts' AND COLUMN_NAME = 'agency_owner_user_id') = 0,
'ALTER TABLE host_period_diamond_accounts ADD COLUMN agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''送礼入账时主播归属 Agency 的 owner 用户 ID0 表示无代理收款人'' AFTER region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_period_diamond_entries' AND COLUMN_NAME = 'agency_owner_user_id') = 0,
'ALTER TABLE host_period_diamond_entries ADD COLUMN agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''本次送礼入账使用的代理收款用户快照'' AFTER region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies' AND COLUMN_NAME = 'settlement_trigger_mode') = 0,
'ALTER TABLE host_agency_salary_policies ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT ''automatic'' COMMENT ''结算触发方式automatic/manual'' AFTER settlement_mode',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_salary_settlement_progress' AND COLUMN_NAME = 'last_settled_total_diamonds') = 0,
'ALTER TABLE host_salary_settlement_progress ADD COLUMN last_settled_total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT ''上次结算任务已处理到的周期累计钻石,避免未升档时重复空跑'' AFTER settled_agency_salary_usd_minor',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 老环境可能已经存在 wallet_outboxCREATE TABLE IF NOT EXISTS 不会补齐后续新增的投递锁字段。
-- 这里用 information_schema 生成幂等 ALTER保证重复执行 initdb 时不会因重复列名失败。
SET @ddl := IF(
@ -666,8 +870,9 @@ CREATE TABLE IF NOT EXISTS red_packet_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用红包',
delayed_open_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟红包打开秒数',
expire_seconds INT NOT NULL DEFAULT 300 COMMENT '红包过期秒数',
expire_seconds INT NOT NULL DEFAULT 86400 COMMENT '红包过期秒数,房内红包固定 24 小时',
daily_send_limit INT NOT NULL DEFAULT 0 COMMENT '每用户 UTC 日发送上限0 表示禁止发送',
rule_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '红包规则说明链接',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -97,6 +97,7 @@ func New(cfg config.Config) (*App, error) {
svc.SetGooglePlayClient(googleClient)
}
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
walletv1.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc))
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,

View File

@ -11,6 +11,8 @@ const (
AssetGiftPoint = "GIFT_POINT"
// AssetDiamond 是后续兑换用资产,当前只在余额模型中保留。
AssetDiamond = "DIAMOND"
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND"
// AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。
AssetUSDBalance = "USD_BALANCE"
@ -46,6 +48,17 @@ const (
// WithdrawalStatusPending 表示主播美元余额提现已提交,等待后台人工审核和线下转账。
WithdrawalStatusPending = "pending"
// HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。
HostSalarySettlementModeDaily = "daily"
// HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。
HostSalarySettlementModeHalfMonth = "half_month"
// HostSalarySettlementTypeMonthEnd 表示月底清算等级增量、剩余钻石折美元和周期关闭。
HostSalarySettlementTypeMonthEnd = "month_end"
// HostSalarySettlementTriggerAutomatic 表示由 cron-service 自动扫描结算的政策。
HostSalarySettlementTriggerAutomatic = "automatic"
// HostSalarySettlementTriggerManual 表示只允许后台工资结算页人工触发的政策。
HostSalarySettlementTriggerManual = "manual"
// VipStatusActive 表示 VIP 配置或用户会员状态当前有效。
VipStatusActive = "active"
// VipStatusDisabled 表示 VIP 配置被后台停用。
@ -80,6 +93,9 @@ const (
RedPacketStatusRefunded = "refunded"
// RedPacketClaimStatusClaimed 表示红包份额领取成功。
RedPacketClaimStatusClaimed = "claimed"
// RedPacketExpireSeconds 固定房内红包 24 小时过期退款。
RedPacketExpireSeconds int32 = 24 * 60 * 60
)
// DebitGiftCommand 是 room-service 送礼扣费的账务命令。
@ -93,6 +109,12 @@ type DebitGiftCommand struct {
GiftCount int32
PriceVersion string
RegionID int64
// TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。
TargetIsHost bool
// TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。
TargetHostRegionID int64
// TargetAgencyOwnerUserID 是送礼瞬间主播上级代理的收款用户快照;结算按快照发放,避免月底组织变化错账。
TargetAgencyOwnerUserID int64
}
// Receipt 是账务命令落账后的稳定回执。
@ -107,6 +129,58 @@ type Receipt struct {
GiftTypeCode string
PriceVersion string
BalanceAfter int64
// HostPeriodDiamondAdded 是本次送礼写入主播工资周期账户的钻石数;非主播恒为 0。
HostPeriodDiamondAdded int64
// HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。
HostPeriodCycleKey string
}
// HostSalarySettlementBatchCommand 是 cron 或测试触发结算批处理的最小输入。
type HostSalarySettlementBatchCommand struct {
AppCode string
RunID string
WorkerID string
BatchSize int
SettlementType string
// TriggerMode 决定本次批处理读取自动政策还是手动政策;空值按 automatic 处理,兼容已有定时任务。
TriggerMode string
CycleKey string
NowMs int64
}
// HostSalarySettlementBatchResult 汇总一个结算批次的扫描和入账结果。
type HostSalarySettlementBatchResult struct {
ClaimedCount int
ProcessedCount int
SuccessCount int
FailureCount int
HasMore bool
}
// HostSalaryPolicy 是 wallet-service 结算读取的后台政策运行快照。
type HostSalaryPolicy struct {
PolicyID uint64
Name string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
ResidualDiamondToUSDRate string
EffectiveFromMs int64
EffectiveToMs int64
Levels []HostSalaryPolicyLevel
}
// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。
type HostSalaryPolicyLevel struct {
LevelNo int
RequiredDiamonds int64
HostSalaryUSDMinor int64
HostCoinReward int64
AgencySalaryUSDMinor int64
Status string
SortOrder int
}
// CoinSellerTransferCommand 是币商给玩家转普通金币的账务命令。
@ -606,6 +680,7 @@ type RedPacketConfig struct {
ExpireSeconds int32
DailySendLimit int32
UpdatedByAdminID int64
RuleURL string
CreatedAtMS int64
UpdatedAtMS int64
}
@ -631,6 +706,9 @@ type RedPacket struct {
ClaimedByViewer bool
ViewerClaimAmount int64
Claims []RedPacketClaim
RefundAmount int64
RefundStatus string
RefundedAtMS int64
}
// RedPacketClaim 是单个用户抢红包的到账事实。
@ -673,9 +751,15 @@ type RedPacketClaimCommand struct {
type RedPacketClaimReceipt struct {
Claim RedPacketClaim
Packet RedPacket
BalanceAfter int64
}
type RedPacketRefundReceipt struct {
Packet RedPacket
RefundedAmount int64
}
type RedPacketQuery struct {
AppCode string
RoomID string
@ -796,6 +880,12 @@ func NormalizeGameOpType(opType string) string {
}
func NormalizeRedPacketType(packetType string) string {
switch strings.ToUpper(strings.TrimSpace(packetType)) {
case "IMMEDIATE":
return RedPacketTypeNormal
case "DELAYED":
return RedPacketTypeDelayed
}
switch strings.ToLower(strings.TrimSpace(packetType)) {
case RedPacketTypeNormal:
return RedPacketTypeNormal

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