政策相关
This commit is contained in:
parent
a7a9ed08ff
commit
0917fe5009
@ -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" +
|
||||
|
||||
@ -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 返回扣费成功并落到房间后的状态结果。
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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
@ -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 修改。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
272
docs/flutter对接/用户举报Flutter对接.md
Normal file
272
docs/flutter对接/用户举报Flutter对接.md
Normal 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 | 举报单 ID;Flutter 可用于本地日志和客服排查。 |
|
||||
| `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 状态处理。
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
138
server/admin/internal/modules/hostagencypolicy/handler.go
Normal file
138
server/admin/internal/modules/hostagencypolicy/handler.go
Normal 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 ""
|
||||
}
|
||||
25
server/admin/internal/modules/hostagencypolicy/request.go
Normal file
25
server/admin/internal/modules/hostagencypolicy/request.go
Normal 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"`
|
||||
}
|
||||
122
server/admin/internal/modules/hostagencypolicy/response.go
Normal file
122
server/admin/internal/modules/hostagencypolicy/response.go
Normal 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)
|
||||
}
|
||||
19
server/admin/internal/modules/hostagencypolicy/routes.go
Normal file
19
server/admin/internal/modules/hostagencypolicy/routes.go
Normal 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)
|
||||
}
|
||||
614
server/admin/internal/modules/hostagencypolicy/service.go
Normal file
614
server/admin/internal/modules/hostagencypolicy/service.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
46
server/admin/internal/modules/hostsalarysettlement/dto.go
Normal file
46
server/admin/internal/modules/hostsalarysettlement/dto.go
Normal 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
|
||||
}
|
||||
115
server/admin/internal/modules/hostsalarysettlement/handler.go
Normal file
115
server/admin/internal/modules/hostsalarysettlement/handler.go
Normal 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 ""
|
||||
}
|
||||
15
server/admin/internal/modules/hostsalarysettlement/routes.go
Normal file
15
server/admin/internal/modules/hostsalarysettlement/routes.go
Normal 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)
|
||||
}
|
||||
290
server/admin/internal/modules/hostsalarysettlement/service.go
Normal file
290
server/admin/internal/modules/hostsalarysettlement/service.go
Normal 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
|
||||
}
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
83
server/admin/internal/modules/report/handler.go
Normal file
83
server/admin/internal/modules/report/handler.go
Normal 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 ""
|
||||
}
|
||||
11
server/admin/internal/modules/report/request.go
Normal file
11
server/admin/internal/modules/report/request.go
Normal file
@ -0,0 +1,11 @@
|
||||
package report
|
||||
|
||||
type listQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Keyword string
|
||||
TargetType string
|
||||
ReportType string
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
38
server/admin/internal/modules/report/response.go
Normal file
38
server/admin/internal/modules/report/response.go
Normal 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"`
|
||||
}
|
||||
15
server/admin/internal/modules/report/routes.go
Normal file
15
server/admin/internal/modules/report/routes.go
Normal 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)
|
||||
}
|
||||
274
server/admin/internal/modules/report/service.go
Normal file
274
server/admin/internal/modules/report/service.go
Normal 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
|
||||
}
|
||||
38
server/admin/internal/modules/report/service_test.go
Normal file
38
server/admin/internal/modules/report/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
121
server/admin/internal/modules/teamsalarypolicy/handler.go
Normal file
121
server/admin/internal/modules/teamsalarypolicy/handler.go
Normal 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 ""
|
||||
}
|
||||
22
server/admin/internal/modules/teamsalarypolicy/request.go
Normal file
22
server/admin/internal/modules/teamsalarypolicy/request.go
Normal 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"`
|
||||
}
|
||||
74
server/admin/internal/modules/teamsalarypolicy/response.go
Normal file
74
server/admin/internal/modules/teamsalarypolicy/response.go
Normal 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,
|
||||
}
|
||||
}
|
||||
17
server/admin/internal/modules/teamsalarypolicy/routes.go
Normal file
17
server/admin/internal/modules/teamsalarypolicy/routes.go
Normal 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)
|
||||
}
|
||||
406
server/admin/internal/modules/teamsalarypolicy/service.go
Normal file
406
server/admin/internal/modules/teamsalarypolicy/service.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
102
server/admin/internal/modules/teamsalarysettlement/dto.go
Normal file
102
server/admin/internal/modules/teamsalarysettlement/dto.go
Normal 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"`
|
||||
}
|
||||
209
server/admin/internal/modules/teamsalarysettlement/handler.go
Normal file
209
server/admin/internal/modules/teamsalarysettlement/handler.go
Normal 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 ""
|
||||
}
|
||||
18
server/admin/internal/modules/teamsalarysettlement/routes.go
Normal file
18
server/admin/internal/modules/teamsalarysettlement/routes.go
Normal 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)
|
||||
}
|
||||
1457
server/admin/internal/modules/teamsalarysettlement/service.go
Normal file
1457
server/admin/internal/modules/teamsalarysettlement/service.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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, "`", "``") + "`"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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{},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
88
server/admin/migrations/027_host_agency_salary_policy.sql
Normal file
88
server/admin/migrations/027_host_agency_salary_policy.sql
Normal 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';
|
||||
43
server/admin/migrations/028_report_navigation.sql
Normal file
43
server/admin/migrations/028_report_navigation.sql
Normal 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';
|
||||
@ -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';
|
||||
24
server/admin/migrations/030_salary_policy_trigger_mode.sql
Normal file
24
server/admin/migrations/030_salary_policy_trigger_mode.sql
Normal 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';
|
||||
67
server/admin/migrations/031_team_salary_policy.sql
Normal file
67
server/admin/migrations/031_team_salary_policy.sql
Normal 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';
|
||||
12
server/admin/migrations/032_team_salary_settlement.sql
Normal file
12
server/admin/migrations/032_team_salary_settlement.sql
Normal 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';
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
20
services/cron-service/internal/config/config_test.go
Normal file
20
services/cron-service/internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
services/cron-service/internal/integration/wallet.go
Normal file
80
services/cron-service/internal/integration/wallet.go
Normal 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 同时作为请求幂等标识传给 wallet;wallet 内部会继续按主播和周期生成账务幂等键。
|
||||
return &walletv1.CronBatchRequest{
|
||||
RequestId: req.RunID,
|
||||
AppCode: req.AppCode,
|
||||
RunId: req.RunID,
|
||||
WorkerId: req.WorkerID,
|
||||
BatchSize: int32(req.BatchSize),
|
||||
LockTtlMs: req.LockTTL.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
func walletCronBatchResult(resp *walletv1.CronBatchResponse) scheduler.BatchResult {
|
||||
if resp == nil {
|
||||
return scheduler.BatchResult{}
|
||||
}
|
||||
return scheduler.BatchResult{
|
||||
ClaimedCount: int(resp.GetClaimedCount()),
|
||||
ProcessedCount: int(resp.GetProcessedCount()),
|
||||
SuccessCount: int(resp.GetSuccessCount()),
|
||||
FailureCount: int(resp.GetFailureCount()),
|
||||
HasMore: resp.GetHasMore(),
|
||||
}
|
||||
}
|
||||
30
services/cron-service/internal/integration/wallet_test.go
Normal file
30
services/cron-service/internal/integration/wallet_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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{
|
||||
{
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -61,6 +61,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
ListMyFriendApplications: h.listMyFriendApplications,
|
||||
GetMyProfile: h.getMyProfile,
|
||||
UserSocialAction: h.userSocialAction,
|
||||
SubmitReport: h.submitReport,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 '下一个值',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package host
|
||||
|
||||
// SearchAgenciesCommand 描述同区域 Agency 搜索条件。
|
||||
// SearchAgenciesCommand 描述同国家 Agency 搜索条件。
|
||||
type SearchAgenciesCommand struct {
|
||||
UserID int64
|
||||
Keyword string
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
117
services/user-service/internal/service/user/report.go
Normal file
117
services/user-service/internal/service/user/report.go
Normal 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
|
||||
}
|
||||
47
services/user-service/internal/service/user/report_test.go
Normal file
47
services/user-service/internal/service/user/report_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 批量查询用户主状态,缺失用户不应返回占位对象。
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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...))
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 用户 ID,0 表示无代理收款人',
|
||||
total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计钻石',
|
||||
gift_diamond_total BIGINT NOT NULL DEFAULT 0 COMMENT '送礼产生的周期钻石累计',
|
||||
version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
|
||||
first_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本周期首次收礼时间,UTC epoch ms',
|
||||
last_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本周期最后收礼时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, cycle_key),
|
||||
KEY idx_host_period_diamond_region_cycle (app_code, region_id, cycle_key),
|
||||
KEY idx_host_period_diamond_cycle_total (app_code, cycle_key, total_diamonds)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石账户表';
|
||||
|
||||
-- 主播工资周期钻石流水:每笔成功送礼只在 target 为 active host 时写入,用于审计和补偿重建周期账户。
|
||||
CREATE TABLE IF NOT EXISTS host_period_diamond_entries (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '流水 ID',
|
||||
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '业务命令幂等 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
|
||||
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM',
|
||||
region_id BIGINT NOT NULL COMMENT '主播身份所属区域 ID',
|
||||
agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '本次送礼入账使用的代理收款用户快照',
|
||||
diamond_delta BIGINT NOT NULL COMMENT '本次增加钻石',
|
||||
diamond_after BIGINT NOT NULL COMMENT '本次入账后周期累计钻石',
|
||||
gift_charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '礼物扣费资产类型快照',
|
||||
gift_charge_amount BIGINT NOT NULL DEFAULT 0 COMMENT '礼物扣费金额快照',
|
||||
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID',
|
||||
gift_count INT NOT NULL DEFAULT 0 COMMENT '礼物数量',
|
||||
sender_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户 ID',
|
||||
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
UNIQUE KEY uk_host_period_diamond_tx (app_code, transaction_id, user_id, cycle_key),
|
||||
KEY idx_host_period_diamond_entries_user_cycle (app_code, user_id, cycle_key, created_at_ms),
|
||||
KEY idx_host_period_diamond_entries_region_cycle (app_code, region_id, cycle_key, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石流水表';
|
||||
|
||||
-- 运行侧工资政策表:字段与 admin_host_agency_salary_policies 对齐,wallet-service 结算只读取这里的已发布快照。
|
||||
-- 后台配置仍由 admin-server 管理;同步/导入时必须整份写入 policy + levels,结算事务不直接读 admin 库。
|
||||
CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID,来自后台配置',
|
||||
name VARCHAR(120) NOT NULL DEFAULT '' COMMENT '政策名称',
|
||||
region_id BIGINT NOT NULL COMMENT '适用区域 ID',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled',
|
||||
settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式:daily/half_month',
|
||||
settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual',
|
||||
gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例快照',
|
||||
residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例',
|
||||
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms',
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, policy_id),
|
||||
KEY idx_host_agency_salary_policy_active (app_code, region_id, status, settlement_mode, settlement_trigger_mode, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策运行快照表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
|
||||
level_no INT NOT NULL COMMENT '等级',
|
||||
required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石',
|
||||
host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计美元工资,单位美分',
|
||||
host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计金币奖励',
|
||||
agency_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '代理累计美元工资,单位美分',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态:active/disabled',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, policy_id, level_no),
|
||||
KEY idx_host_agency_salary_policy_level_required (app_code, policy_id, status, required_diamonds)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级运行快照表';
|
||||
|
||||
-- 结算进度按主播+月份独立记录累计已发值;日结/半月结只更新累计,月底清算只打 cleared 标记。
|
||||
-- 这里不覆盖 host_period_diamond_accounts.total_diamonds,历史累计钻石必须可审计和可重放。
|
||||
CREATE TABLE IF NOT EXISTS host_salary_settlement_progress (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
|
||||
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM',
|
||||
settled_level_no INT NOT NULL DEFAULT 0 COMMENT '已结算到的最高等级',
|
||||
settled_host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发主播美元工资累计,单位美分',
|
||||
settled_host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '已发主播金币奖励累计',
|
||||
settled_agency_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发代理美元工资累计,单位美分',
|
||||
last_settled_total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '上次结算任务已处理到的周期累计钻石,避免未升档时重复空跑',
|
||||
residual_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '月底剩余钻石已转主播美元金额,单位美分',
|
||||
month_end_cleared_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '月底清算完成时间,0 表示未清算',
|
||||
last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的政策 ID',
|
||||
version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, cycle_key),
|
||||
KEY idx_host_salary_settlement_progress_cycle (app_code, cycle_key, month_end_cleared_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期结算进度表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_salary_settlement_records (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
settlement_id VARCHAR(128) NOT NULL COMMENT '结算记录 ID',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '钱包交易幂等命令 ID',
|
||||
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
|
||||
settlement_type VARCHAR(24) NOT NULL COMMENT '结算类型:daily/half_month/month_end',
|
||||
user_id BIGINT NOT NULL COMMENT '主播用户 ID',
|
||||
agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '代理收款用户 ID',
|
||||
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM',
|
||||
policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的政策 ID',
|
||||
level_no INT NOT NULL DEFAULT 0 COMMENT '本次结算后的等级',
|
||||
total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT '结算时周期累计钻石',
|
||||
host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次主播美元工资增量,单位美分',
|
||||
host_coin_reward_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次主播金币奖励增量',
|
||||
agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次代理美元工资增量,单位美分',
|
||||
residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次月底剩余钻石转美元增量,单位美分',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'succeeded' COMMENT '状态:succeeded/skipped/failed',
|
||||
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过或失败原因',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, settlement_id),
|
||||
UNIQUE KEY uk_host_salary_settlement_command (app_code, command_id),
|
||||
KEY idx_host_salary_settlement_user_cycle (app_code, user_id, cycle_key, created_at_ms),
|
||||
KEY idx_host_salary_settlement_agency_cycle (app_code, agency_owner_user_id, cycle_key, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资结算记录表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_salary_settlement_progress (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
policy_type VARCHAR(24) NOT NULL COMMENT '团队工资类型:bd/admin',
|
||||
user_id BIGINT NOT NULL COMMENT '收款用户 ID:BD 或 Admin',
|
||||
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM',
|
||||
region_id BIGINT NOT NULL COMMENT '结算时使用的区域 ID',
|
||||
settled_level_no INT NOT NULL DEFAULT 0 COMMENT '已结算到的最高等级',
|
||||
settled_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '已发团队工资累计,单位美分',
|
||||
last_settled_income_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '上次已处理收入累计,单位美分,用于避免未升档重复空跑',
|
||||
last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一次使用的政策 ID',
|
||||
month_closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完整月结算关闭时间,0 表示未关闭',
|
||||
version BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, policy_type, user_id, cycle_key),
|
||||
KEY idx_team_salary_progress_cycle (app_code, policy_type, cycle_key, region_id, month_closed_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资周期结算进度表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_salary_settlement_records (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
settlement_id VARCHAR(128) NOT NULL COMMENT '结算记录 ID',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '钱包交易幂等命令 ID',
|
||||
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
|
||||
policy_type VARCHAR(24) NOT NULL COMMENT '团队工资类型:bd/admin',
|
||||
trigger_mode VARCHAR(24) NOT NULL DEFAULT 'manual' COMMENT '触发方式:automatic/manual',
|
||||
user_id BIGINT NOT NULL COMMENT '收款用户 ID:BD 或 Admin',
|
||||
cycle_key VARCHAR(16) NOT NULL COMMENT '工资周期键,UTC yyyy-MM',
|
||||
region_id BIGINT NOT NULL COMMENT '结算时使用的区域 ID',
|
||||
policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '使用的政策 ID',
|
||||
level_no INT NOT NULL DEFAULT 0 COMMENT '本次结算后的等级',
|
||||
income_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '结算时团队收入累计,单位美分',
|
||||
salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次团队美元工资增量,单位美分',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'succeeded' COMMENT '状态:succeeded/skipped/failed',
|
||||
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过或失败原因',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, settlement_id),
|
||||
UNIQUE KEY uk_team_salary_settlement_command (app_code, command_id),
|
||||
KEY idx_team_salary_records_user_cycle (app_code, policy_type, user_id, cycle_key, created_at_ms),
|
||||
KEY idx_team_salary_records_cycle (app_code, policy_type, cycle_key, region_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资结算记录表';
|
||||
|
||||
-- 老环境已建过主播周期账户时,CREATE TABLE IF NOT EXISTS 不会补新字段;结算前必须有代理收款快照。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_period_diamond_accounts' AND COLUMN_NAME = 'agency_owner_user_id') = 0,
|
||||
'ALTER TABLE host_period_diamond_accounts ADD COLUMN agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''送礼入账时主播归属 Agency 的 owner 用户 ID,0 表示无代理收款人'' AFTER region_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_period_diamond_entries' AND COLUMN_NAME = 'agency_owner_user_id') = 0,
|
||||
'ALTER TABLE host_period_diamond_entries ADD COLUMN agency_owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''本次送礼入账使用的代理收款用户快照'' AFTER region_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies' AND COLUMN_NAME = 'settlement_trigger_mode') = 0,
|
||||
'ALTER TABLE host_agency_salary_policies ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT ''automatic'' COMMENT ''结算触发方式:automatic/manual'' AFTER settlement_mode',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_salary_settlement_progress' AND COLUMN_NAME = 'last_settled_total_diamonds') = 0,
|
||||
'ALTER TABLE host_salary_settlement_progress ADD COLUMN last_settled_total_diamonds BIGINT NOT NULL DEFAULT 0 COMMENT ''上次结算任务已处理到的周期累计钻石,避免未升档时重复空跑'' AFTER settled_agency_salary_usd_minor',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 老环境可能已经存在 wallet_outbox,CREATE TABLE IF NOT EXISTS 不会补齐后续新增的投递锁字段。
|
||||
-- 这里用 information_schema 生成幂等 ALTER,保证重复执行 initdb 时不会因重复列名失败。
|
||||
SET @ddl := IF(
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user