diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 067f2d25..4e8bb3c2 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -6510,8 +6510,14 @@ type SendGiftRequest struct { TargetType string `protobuf:"bytes,5,opt,name=target_type,json=targetType,proto3" json:"target_type,omitempty"` TargetUserIds []int64 `protobuf:"varint,6,rep,packed,name=target_user_ids,json=targetUserIds,proto3" json:"target_user_ids,omitempty"` PoolId string `protobuf:"bytes,7,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // target_is_host 由 gateway 根据 user-service active host profile 注入,room-service 只透传给 wallet-service。 + TargetIsHost bool `protobuf:"varint,8,opt,name=target_is_host,json=targetIsHost,proto3" json:"target_is_host,omitempty"` + // target_host_region_id 是主播所属区域,后续结算按该区域匹配 Host & Agency 政策。 + TargetHostRegionId int64 `protobuf:"varint,9,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"` + // target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。 + TargetAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftRequest) Reset() { @@ -6593,6 +6599,27 @@ func (x *SendGiftRequest) GetPoolId() string { return "" } +func (x *SendGiftRequest) GetTargetIsHost() bool { + if x != nil { + return x.TargetIsHost + } + return false +} + +func (x *SendGiftRequest) GetTargetHostRegionId() int64 { + if x != nil { + return x.TargetHostRegionId + } + return 0 +} + +func (x *SendGiftRequest) GetTargetAgencyOwnerUserId() int64 { + if x != nil { + return x.TargetAgencyOwnerUserId + } + return 0 +} + // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 type SendGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9345,7 +9372,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" + "\n" + "rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" + - "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x81\x02\n" + + "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x98\x03\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -9355,7 +9382,11 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\vtarget_type\x18\x05 \x01(\tR\n" + "targetType\x12&\n" + "\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\x12\x17\n" + - "\apool_id\x18\a \x01(\tR\x06poolId\"\xfb\x02\n" + + "\apool_id\x18\a \x01(\tR\x06poolId\x12$\n" + + "\x0etarget_is_host\x18\b \x01(\bR\ftargetIsHost\x121\n" + + "\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" + + "\x1btarget_agency_owner_user_id\x18\n" + + " \x01(\x03R\x17targetAgencyOwnerUserId\"\xfb\x02\n" + "\x10SendGiftResponse\x124\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 99e549cf..fb6360e3 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -776,6 +776,12 @@ message SendGiftRequest { string target_type = 5; repeated int64 target_user_ids = 6; string pool_id = 7; + // target_is_host 由 gateway 根据 user-service active host profile 注入,room-service 只透传给 wallet-service。 + bool target_is_host = 8; + // target_host_region_id 是主播所属区域,后续结算按该区域匹配 Host & Agency 政策。 + int64 target_host_region_id = 9; + // target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。 + int64 target_agency_owner_user_id = 10; } // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 63b93855..675a067b 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -33,8 +33,10 @@ type HostProfile struct { FirstBecameHostAtMs int64 `protobuf:"varint,7,opt,name=first_became_host_at_ms,json=firstBecameHostAtMs,proto3" json:"first_became_host_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // current_agency_owner_user_id 是当前 Agency 的收款用户快照,wallet-service 入账时固化该值,避免月底按新归属错发代理工资。 + CurrentAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=current_agency_owner_user_id,json=currentAgencyOwnerUserId,proto3" json:"current_agency_owner_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HostProfile) Reset() { @@ -130,6 +132,13 @@ func (x *HostProfile) GetUpdatedAtMs() int64 { return 0 } +func (x *HostProfile) GetCurrentAgencyOwnerUserId() int64 { + if x != nil { + return x.CurrentAgencyOwnerUserId + } + return 0 +} + // Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。 type Agency struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -144,6 +153,8 @@ type Agency struct { CreatedByUserId int64 `protobuf:"varint,9,opt,name=created_by_user_id,json=createdByUserId,proto3" json:"created_by_user_id,omitempty"` CreatedAtMs int64 `protobuf:"varint,10,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,11,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + Avatar string `protobuf:"bytes,12,opt,name=avatar,proto3" json:"avatar,omitempty"` + HostCount int32 `protobuf:"varint,13,opt,name=host_count,json=hostCount,proto3" json:"host_count,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -255,6 +266,20 @@ func (x *Agency) GetUpdatedAtMs() int64 { return 0 } +func (x *Agency) GetAvatar() string { + if x != nil { + return x.Avatar + } + return "" +} + +func (x *Agency) GetHostCount() int32 { + if x != nil { + return x.HostCount + } + return 0 +} + // BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。 type BDProfile struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3903,7 +3928,7 @@ var File_proto_user_v1_host_proto protoreflect.FileDescriptor const file_proto_user_v1_host_proto_rawDesc = "" + "\n" + - "\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\xd1\x02\n" + + "\x18proto/user/v1/host.proto\x12\rhyapp.user.v1\x1a\x18proto/user/v1/user.proto\"\x91\x03\n" + "\vHostProfile\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" + @@ -3913,7 +3938,9 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x06source\x18\x06 \x01(\tR\x06source\x124\n" + "\x17first_became_host_at_ms\x18\a \x01(\x03R\x13firstBecameHostAtMs\x12\"\n" + "\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xf2\x02\n" + + "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12>\n" + + "\x1ccurrent_agency_owner_user_id\x18\n" + + " \x01(\x03R\x18currentAgencyOwnerUserId\"\xa9\x03\n" + "\x06Agency\x12\x1b\n" + "\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x1b\n" + @@ -3926,7 +3953,10 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x12created_by_user_id\x18\t \x01(\x03R\x0fcreatedByUserId\x12\"\n" + "\rcreated_at_ms\x18\n" + " \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\"\x95\x02\n" + + "\rupdated_at_ms\x18\v \x01(\x03R\vupdatedAtMs\x12\x16\n" + + "\x06avatar\x18\f \x01(\tR\x06avatar\x12\x1d\n" + + "\n" + + "host_count\x18\r \x01(\x05R\thostCount\"\x95\x02\n" + "\tBDProfile\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" + "\x04role\x18\x02 \x01(\tR\x04role\x12\x1b\n" + diff --git a/api/proto/user/v1/host.proto b/api/proto/user/v1/host.proto index f7686d7b..586ad0df 100644 --- a/api/proto/user/v1/host.proto +++ b/api/proto/user/v1/host.proto @@ -17,6 +17,8 @@ message HostProfile { int64 first_became_host_at_ms = 7; int64 created_at_ms = 8; int64 updated_at_ms = 9; + // current_agency_owner_user_id 是当前 Agency 的收款用户快照,wallet-service 入账时固化该值,避免月底按新归属错发代理工资。 + int64 current_agency_owner_user_id = 10; } // Agency 是主播组织,owner 自动拥有 host 身份和 owner membership。 @@ -32,6 +34,8 @@ message Agency { int64 created_by_user_id = 9; int64 created_at_ms = 10; int64 updated_at_ms = 11; + string avatar = 12; + int32 host_count = 13; } // BDProfile 表达 BD 或 BD Leader 拓展角色。BD 默认不是 host。 diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index 11a333c0..36a0c2d5 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -3279,6 +3279,260 @@ func (x *ListFriendApplicationsResponse) GetTotal() int64 { return 0 } +// UserReport 是 App 用户提交的治理举报单。 +type UserReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + ReportId string `protobuf:"bytes,1,opt,name=report_id,json=reportId,proto3" json:"report_id,omitempty"` + ReporterUserId int64 `protobuf:"varint,2,opt,name=reporter_user_id,json=reporterUserId,proto3" json:"reporter_user_id,omitempty"` + TargetType string `protobuf:"bytes,3,opt,name=target_type,json=targetType,proto3" json:"target_type,omitempty"` + UserId int64 `protobuf:"varint,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + ReportType string `protobuf:"bytes,6,opt,name=report_type,json=reportType,proto3" json:"report_type,omitempty"` + Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"` + ImageUrls []string `protobuf:"bytes,8,rep,name=image_urls,json=imageUrls,proto3" json:"image_urls,omitempty"` + Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` + CreatedAtMs int64 `protobuf:"varint,10,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserReport) Reset() { + *x = UserReport{} + mi := &file_proto_user_v1_user_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserReport) ProtoMessage() {} + +func (x *UserReport) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[45] + 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 UserReport.ProtoReflect.Descriptor instead. +func (*UserReport) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{45} +} + +func (x *UserReport) GetReportId() string { + if x != nil { + return x.ReportId + } + return "" +} + +func (x *UserReport) GetReporterUserId() int64 { + if x != nil { + return x.ReporterUserId + } + return 0 +} + +func (x *UserReport) GetTargetType() string { + if x != nil { + return x.TargetType + } + return "" +} + +func (x *UserReport) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserReport) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *UserReport) GetReportType() string { + if x != nil { + return x.ReportType + } + return "" +} + +func (x *UserReport) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *UserReport) GetImageUrls() []string { + if x != nil { + return x.ImageUrls + } + return nil +} + +func (x *UserReport) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *UserReport) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +// SubmitReportRequest 提交用户或房间举报;user_id 和 room_id 必须二选一。 +type SubmitReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + ReporterUserId int64 `protobuf:"varint,2,opt,name=reporter_user_id,json=reporterUserId,proto3" json:"reporter_user_id,omitempty"` + UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + RoomId string `protobuf:"bytes,4,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + ReportType string `protobuf:"bytes,5,opt,name=report_type,json=reportType,proto3" json:"report_type,omitempty"` + Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"` + ImageUrls []string `protobuf:"bytes,7,rep,name=image_urls,json=imageUrls,proto3" json:"image_urls,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitReportRequest) Reset() { + *x = SubmitReportRequest{} + mi := &file_proto_user_v1_user_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitReportRequest) ProtoMessage() {} + +func (x *SubmitReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[46] + 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 SubmitReportRequest.ProtoReflect.Descriptor instead. +func (*SubmitReportRequest) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{46} +} + +func (x *SubmitReportRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *SubmitReportRequest) GetReporterUserId() int64 { + if x != nil { + return x.ReporterUserId + } + return 0 +} + +func (x *SubmitReportRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *SubmitReportRequest) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *SubmitReportRequest) GetReportType() string { + if x != nil { + return x.ReportType + } + return "" +} + +func (x *SubmitReportRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *SubmitReportRequest) GetImageUrls() []string { + if x != nil { + return x.ImageUrls + } + return nil +} + +type SubmitReportResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Report *UserReport `protobuf:"bytes,1,opt,name=report,proto3" json:"report,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitReportResponse) Reset() { + *x = SubmitReportResponse{} + mi := &file_proto_user_v1_user_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitReportResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitReportResponse) ProtoMessage() {} + +func (x *SubmitReportResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[47] + 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 SubmitReportResponse.ProtoReflect.Descriptor instead. +func (*SubmitReportResponse) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{47} +} + +func (x *SubmitReportResponse) GetReport() *UserReport { + if x != nil { + return x.Report + } + return nil +} + // BatchGetUsersRequest 批量查询用户主状态。 type BatchGetUsersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3290,7 +3544,7 @@ type BatchGetUsersRequest struct { func (x *BatchGetUsersRequest) Reset() { *x = BatchGetUsersRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[45] + mi := &file_proto_user_v1_user_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3302,7 +3556,7 @@ func (x *BatchGetUsersRequest) String() string { func (*BatchGetUsersRequest) ProtoMessage() {} func (x *BatchGetUsersRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[45] + mi := &file_proto_user_v1_user_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3315,7 +3569,7 @@ func (x *BatchGetUsersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchGetUsersRequest.ProtoReflect.Descriptor instead. func (*BatchGetUsersRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{45} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{48} } func (x *BatchGetUsersRequest) GetMeta() *RequestMeta { @@ -3342,7 +3596,7 @@ type BatchGetUsersResponse struct { func (x *BatchGetUsersResponse) Reset() { *x = BatchGetUsersResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[46] + mi := &file_proto_user_v1_user_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3354,7 +3608,7 @@ func (x *BatchGetUsersResponse) String() string { func (*BatchGetUsersResponse) ProtoMessage() {} func (x *BatchGetUsersResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[46] + mi := &file_proto_user_v1_user_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3367,7 +3621,7 @@ func (x *BatchGetUsersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchGetUsersResponse.ProtoReflect.Descriptor instead. func (*BatchGetUsersResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{46} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{49} } func (x *BatchGetUsersResponse) GetUsers() map[int64]*User { @@ -3392,7 +3646,7 @@ type ListUserIDsRequest struct { func (x *ListUserIDsRequest) Reset() { *x = ListUserIDsRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[47] + mi := &file_proto_user_v1_user_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3404,7 +3658,7 @@ func (x *ListUserIDsRequest) String() string { func (*ListUserIDsRequest) ProtoMessage() {} func (x *ListUserIDsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[47] + mi := &file_proto_user_v1_user_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3417,7 +3671,7 @@ func (x *ListUserIDsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUserIDsRequest.ProtoReflect.Descriptor instead. func (*ListUserIDsRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{47} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{50} } func (x *ListUserIDsRequest) GetMeta() *RequestMeta { @@ -3474,7 +3728,7 @@ type ListUserIDsResponse struct { func (x *ListUserIDsResponse) Reset() { *x = ListUserIDsResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[48] + mi := &file_proto_user_v1_user_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3486,7 +3740,7 @@ func (x *ListUserIDsResponse) String() string { func (*ListUserIDsResponse) ProtoMessage() {} func (x *ListUserIDsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[48] + mi := &file_proto_user_v1_user_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3499,7 +3753,7 @@ func (x *ListUserIDsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUserIDsResponse.ProtoReflect.Descriptor instead. func (*ListUserIDsResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{48} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{51} } func (x *ListUserIDsResponse) GetUserIds() []int64 { @@ -3538,7 +3792,7 @@ type UpdateUserProfileRequest struct { func (x *UpdateUserProfileRequest) Reset() { *x = UpdateUserProfileRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[49] + mi := &file_proto_user_v1_user_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3550,7 +3804,7 @@ func (x *UpdateUserProfileRequest) String() string { func (*UpdateUserProfileRequest) ProtoMessage() {} func (x *UpdateUserProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[49] + mi := &file_proto_user_v1_user_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3563,7 +3817,7 @@ func (x *UpdateUserProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateUserProfileRequest.ProtoReflect.Descriptor instead. func (*UpdateUserProfileRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{49} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{52} } func (x *UpdateUserProfileRequest) GetMeta() *RequestMeta { @@ -3618,7 +3872,7 @@ type UpdateUserProfileResponse struct { func (x *UpdateUserProfileResponse) Reset() { *x = UpdateUserProfileResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[50] + mi := &file_proto_user_v1_user_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3630,7 +3884,7 @@ func (x *UpdateUserProfileResponse) String() string { func (*UpdateUserProfileResponse) ProtoMessage() {} func (x *UpdateUserProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[50] + mi := &file_proto_user_v1_user_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3643,7 +3897,7 @@ func (x *UpdateUserProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateUserProfileResponse.ProtoReflect.Descriptor instead. func (*UpdateUserProfileResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{50} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{53} } func (x *UpdateUserProfileResponse) GetUser() *User { @@ -3665,7 +3919,7 @@ type ChangeUserCountryRequest struct { func (x *ChangeUserCountryRequest) Reset() { *x = ChangeUserCountryRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[51] + mi := &file_proto_user_v1_user_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3677,7 +3931,7 @@ func (x *ChangeUserCountryRequest) String() string { func (*ChangeUserCountryRequest) ProtoMessage() {} func (x *ChangeUserCountryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[51] + mi := &file_proto_user_v1_user_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3690,7 +3944,7 @@ func (x *ChangeUserCountryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeUserCountryRequest.ProtoReflect.Descriptor instead. func (*ChangeUserCountryRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{51} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{54} } func (x *ChangeUserCountryRequest) GetMeta() *RequestMeta { @@ -3728,7 +3982,7 @@ type ChangeUserCountryResponse struct { func (x *ChangeUserCountryResponse) Reset() { *x = ChangeUserCountryResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[52] + mi := &file_proto_user_v1_user_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3740,7 +3994,7 @@ func (x *ChangeUserCountryResponse) String() string { func (*ChangeUserCountryResponse) ProtoMessage() {} func (x *ChangeUserCountryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[52] + mi := &file_proto_user_v1_user_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3753,7 +4007,7 @@ func (x *ChangeUserCountryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeUserCountryResponse.ProtoReflect.Descriptor instead. func (*ChangeUserCountryResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{52} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{55} } func (x *ChangeUserCountryResponse) GetUser() *User { @@ -3808,7 +4062,7 @@ type SetUserStatusRequest struct { func (x *SetUserStatusRequest) Reset() { *x = SetUserStatusRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[53] + mi := &file_proto_user_v1_user_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3820,7 +4074,7 @@ func (x *SetUserStatusRequest) String() string { func (*SetUserStatusRequest) ProtoMessage() {} func (x *SetUserStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[53] + mi := &file_proto_user_v1_user_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3833,7 +4087,7 @@ func (x *SetUserStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetUserStatusRequest.ProtoReflect.Descriptor instead. func (*SetUserStatusRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{53} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{56} } func (x *SetUserStatusRequest) GetMeta() *RequestMeta { @@ -3899,7 +4153,7 @@ type SetUserStatusResponse struct { func (x *SetUserStatusResponse) Reset() { *x = SetUserStatusResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[54] + mi := &file_proto_user_v1_user_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3911,7 +4165,7 @@ func (x *SetUserStatusResponse) String() string { func (*SetUserStatusResponse) ProtoMessage() {} func (x *SetUserStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[54] + mi := &file_proto_user_v1_user_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3924,7 +4178,7 @@ func (x *SetUserStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetUserStatusResponse.ProtoReflect.Descriptor instead. func (*SetUserStatusResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{54} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{57} } func (x *SetUserStatusResponse) GetUser() *User { @@ -4020,7 +4274,7 @@ type CompleteOnboardingRequest struct { func (x *CompleteOnboardingRequest) Reset() { *x = CompleteOnboardingRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[55] + mi := &file_proto_user_v1_user_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4032,7 +4286,7 @@ func (x *CompleteOnboardingRequest) String() string { func (*CompleteOnboardingRequest) ProtoMessage() {} func (x *CompleteOnboardingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[55] + mi := &file_proto_user_v1_user_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4045,7 +4299,7 @@ func (x *CompleteOnboardingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteOnboardingRequest.ProtoReflect.Descriptor instead. func (*CompleteOnboardingRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{55} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{58} } func (x *CompleteOnboardingRequest) GetMeta() *RequestMeta { @@ -4112,7 +4366,7 @@ type CompleteOnboardingResponse struct { func (x *CompleteOnboardingResponse) Reset() { *x = CompleteOnboardingResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[56] + mi := &file_proto_user_v1_user_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4124,7 +4378,7 @@ func (x *CompleteOnboardingResponse) String() string { func (*CompleteOnboardingResponse) ProtoMessage() {} func (x *CompleteOnboardingResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[56] + mi := &file_proto_user_v1_user_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4137,7 +4391,7 @@ func (x *CompleteOnboardingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteOnboardingResponse.ProtoReflect.Descriptor instead. func (*CompleteOnboardingResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{56} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{59} } func (x *CompleteOnboardingResponse) GetUser() *User { @@ -4201,7 +4455,7 @@ type BindPushTokenRequest struct { func (x *BindPushTokenRequest) Reset() { *x = BindPushTokenRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[57] + mi := &file_proto_user_v1_user_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4213,7 +4467,7 @@ func (x *BindPushTokenRequest) String() string { func (*BindPushTokenRequest) ProtoMessage() {} func (x *BindPushTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[57] + mi := &file_proto_user_v1_user_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4226,7 +4480,7 @@ func (x *BindPushTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BindPushTokenRequest.ProtoReflect.Descriptor instead. func (*BindPushTokenRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{57} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{60} } func (x *BindPushTokenRequest) GetMeta() *RequestMeta { @@ -4302,7 +4556,7 @@ type BindPushTokenResponse struct { func (x *BindPushTokenResponse) Reset() { *x = BindPushTokenResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[58] + mi := &file_proto_user_v1_user_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4314,7 +4568,7 @@ func (x *BindPushTokenResponse) String() string { func (*BindPushTokenResponse) ProtoMessage() {} func (x *BindPushTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[58] + mi := &file_proto_user_v1_user_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4327,7 +4581,7 @@ func (x *BindPushTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BindPushTokenResponse.ProtoReflect.Descriptor instead. func (*BindPushTokenResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{58} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{61} } func (x *BindPushTokenResponse) GetBound() bool { @@ -4358,7 +4612,7 @@ type DeletePushTokenRequest struct { func (x *DeletePushTokenRequest) Reset() { *x = DeletePushTokenRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[59] + mi := &file_proto_user_v1_user_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4370,7 +4624,7 @@ func (x *DeletePushTokenRequest) String() string { func (*DeletePushTokenRequest) ProtoMessage() {} func (x *DeletePushTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[59] + mi := &file_proto_user_v1_user_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4383,7 +4637,7 @@ func (x *DeletePushTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePushTokenRequest.ProtoReflect.Descriptor instead. func (*DeletePushTokenRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{59} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{62} } func (x *DeletePushTokenRequest) GetMeta() *RequestMeta { @@ -4424,7 +4678,7 @@ type DeletePushTokenResponse struct { func (x *DeletePushTokenResponse) Reset() { *x = DeletePushTokenResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[60] + mi := &file_proto_user_v1_user_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4436,7 +4690,7 @@ func (x *DeletePushTokenResponse) String() string { func (*DeletePushTokenResponse) ProtoMessage() {} func (x *DeletePushTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[60] + mi := &file_proto_user_v1_user_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4449,7 +4703,7 @@ func (x *DeletePushTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePushTokenResponse.ProtoReflect.Descriptor instead. func (*DeletePushTokenResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{60} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{63} } func (x *DeletePushTokenResponse) GetDeleted() bool { @@ -4488,7 +4742,7 @@ type Country struct { func (x *Country) Reset() { *x = Country{} - mi := &file_proto_user_v1_user_proto_msgTypes[61] + mi := &file_proto_user_v1_user_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4500,7 +4754,7 @@ func (x *Country) String() string { func (*Country) ProtoMessage() {} func (x *Country) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[61] + mi := &file_proto_user_v1_user_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4513,7 +4767,7 @@ func (x *Country) ProtoReflect() protoreflect.Message { // Deprecated: Use Country.ProtoReflect.Descriptor instead. func (*Country) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{61} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{64} } func (x *Country) GetCountryId() int64 { @@ -4625,7 +4879,7 @@ type Region struct { func (x *Region) Reset() { *x = Region{} - mi := &file_proto_user_v1_user_proto_msgTypes[62] + mi := &file_proto_user_v1_user_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4637,7 +4891,7 @@ func (x *Region) String() string { func (*Region) ProtoMessage() {} func (x *Region) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[62] + mi := &file_proto_user_v1_user_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4650,7 +4904,7 @@ func (x *Region) ProtoReflect() protoreflect.Message { // Deprecated: Use Region.ProtoReflect.Descriptor instead. func (*Region) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{62} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{65} } func (x *Region) GetRegionId() int64 { @@ -4726,7 +4980,7 @@ type ListCountriesRequest struct { func (x *ListCountriesRequest) Reset() { *x = ListCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[63] + mi := &file_proto_user_v1_user_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4738,7 +4992,7 @@ func (x *ListCountriesRequest) String() string { func (*ListCountriesRequest) ProtoMessage() {} func (x *ListCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[63] + mi := &file_proto_user_v1_user_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4751,7 +5005,7 @@ func (x *ListCountriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListCountriesRequest.ProtoReflect.Descriptor instead. func (*ListCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{63} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{66} } func (x *ListCountriesRequest) GetMeta() *RequestMeta { @@ -4777,7 +5031,7 @@ type ListCountriesResponse struct { func (x *ListCountriesResponse) Reset() { *x = ListCountriesResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[64] + mi := &file_proto_user_v1_user_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4789,7 +5043,7 @@ func (x *ListCountriesResponse) String() string { func (*ListCountriesResponse) ProtoMessage() {} func (x *ListCountriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[64] + mi := &file_proto_user_v1_user_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4802,7 +5056,7 @@ func (x *ListCountriesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListCountriesResponse.ProtoReflect.Descriptor instead. func (*ListCountriesResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{64} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{67} } func (x *ListCountriesResponse) GetCountries() []*Country { @@ -4831,7 +5085,7 @@ type UpdateCountryRequest struct { func (x *UpdateCountryRequest) Reset() { *x = UpdateCountryRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[65] + mi := &file_proto_user_v1_user_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4843,7 +5097,7 @@ func (x *UpdateCountryRequest) String() string { func (*UpdateCountryRequest) ProtoMessage() {} func (x *UpdateCountryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[65] + mi := &file_proto_user_v1_user_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4856,7 +5110,7 @@ func (x *UpdateCountryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateCountryRequest.ProtoReflect.Descriptor instead. func (*UpdateCountryRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{65} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{68} } func (x *UpdateCountryRequest) GetMeta() *RequestMeta { @@ -4938,7 +5192,7 @@ type CountryResponse struct { func (x *CountryResponse) Reset() { *x = CountryResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[66] + mi := &file_proto_user_v1_user_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4950,7 +5204,7 @@ func (x *CountryResponse) String() string { func (*CountryResponse) ProtoMessage() {} func (x *CountryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[66] + mi := &file_proto_user_v1_user_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4963,7 +5217,7 @@ func (x *CountryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CountryResponse.ProtoReflect.Descriptor instead. func (*CountryResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{66} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{69} } func (x *CountryResponse) GetCountry() *Country { @@ -4982,7 +5236,7 @@ type ListRegistrationCountriesRequest struct { func (x *ListRegistrationCountriesRequest) Reset() { *x = ListRegistrationCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[67] + mi := &file_proto_user_v1_user_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4994,7 +5248,7 @@ func (x *ListRegistrationCountriesRequest) String() string { func (*ListRegistrationCountriesRequest) ProtoMessage() {} func (x *ListRegistrationCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[67] + mi := &file_proto_user_v1_user_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5007,7 +5261,7 @@ func (x *ListRegistrationCountriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRegistrationCountriesRequest.ProtoReflect.Descriptor instead. func (*ListRegistrationCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{67} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{70} } func (x *ListRegistrationCountriesRequest) GetMeta() *RequestMeta { @@ -5026,7 +5280,7 @@ type ListRegistrationCountriesResponse struct { func (x *ListRegistrationCountriesResponse) Reset() { *x = ListRegistrationCountriesResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[68] + mi := &file_proto_user_v1_user_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5038,7 +5292,7 @@ func (x *ListRegistrationCountriesResponse) String() string { func (*ListRegistrationCountriesResponse) ProtoMessage() {} func (x *ListRegistrationCountriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[68] + mi := &file_proto_user_v1_user_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5051,7 +5305,7 @@ func (x *ListRegistrationCountriesResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ListRegistrationCountriesResponse.ProtoReflect.Descriptor instead. func (*ListRegistrationCountriesResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{68} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{71} } func (x *ListRegistrationCountriesResponse) GetCountries() []*Country { @@ -5072,7 +5326,7 @@ type LoginRiskBlockedCountry struct { func (x *LoginRiskBlockedCountry) Reset() { *x = LoginRiskBlockedCountry{} - mi := &file_proto_user_v1_user_proto_msgTypes[69] + mi := &file_proto_user_v1_user_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5084,7 +5338,7 @@ func (x *LoginRiskBlockedCountry) String() string { func (*LoginRiskBlockedCountry) ProtoMessage() {} func (x *LoginRiskBlockedCountry) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[69] + mi := &file_proto_user_v1_user_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5097,7 +5351,7 @@ func (x *LoginRiskBlockedCountry) ProtoReflect() protoreflect.Message { // Deprecated: Use LoginRiskBlockedCountry.ProtoReflect.Descriptor instead. func (*LoginRiskBlockedCountry) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{69} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{72} } func (x *LoginRiskBlockedCountry) GetCountryCode() string { @@ -5130,7 +5384,7 @@ type ListLoginRiskBlockedCountriesRequest struct { func (x *ListLoginRiskBlockedCountriesRequest) Reset() { *x = ListLoginRiskBlockedCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[70] + mi := &file_proto_user_v1_user_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5142,7 +5396,7 @@ func (x *ListLoginRiskBlockedCountriesRequest) String() string { func (*ListLoginRiskBlockedCountriesRequest) ProtoMessage() {} func (x *ListLoginRiskBlockedCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[70] + mi := &file_proto_user_v1_user_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5155,7 +5409,7 @@ func (x *ListLoginRiskBlockedCountriesRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use ListLoginRiskBlockedCountriesRequest.ProtoReflect.Descriptor instead. func (*ListLoginRiskBlockedCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{70} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{73} } func (x *ListLoginRiskBlockedCountriesRequest) GetMeta() *RequestMeta { @@ -5175,7 +5429,7 @@ type ListLoginRiskBlockedCountriesResponse struct { func (x *ListLoginRiskBlockedCountriesResponse) Reset() { *x = ListLoginRiskBlockedCountriesResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[71] + mi := &file_proto_user_v1_user_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5187,7 +5441,7 @@ func (x *ListLoginRiskBlockedCountriesResponse) String() string { func (*ListLoginRiskBlockedCountriesResponse) ProtoMessage() {} func (x *ListLoginRiskBlockedCountriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[71] + mi := &file_proto_user_v1_user_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5200,7 +5454,7 @@ func (x *ListLoginRiskBlockedCountriesResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use ListLoginRiskBlockedCountriesResponse.ProtoReflect.Descriptor instead. func (*ListLoginRiskBlockedCountriesResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{71} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{74} } func (x *ListLoginRiskBlockedCountriesResponse) GetCountries() []*LoginRiskBlockedCountry { @@ -5227,7 +5481,7 @@ type ListRegionsRequest struct { func (x *ListRegionsRequest) Reset() { *x = ListRegionsRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[72] + mi := &file_proto_user_v1_user_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5239,7 +5493,7 @@ func (x *ListRegionsRequest) String() string { func (*ListRegionsRequest) ProtoMessage() {} func (x *ListRegionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[72] + mi := &file_proto_user_v1_user_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5252,7 +5506,7 @@ func (x *ListRegionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRegionsRequest.ProtoReflect.Descriptor instead. func (*ListRegionsRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{72} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{75} } func (x *ListRegionsRequest) GetMeta() *RequestMeta { @@ -5278,7 +5532,7 @@ type ListRegionsResponse struct { func (x *ListRegionsResponse) Reset() { *x = ListRegionsResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[73] + mi := &file_proto_user_v1_user_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5290,7 +5544,7 @@ func (x *ListRegionsResponse) String() string { func (*ListRegionsResponse) ProtoMessage() {} func (x *ListRegionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[73] + mi := &file_proto_user_v1_user_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5303,7 +5557,7 @@ func (x *ListRegionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRegionsResponse.ProtoReflect.Descriptor instead. func (*ListRegionsResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{73} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{76} } func (x *ListRegionsResponse) GetRegions() []*Region { @@ -5323,7 +5577,7 @@ type GetRegionRequest struct { func (x *GetRegionRequest) Reset() { *x = GetRegionRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[74] + mi := &file_proto_user_v1_user_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5335,7 +5589,7 @@ func (x *GetRegionRequest) String() string { func (*GetRegionRequest) ProtoMessage() {} func (x *GetRegionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[74] + mi := &file_proto_user_v1_user_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5348,7 +5602,7 @@ func (x *GetRegionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetRegionRequest.ProtoReflect.Descriptor instead. func (*GetRegionRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{74} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{77} } func (x *GetRegionRequest) GetMeta() *RequestMeta { @@ -5380,7 +5634,7 @@ type UpdateRegionRequest struct { func (x *UpdateRegionRequest) Reset() { *x = UpdateRegionRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[75] + mi := &file_proto_user_v1_user_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5392,7 +5646,7 @@ func (x *UpdateRegionRequest) String() string { func (*UpdateRegionRequest) ProtoMessage() {} func (x *UpdateRegionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[75] + mi := &file_proto_user_v1_user_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5405,7 +5659,7 @@ func (x *UpdateRegionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateRegionRequest.ProtoReflect.Descriptor instead. func (*UpdateRegionRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{75} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{78} } func (x *UpdateRegionRequest) GetMeta() *RequestMeta { @@ -5462,7 +5716,7 @@ type ReplaceRegionCountriesRequest struct { func (x *ReplaceRegionCountriesRequest) Reset() { *x = ReplaceRegionCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[76] + mi := &file_proto_user_v1_user_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5474,7 +5728,7 @@ func (x *ReplaceRegionCountriesRequest) String() string { func (*ReplaceRegionCountriesRequest) ProtoMessage() {} func (x *ReplaceRegionCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[76] + mi := &file_proto_user_v1_user_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5487,7 +5741,7 @@ func (x *ReplaceRegionCountriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplaceRegionCountriesRequest.ProtoReflect.Descriptor instead. func (*ReplaceRegionCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{76} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{79} } func (x *ReplaceRegionCountriesRequest) GetMeta() *RequestMeta { @@ -5527,7 +5781,7 @@ type RegionResponse struct { func (x *RegionResponse) Reset() { *x = RegionResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[77] + mi := &file_proto_user_v1_user_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5539,7 +5793,7 @@ func (x *RegionResponse) String() string { func (*RegionResponse) ProtoMessage() {} func (x *RegionResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[77] + mi := &file_proto_user_v1_user_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5552,7 +5806,7 @@ func (x *RegionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RegionResponse.ProtoReflect.Descriptor instead. func (*RegionResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{77} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{80} } func (x *RegionResponse) GetRegion() *Region { @@ -5578,7 +5832,7 @@ type UserIdentity struct { func (x *UserIdentity) Reset() { *x = UserIdentity{} - mi := &file_proto_user_v1_user_proto_msgTypes[78] + mi := &file_proto_user_v1_user_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5590,7 +5844,7 @@ func (x *UserIdentity) String() string { func (*UserIdentity) ProtoMessage() {} func (x *UserIdentity) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[78] + mi := &file_proto_user_v1_user_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5603,7 +5857,7 @@ func (x *UserIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use UserIdentity.ProtoReflect.Descriptor instead. func (*UserIdentity) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{78} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{81} } func (x *UserIdentity) GetUserId() int64 { @@ -5666,7 +5920,7 @@ type GetUserIdentityRequest struct { func (x *GetUserIdentityRequest) Reset() { *x = GetUserIdentityRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[79] + mi := &file_proto_user_v1_user_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5678,7 +5932,7 @@ func (x *GetUserIdentityRequest) String() string { func (*GetUserIdentityRequest) ProtoMessage() {} func (x *GetUserIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[79] + mi := &file_proto_user_v1_user_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5691,7 +5945,7 @@ func (x *GetUserIdentityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetUserIdentityRequest.ProtoReflect.Descriptor instead. func (*GetUserIdentityRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{79} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{82} } func (x *GetUserIdentityRequest) GetMeta() *RequestMeta { @@ -5718,7 +5972,7 @@ type GetUserIdentityResponse struct { func (x *GetUserIdentityResponse) Reset() { *x = GetUserIdentityResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[80] + mi := &file_proto_user_v1_user_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5730,7 +5984,7 @@ func (x *GetUserIdentityResponse) String() string { func (*GetUserIdentityResponse) ProtoMessage() {} func (x *GetUserIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[80] + mi := &file_proto_user_v1_user_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5743,7 +5997,7 @@ func (x *GetUserIdentityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetUserIdentityResponse.ProtoReflect.Descriptor instead. func (*GetUserIdentityResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{80} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{83} } func (x *GetUserIdentityResponse) GetIdentity() *UserIdentity { @@ -5764,7 +6018,7 @@ type ResolveDisplayUserIDRequest struct { func (x *ResolveDisplayUserIDRequest) Reset() { *x = ResolveDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[81] + mi := &file_proto_user_v1_user_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5776,7 +6030,7 @@ func (x *ResolveDisplayUserIDRequest) String() string { func (*ResolveDisplayUserIDRequest) ProtoMessage() {} func (x *ResolveDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[81] + mi := &file_proto_user_v1_user_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5789,7 +6043,7 @@ func (x *ResolveDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ResolveDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{81} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{84} } func (x *ResolveDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -5816,7 +6070,7 @@ type ResolveDisplayUserIDResponse struct { func (x *ResolveDisplayUserIDResponse) Reset() { *x = ResolveDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[82] + mi := &file_proto_user_v1_user_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5828,7 +6082,7 @@ func (x *ResolveDisplayUserIDResponse) String() string { func (*ResolveDisplayUserIDResponse) ProtoMessage() {} func (x *ResolveDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[82] + mi := &file_proto_user_v1_user_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5841,7 +6095,7 @@ func (x *ResolveDisplayUserIDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ResolveDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{82} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{85} } func (x *ResolveDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -5865,7 +6119,7 @@ type ChangeDisplayUserIDRequest struct { func (x *ChangeDisplayUserIDRequest) Reset() { *x = ChangeDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[83] + mi := &file_proto_user_v1_user_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5877,7 +6131,7 @@ func (x *ChangeDisplayUserIDRequest) String() string { func (*ChangeDisplayUserIDRequest) ProtoMessage() {} func (x *ChangeDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[83] + mi := &file_proto_user_v1_user_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5890,7 +6144,7 @@ func (x *ChangeDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ChangeDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{83} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{86} } func (x *ChangeDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -5938,7 +6192,7 @@ type ChangeDisplayUserIDResponse struct { func (x *ChangeDisplayUserIDResponse) Reset() { *x = ChangeDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[84] + mi := &file_proto_user_v1_user_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5950,7 +6204,7 @@ func (x *ChangeDisplayUserIDResponse) String() string { func (*ChangeDisplayUserIDResponse) ProtoMessage() {} func (x *ChangeDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[84] + mi := &file_proto_user_v1_user_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5963,7 +6217,7 @@ func (x *ChangeDisplayUserIDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ChangeDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{84} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{87} } func (x *ChangeDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -5987,7 +6241,7 @@ type ApplyPrettyDisplayUserIDRequest struct { func (x *ApplyPrettyDisplayUserIDRequest) Reset() { *x = ApplyPrettyDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[85] + mi := &file_proto_user_v1_user_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5999,7 +6253,7 @@ func (x *ApplyPrettyDisplayUserIDRequest) String() string { func (*ApplyPrettyDisplayUserIDRequest) ProtoMessage() {} func (x *ApplyPrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[85] + mi := &file_proto_user_v1_user_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6012,7 +6266,7 @@ func (x *ApplyPrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyPrettyDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ApplyPrettyDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{85} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{88} } func (x *ApplyPrettyDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -6061,7 +6315,7 @@ type ApplyPrettyDisplayUserIDResponse struct { func (x *ApplyPrettyDisplayUserIDResponse) Reset() { *x = ApplyPrettyDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[86] + mi := &file_proto_user_v1_user_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6073,7 +6327,7 @@ func (x *ApplyPrettyDisplayUserIDResponse) String() string { func (*ApplyPrettyDisplayUserIDResponse) ProtoMessage() {} func (x *ApplyPrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[86] + mi := &file_proto_user_v1_user_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6086,7 +6340,7 @@ func (x *ApplyPrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyPrettyDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ApplyPrettyDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{86} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{89} } func (x *ApplyPrettyDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -6115,7 +6369,7 @@ type ExpirePrettyDisplayUserIDRequest struct { func (x *ExpirePrettyDisplayUserIDRequest) Reset() { *x = ExpirePrettyDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[87] + mi := &file_proto_user_v1_user_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6127,7 +6381,7 @@ func (x *ExpirePrettyDisplayUserIDRequest) String() string { func (*ExpirePrettyDisplayUserIDRequest) ProtoMessage() {} func (x *ExpirePrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[87] + mi := &file_proto_user_v1_user_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6140,7 +6394,7 @@ func (x *ExpirePrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExpirePrettyDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ExpirePrettyDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{87} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{90} } func (x *ExpirePrettyDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -6174,7 +6428,7 @@ type ExpirePrettyDisplayUserIDResponse struct { func (x *ExpirePrettyDisplayUserIDResponse) Reset() { *x = ExpirePrettyDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[88] + mi := &file_proto_user_v1_user_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6186,7 +6440,7 @@ func (x *ExpirePrettyDisplayUserIDResponse) String() string { func (*ExpirePrettyDisplayUserIDResponse) ProtoMessage() {} func (x *ExpirePrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[88] + mi := &file_proto_user_v1_user_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6199,7 +6453,7 @@ func (x *ExpirePrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ExpirePrettyDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ExpirePrettyDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{88} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{91} } func (x *ExpirePrettyDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -6472,7 +6726,35 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\tpage_size\x18\x05 \x01(\x05R\bpageSize\"|\n" + "\x1eListFriendApplicationsResponse\x12D\n" + "\fapplications\x18\x01 \x03(\v2 .hyapp.user.v1.FriendApplicationR\fapplications\x12\x14\n" + - "\x05total\x18\x02 \x01(\x03R\x05total\"a\n" + + "\x05total\x18\x02 \x01(\x03R\x05total\"\xba\x02\n" + + "\n" + + "UserReport\x12\x1b\n" + + "\treport_id\x18\x01 \x01(\tR\breportId\x12(\n" + + "\x10reporter_user_id\x18\x02 \x01(\x03R\x0ereporterUserId\x12\x1f\n" + + "\vtarget_type\x18\x03 \x01(\tR\n" + + "targetType\x12\x17\n" + + "\auser_id\x18\x04 \x01(\x03R\x06userId\x12\x17\n" + + "\aroom_id\x18\x05 \x01(\tR\x06roomId\x12\x1f\n" + + "\vreport_type\x18\x06 \x01(\tR\n" + + "reportType\x12\x16\n" + + "\x06reason\x18\a \x01(\tR\x06reason\x12\x1d\n" + + "\n" + + "image_urls\x18\b \x03(\tR\timageUrls\x12\x16\n" + + "\x06status\x18\t \x01(\tR\x06status\x12\"\n" + + "\rcreated_at_ms\x18\n" + + " \x01(\x03R\vcreatedAtMs\"\xf9\x01\n" + + "\x13SubmitReportRequest\x12.\n" + + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12(\n" + + "\x10reporter_user_id\x18\x02 \x01(\x03R\x0ereporterUserId\x12\x17\n" + + "\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x17\n" + + "\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1f\n" + + "\vreport_type\x18\x05 \x01(\tR\n" + + "reportType\x12\x16\n" + + "\x06reason\x18\x06 \x01(\tR\x06reason\x12\x1d\n" + + "\n" + + "image_urls\x18\a \x03(\tR\timageUrls\"I\n" + + "\x14SubmitReportResponse\x121\n" + + "\x06report\x18\x01 \x01(\v2\x19.hyapp.user.v1.UserReportR\x06report\"a\n" + "\x14BatchGetUsersRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x19\n" + "\buser_ids\x18\x02 \x03(\x03R\auserIds\"\xad\x01\n" + @@ -6727,7 +7009,7 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\x11UpdateUserProfile\x12'.hyapp.user.v1.UpdateUserProfileRequest\x1a(.hyapp.user.v1.UpdateUserProfileResponse\x12f\n" + "\x11ChangeUserCountry\x12'.hyapp.user.v1.ChangeUserCountryRequest\x1a(.hyapp.user.v1.ChangeUserCountryResponse\x12Z\n" + "\rSetUserStatus\x12#.hyapp.user.v1.SetUserStatusRequest\x1a$.hyapp.user.v1.SetUserStatusResponse\x12i\n" + - "\x12CompleteOnboarding\x12(.hyapp.user.v1.CompleteOnboardingRequest\x1a).hyapp.user.v1.CompleteOnboardingResponse2\xea\a\n" + + "\x12CompleteOnboarding\x12(.hyapp.user.v1.CompleteOnboardingRequest\x1a).hyapp.user.v1.CompleteOnboardingResponse2\xc3\b\n" + "\x11UserSocialService\x12i\n" + "\x12RecordProfileVisit\x12(.hyapp.user.v1.RecordProfileVisitRequest\x1a).hyapp.user.v1.RecordProfileVisitResponse\x12l\n" + "\x13ListProfileVisitors\x12).hyapp.user.v1.ListProfileVisitorsRequest\x1a*.hyapp.user.v1.ListProfileVisitorsResponse\x12Q\n" + @@ -6739,7 +7021,8 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\x17AcceptFriendApplication\x12-.hyapp.user.v1.AcceptFriendApplicationRequest\x1a..hyapp.user.v1.AcceptFriendApplicationResponse\x12W\n" + "\fDeleteFriend\x12\".hyapp.user.v1.DeleteFriendRequest\x1a#.hyapp.user.v1.DeleteFriendResponse\x12T\n" + "\vListFriends\x12!.hyapp.user.v1.ListFriendsRequest\x1a\".hyapp.user.v1.ListFriendsResponse\x12u\n" + - "\x16ListFriendApplications\x12,.hyapp.user.v1.ListFriendApplicationsRequest\x1a-.hyapp.user.v1.ListFriendApplicationsResponse2\xaf\x02\n" + + "\x16ListFriendApplications\x12,.hyapp.user.v1.ListFriendApplicationsRequest\x1a-.hyapp.user.v1.ListFriendApplicationsResponse\x12W\n" + + "\fSubmitReport\x12\".hyapp.user.v1.SubmitReportRequest\x1a#.hyapp.user.v1.SubmitReportResponse2\xaf\x02\n" + "\x0fUserCronService\x12\\\n" + "\x17ProcessLoginIPRiskBatch\x12\x1f.hyapp.user.v1.CronBatchRequest\x1a .hyapp.user.v1.CronBatchResponse\x12^\n" + "\x19ProcessRegionRebuildBatch\x12\x1f.hyapp.user.v1.CronBatchRequest\x1a .hyapp.user.v1.CronBatchResponse\x12^\n" + @@ -6781,7 +7064,7 @@ func file_proto_user_v1_user_proto_rawDescGZIP() []byte { } var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 90) +var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 93) var file_proto_user_v1_user_proto_goTypes = []any{ (UserStatus)(0), // 0: hyapp.user.v1.UserStatus (*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta @@ -6829,51 +7112,54 @@ var file_proto_user_v1_user_proto_goTypes = []any{ (*FriendApplication)(nil), // 43: hyapp.user.v1.FriendApplication (*ListFriendApplicationsRequest)(nil), // 44: hyapp.user.v1.ListFriendApplicationsRequest (*ListFriendApplicationsResponse)(nil), // 45: hyapp.user.v1.ListFriendApplicationsResponse - (*BatchGetUsersRequest)(nil), // 46: hyapp.user.v1.BatchGetUsersRequest - (*BatchGetUsersResponse)(nil), // 47: hyapp.user.v1.BatchGetUsersResponse - (*ListUserIDsRequest)(nil), // 48: hyapp.user.v1.ListUserIDsRequest - (*ListUserIDsResponse)(nil), // 49: hyapp.user.v1.ListUserIDsResponse - (*UpdateUserProfileRequest)(nil), // 50: hyapp.user.v1.UpdateUserProfileRequest - (*UpdateUserProfileResponse)(nil), // 51: hyapp.user.v1.UpdateUserProfileResponse - (*ChangeUserCountryRequest)(nil), // 52: hyapp.user.v1.ChangeUserCountryRequest - (*ChangeUserCountryResponse)(nil), // 53: hyapp.user.v1.ChangeUserCountryResponse - (*SetUserStatusRequest)(nil), // 54: hyapp.user.v1.SetUserStatusRequest - (*SetUserStatusResponse)(nil), // 55: hyapp.user.v1.SetUserStatusResponse - (*CompleteOnboardingRequest)(nil), // 56: hyapp.user.v1.CompleteOnboardingRequest - (*CompleteOnboardingResponse)(nil), // 57: hyapp.user.v1.CompleteOnboardingResponse - (*BindPushTokenRequest)(nil), // 58: hyapp.user.v1.BindPushTokenRequest - (*BindPushTokenResponse)(nil), // 59: hyapp.user.v1.BindPushTokenResponse - (*DeletePushTokenRequest)(nil), // 60: hyapp.user.v1.DeletePushTokenRequest - (*DeletePushTokenResponse)(nil), // 61: hyapp.user.v1.DeletePushTokenResponse - (*Country)(nil), // 62: hyapp.user.v1.Country - (*Region)(nil), // 63: hyapp.user.v1.Region - (*ListCountriesRequest)(nil), // 64: hyapp.user.v1.ListCountriesRequest - (*ListCountriesResponse)(nil), // 65: hyapp.user.v1.ListCountriesResponse - (*UpdateCountryRequest)(nil), // 66: hyapp.user.v1.UpdateCountryRequest - (*CountryResponse)(nil), // 67: hyapp.user.v1.CountryResponse - (*ListRegistrationCountriesRequest)(nil), // 68: hyapp.user.v1.ListRegistrationCountriesRequest - (*ListRegistrationCountriesResponse)(nil), // 69: hyapp.user.v1.ListRegistrationCountriesResponse - (*LoginRiskBlockedCountry)(nil), // 70: hyapp.user.v1.LoginRiskBlockedCountry - (*ListLoginRiskBlockedCountriesRequest)(nil), // 71: hyapp.user.v1.ListLoginRiskBlockedCountriesRequest - (*ListLoginRiskBlockedCountriesResponse)(nil), // 72: hyapp.user.v1.ListLoginRiskBlockedCountriesResponse - (*ListRegionsRequest)(nil), // 73: hyapp.user.v1.ListRegionsRequest - (*ListRegionsResponse)(nil), // 74: hyapp.user.v1.ListRegionsResponse - (*GetRegionRequest)(nil), // 75: hyapp.user.v1.GetRegionRequest - (*UpdateRegionRequest)(nil), // 76: hyapp.user.v1.UpdateRegionRequest - (*ReplaceRegionCountriesRequest)(nil), // 77: hyapp.user.v1.ReplaceRegionCountriesRequest - (*RegionResponse)(nil), // 78: hyapp.user.v1.RegionResponse - (*UserIdentity)(nil), // 79: hyapp.user.v1.UserIdentity - (*GetUserIdentityRequest)(nil), // 80: hyapp.user.v1.GetUserIdentityRequest - (*GetUserIdentityResponse)(nil), // 81: hyapp.user.v1.GetUserIdentityResponse - (*ResolveDisplayUserIDRequest)(nil), // 82: hyapp.user.v1.ResolveDisplayUserIDRequest - (*ResolveDisplayUserIDResponse)(nil), // 83: hyapp.user.v1.ResolveDisplayUserIDResponse - (*ChangeDisplayUserIDRequest)(nil), // 84: hyapp.user.v1.ChangeDisplayUserIDRequest - (*ChangeDisplayUserIDResponse)(nil), // 85: hyapp.user.v1.ChangeDisplayUserIDResponse - (*ApplyPrettyDisplayUserIDRequest)(nil), // 86: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest - (*ApplyPrettyDisplayUserIDResponse)(nil), // 87: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse - (*ExpirePrettyDisplayUserIDRequest)(nil), // 88: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest - (*ExpirePrettyDisplayUserIDResponse)(nil), // 89: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse - nil, // 90: hyapp.user.v1.BatchGetUsersResponse.UsersEntry + (*UserReport)(nil), // 46: hyapp.user.v1.UserReport + (*SubmitReportRequest)(nil), // 47: hyapp.user.v1.SubmitReportRequest + (*SubmitReportResponse)(nil), // 48: hyapp.user.v1.SubmitReportResponse + (*BatchGetUsersRequest)(nil), // 49: hyapp.user.v1.BatchGetUsersRequest + (*BatchGetUsersResponse)(nil), // 50: hyapp.user.v1.BatchGetUsersResponse + (*ListUserIDsRequest)(nil), // 51: hyapp.user.v1.ListUserIDsRequest + (*ListUserIDsResponse)(nil), // 52: hyapp.user.v1.ListUserIDsResponse + (*UpdateUserProfileRequest)(nil), // 53: hyapp.user.v1.UpdateUserProfileRequest + (*UpdateUserProfileResponse)(nil), // 54: hyapp.user.v1.UpdateUserProfileResponse + (*ChangeUserCountryRequest)(nil), // 55: hyapp.user.v1.ChangeUserCountryRequest + (*ChangeUserCountryResponse)(nil), // 56: hyapp.user.v1.ChangeUserCountryResponse + (*SetUserStatusRequest)(nil), // 57: hyapp.user.v1.SetUserStatusRequest + (*SetUserStatusResponse)(nil), // 58: hyapp.user.v1.SetUserStatusResponse + (*CompleteOnboardingRequest)(nil), // 59: hyapp.user.v1.CompleteOnboardingRequest + (*CompleteOnboardingResponse)(nil), // 60: hyapp.user.v1.CompleteOnboardingResponse + (*BindPushTokenRequest)(nil), // 61: hyapp.user.v1.BindPushTokenRequest + (*BindPushTokenResponse)(nil), // 62: hyapp.user.v1.BindPushTokenResponse + (*DeletePushTokenRequest)(nil), // 63: hyapp.user.v1.DeletePushTokenRequest + (*DeletePushTokenResponse)(nil), // 64: hyapp.user.v1.DeletePushTokenResponse + (*Country)(nil), // 65: hyapp.user.v1.Country + (*Region)(nil), // 66: hyapp.user.v1.Region + (*ListCountriesRequest)(nil), // 67: hyapp.user.v1.ListCountriesRequest + (*ListCountriesResponse)(nil), // 68: hyapp.user.v1.ListCountriesResponse + (*UpdateCountryRequest)(nil), // 69: hyapp.user.v1.UpdateCountryRequest + (*CountryResponse)(nil), // 70: hyapp.user.v1.CountryResponse + (*ListRegistrationCountriesRequest)(nil), // 71: hyapp.user.v1.ListRegistrationCountriesRequest + (*ListRegistrationCountriesResponse)(nil), // 72: hyapp.user.v1.ListRegistrationCountriesResponse + (*LoginRiskBlockedCountry)(nil), // 73: hyapp.user.v1.LoginRiskBlockedCountry + (*ListLoginRiskBlockedCountriesRequest)(nil), // 74: hyapp.user.v1.ListLoginRiskBlockedCountriesRequest + (*ListLoginRiskBlockedCountriesResponse)(nil), // 75: hyapp.user.v1.ListLoginRiskBlockedCountriesResponse + (*ListRegionsRequest)(nil), // 76: hyapp.user.v1.ListRegionsRequest + (*ListRegionsResponse)(nil), // 77: hyapp.user.v1.ListRegionsResponse + (*GetRegionRequest)(nil), // 78: hyapp.user.v1.GetRegionRequest + (*UpdateRegionRequest)(nil), // 79: hyapp.user.v1.UpdateRegionRequest + (*ReplaceRegionCountriesRequest)(nil), // 80: hyapp.user.v1.ReplaceRegionCountriesRequest + (*RegionResponse)(nil), // 81: hyapp.user.v1.RegionResponse + (*UserIdentity)(nil), // 82: hyapp.user.v1.UserIdentity + (*GetUserIdentityRequest)(nil), // 83: hyapp.user.v1.GetUserIdentityRequest + (*GetUserIdentityResponse)(nil), // 84: hyapp.user.v1.GetUserIdentityResponse + (*ResolveDisplayUserIDRequest)(nil), // 85: hyapp.user.v1.ResolveDisplayUserIDRequest + (*ResolveDisplayUserIDResponse)(nil), // 86: hyapp.user.v1.ResolveDisplayUserIDResponse + (*ChangeDisplayUserIDRequest)(nil), // 87: hyapp.user.v1.ChangeDisplayUserIDRequest + (*ChangeDisplayUserIDResponse)(nil), // 88: hyapp.user.v1.ChangeDisplayUserIDResponse + (*ApplyPrettyDisplayUserIDRequest)(nil), // 89: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest + (*ApplyPrettyDisplayUserIDResponse)(nil), // 90: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse + (*ExpirePrettyDisplayUserIDRequest)(nil), // 91: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest + (*ExpirePrettyDisplayUserIDResponse)(nil), // 92: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse + nil, // 93: hyapp.user.v1.BatchGetUsersResponse.UsersEntry } var file_proto_user_v1_user_proto_depIdxs = []int32{ 1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta @@ -6909,130 +7195,134 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{ 40, // 30: hyapp.user.v1.ListFriendsResponse.records:type_name -> hyapp.user.v1.FriendRecord 1, // 31: hyapp.user.v1.ListFriendApplicationsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 43, // 32: hyapp.user.v1.ListFriendApplicationsResponse.applications:type_name -> hyapp.user.v1.FriendApplication - 1, // 33: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 90, // 34: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry - 1, // 35: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 36: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 37: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User - 1, // 38: hyapp.user.v1.ChangeUserCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 39: hyapp.user.v1.ChangeUserCountryResponse.user:type_name -> hyapp.user.v1.User - 1, // 40: hyapp.user.v1.SetUserStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 0, // 41: hyapp.user.v1.SetUserStatusRequest.status:type_name -> hyapp.user.v1.UserStatus - 5, // 42: hyapp.user.v1.SetUserStatusResponse.user:type_name -> hyapp.user.v1.User - 1, // 43: hyapp.user.v1.CompleteOnboardingRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 44: hyapp.user.v1.CompleteOnboardingResponse.user:type_name -> hyapp.user.v1.User - 13, // 45: hyapp.user.v1.CompleteOnboardingResponse.token:type_name -> hyapp.user.v1.AuthToken - 7, // 46: hyapp.user.v1.CompleteOnboardingResponse.invite:type_name -> hyapp.user.v1.InviteBinding - 1, // 47: hyapp.user.v1.BindPushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 48: hyapp.user.v1.DeletePushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 49: hyapp.user.v1.ListCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 62, // 50: hyapp.user.v1.ListCountriesResponse.countries:type_name -> hyapp.user.v1.Country - 1, // 51: hyapp.user.v1.UpdateCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 62, // 52: hyapp.user.v1.CountryResponse.country:type_name -> hyapp.user.v1.Country - 1, // 53: hyapp.user.v1.ListRegistrationCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 62, // 54: hyapp.user.v1.ListRegistrationCountriesResponse.countries:type_name -> hyapp.user.v1.Country - 1, // 55: hyapp.user.v1.ListLoginRiskBlockedCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 70, // 56: hyapp.user.v1.ListLoginRiskBlockedCountriesResponse.countries:type_name -> hyapp.user.v1.LoginRiskBlockedCountry - 1, // 57: hyapp.user.v1.ListRegionsRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 63, // 58: hyapp.user.v1.ListRegionsResponse.regions:type_name -> hyapp.user.v1.Region - 1, // 59: hyapp.user.v1.GetRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 60: hyapp.user.v1.UpdateRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 61: hyapp.user.v1.ReplaceRegionCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 63, // 62: hyapp.user.v1.RegionResponse.region:type_name -> hyapp.user.v1.Region - 1, // 63: hyapp.user.v1.GetUserIdentityRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 79, // 64: hyapp.user.v1.GetUserIdentityResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 65: hyapp.user.v1.ResolveDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 79, // 66: hyapp.user.v1.ResolveDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 67: hyapp.user.v1.ChangeDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 79, // 68: hyapp.user.v1.ChangeDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 69: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 79, // 70: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 71: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 79, // 72: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 5, // 73: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User - 14, // 74: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest - 16, // 75: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest - 20, // 76: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest - 46, // 77: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest - 48, // 78: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest - 9, // 79: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest - 50, // 80: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest - 52, // 81: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest - 54, // 82: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest - 56, // 83: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest - 22, // 84: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest - 25, // 85: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest - 27, // 86: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest - 29, // 87: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest - 32, // 88: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest - 34, // 89: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest - 36, // 90: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest - 38, // 91: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest - 41, // 92: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest - 44, // 93: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest - 11, // 94: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest - 11, // 95: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest - 11, // 96: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest - 58, // 97: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest - 60, // 98: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest - 3, // 99: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest - 64, // 100: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest - 66, // 101: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest - 68, // 102: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest - 71, // 103: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest - 73, // 104: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest - 75, // 105: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest - 76, // 106: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest - 77, // 107: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest - 80, // 108: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest - 82, // 109: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest - 84, // 110: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest - 86, // 111: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest - 88, // 112: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest - 15, // 113: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse - 18, // 114: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse - 21, // 115: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse - 47, // 116: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse - 49, // 117: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse - 10, // 118: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse - 51, // 119: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse - 53, // 120: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse - 55, // 121: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse - 57, // 122: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse - 23, // 123: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse - 26, // 124: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse - 28, // 125: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse - 30, // 126: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse - 33, // 127: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse - 35, // 128: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse - 37, // 129: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse - 39, // 130: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse - 42, // 131: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse - 45, // 132: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse - 12, // 133: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse - 12, // 134: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse - 12, // 135: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse - 59, // 136: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse - 61, // 137: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse - 4, // 138: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse - 65, // 139: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse - 67, // 140: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse - 69, // 141: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse - 72, // 142: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse - 74, // 143: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse - 78, // 144: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse - 78, // 145: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse - 78, // 146: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse - 81, // 147: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse - 83, // 148: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse - 85, // 149: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse - 87, // 150: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse - 89, // 151: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse - 113, // [113:152] is the sub-list for method output_type - 74, // [74:113] is the sub-list for method input_type - 74, // [74:74] is the sub-list for extension type_name - 74, // [74:74] is the sub-list for extension extendee - 0, // [0:74] is the sub-list for field type_name + 1, // 33: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 46, // 34: hyapp.user.v1.SubmitReportResponse.report:type_name -> hyapp.user.v1.UserReport + 1, // 35: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 93, // 36: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry + 1, // 37: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 38: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 39: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User + 1, // 40: hyapp.user.v1.ChangeUserCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 41: hyapp.user.v1.ChangeUserCountryResponse.user:type_name -> hyapp.user.v1.User + 1, // 42: hyapp.user.v1.SetUserStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 0, // 43: hyapp.user.v1.SetUserStatusRequest.status:type_name -> hyapp.user.v1.UserStatus + 5, // 44: hyapp.user.v1.SetUserStatusResponse.user:type_name -> hyapp.user.v1.User + 1, // 45: hyapp.user.v1.CompleteOnboardingRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 46: hyapp.user.v1.CompleteOnboardingResponse.user:type_name -> hyapp.user.v1.User + 13, // 47: hyapp.user.v1.CompleteOnboardingResponse.token:type_name -> hyapp.user.v1.AuthToken + 7, // 48: hyapp.user.v1.CompleteOnboardingResponse.invite:type_name -> hyapp.user.v1.InviteBinding + 1, // 49: hyapp.user.v1.BindPushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 50: hyapp.user.v1.DeletePushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 51: hyapp.user.v1.ListCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 65, // 52: hyapp.user.v1.ListCountriesResponse.countries:type_name -> hyapp.user.v1.Country + 1, // 53: hyapp.user.v1.UpdateCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 65, // 54: hyapp.user.v1.CountryResponse.country:type_name -> hyapp.user.v1.Country + 1, // 55: hyapp.user.v1.ListRegistrationCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 65, // 56: hyapp.user.v1.ListRegistrationCountriesResponse.countries:type_name -> hyapp.user.v1.Country + 1, // 57: hyapp.user.v1.ListLoginRiskBlockedCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 73, // 58: hyapp.user.v1.ListLoginRiskBlockedCountriesResponse.countries:type_name -> hyapp.user.v1.LoginRiskBlockedCountry + 1, // 59: hyapp.user.v1.ListRegionsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 66, // 60: hyapp.user.v1.ListRegionsResponse.regions:type_name -> hyapp.user.v1.Region + 1, // 61: hyapp.user.v1.GetRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 62: hyapp.user.v1.UpdateRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 63: hyapp.user.v1.ReplaceRegionCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 66, // 64: hyapp.user.v1.RegionResponse.region:type_name -> hyapp.user.v1.Region + 1, // 65: hyapp.user.v1.GetUserIdentityRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 82, // 66: hyapp.user.v1.GetUserIdentityResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 67: hyapp.user.v1.ResolveDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 82, // 68: hyapp.user.v1.ResolveDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 69: hyapp.user.v1.ChangeDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 82, // 70: hyapp.user.v1.ChangeDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 71: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 82, // 72: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 73: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 82, // 74: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 5, // 75: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User + 14, // 76: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest + 16, // 77: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest + 20, // 78: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest + 49, // 79: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest + 51, // 80: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest + 9, // 81: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest + 53, // 82: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest + 55, // 83: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest + 57, // 84: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest + 59, // 85: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest + 22, // 86: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest + 25, // 87: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest + 27, // 88: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest + 29, // 89: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest + 32, // 90: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest + 34, // 91: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest + 36, // 92: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest + 38, // 93: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest + 41, // 94: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest + 44, // 95: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest + 47, // 96: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest + 11, // 97: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest + 11, // 98: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest + 11, // 99: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest + 61, // 100: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest + 63, // 101: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest + 3, // 102: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest + 67, // 103: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest + 69, // 104: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest + 71, // 105: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest + 74, // 106: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest + 76, // 107: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest + 78, // 108: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest + 79, // 109: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest + 80, // 110: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest + 83, // 111: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest + 85, // 112: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest + 87, // 113: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest + 89, // 114: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest + 91, // 115: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest + 15, // 116: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse + 18, // 117: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse + 21, // 118: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse + 50, // 119: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse + 52, // 120: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse + 10, // 121: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse + 54, // 122: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse + 56, // 123: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse + 58, // 124: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse + 60, // 125: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse + 23, // 126: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse + 26, // 127: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse + 28, // 128: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse + 30, // 129: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse + 33, // 130: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse + 35, // 131: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse + 37, // 132: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse + 39, // 133: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse + 42, // 134: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse + 45, // 135: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse + 48, // 136: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse + 12, // 137: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse + 12, // 138: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse + 12, // 139: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse + 62, // 140: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse + 64, // 141: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse + 4, // 142: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse + 68, // 143: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse + 70, // 144: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse + 72, // 145: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse + 75, // 146: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse + 77, // 147: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse + 81, // 148: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse + 81, // 149: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse + 81, // 150: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse + 84, // 151: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse + 86, // 152: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse + 88, // 153: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse + 90, // 154: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse + 92, // 155: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse + 116, // [116:156] is the sub-list for method output_type + 76, // [76:116] is the sub-list for method input_type + 76, // [76:76] is the sub-list for extension type_name + 76, // [76:76] is the sub-list for extension extendee + 0, // [0:76] is the sub-list for field type_name } func init() { file_proto_user_v1_user_proto_init() } @@ -7040,15 +7330,15 @@ func file_proto_user_v1_user_proto_init() { if File_proto_user_v1_user_proto != nil { return } - file_proto_user_v1_user_proto_msgTypes[49].OneofWrappers = []any{} - file_proto_user_v1_user_proto_msgTypes[63].OneofWrappers = []any{} + file_proto_user_v1_user_proto_msgTypes[52].OneofWrappers = []any{} + file_proto_user_v1_user_proto_msgTypes[66].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)), NumEnums: 1, - NumMessages: 90, + NumMessages: 93, NumExtensions: 0, NumServices: 9, }, diff --git a/api/proto/user/v1/user.proto b/api/proto/user/v1/user.proto index dc6c6df9..baa58b18 100644 --- a/api/proto/user/v1/user.proto +++ b/api/proto/user/v1/user.proto @@ -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 修改。 diff --git a/api/proto/user/v1/user_grpc.pb.go b/api/proto/user/v1/user_grpc.pb.go index 2d4602d4..c4fc2f91 100644 --- a/api/proto/user/v1/user_grpc.pb.go +++ b/api/proto/user/v1/user_grpc.pb.go @@ -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", diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 50087ac0..337142f1 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -34,9 +34,15 @@ type DebitGiftRequest struct { PriceVersion string `protobuf:"bytes,7,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"` AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` // region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。 - RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RegionId int64 `protobuf:"varint,9,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + // target_is_host 由 gateway 根据 user-service active host profile 注入,客户端不能直接声明。 + TargetIsHost bool `protobuf:"varint,10,opt,name=target_is_host,json=targetIsHost,proto3" json:"target_is_host,omitempty"` + // target_host_region_id 是主播身份所属区域,用于后续按主播区域匹配工资政策。 + TargetHostRegionId int64 `protobuf:"varint,11,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"` + // target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。 + TargetAgencyOwnerUserId int64 `protobuf:"varint,12,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebitGiftRequest) Reset() { @@ -132,6 +138,27 @@ func (x *DebitGiftRequest) GetRegionId() int64 { return 0 } +func (x *DebitGiftRequest) GetTargetIsHost() bool { + if x != nil { + return x.TargetIsHost + } + return false +} + +func (x *DebitGiftRequest) GetTargetHostRegionId() int64 { + if x != nil { + return x.TargetHostRegionId + } + return 0 +} + +func (x *DebitGiftRequest) GetTargetAgencyOwnerUserId() int64 { + if x != nil { + return x.TargetAgencyOwnerUserId + } + return 0 +} + // DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。 type DebitGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -146,8 +173,12 @@ type DebitGiftResponse struct { ChargeAssetType string `protobuf:"bytes,8,opt,name=charge_asset_type,json=chargeAssetType,proto3" json:"charge_asset_type,omitempty"` ChargeAmount int64 `protobuf:"varint,9,opt,name=charge_amount,json=chargeAmount,proto3" json:"charge_amount,omitempty"` GiftTypeCode string `protobuf:"bytes,10,opt,name=gift_type_code,json=giftTypeCode,proto3" json:"gift_type_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // host_period_diamond_added 是本次送礼给主播周期钻石账户增加的钻石;非主播为 0。 + HostPeriodDiamondAdded int64 `protobuf:"varint,11,opt,name=host_period_diamond_added,json=hostPeriodDiamondAdded,proto3" json:"host_period_diamond_added,omitempty"` + // host_period_cycle_key 是 UTC 月周期,例如 2026-05;非主播为空。 + HostPeriodCycleKey string `protobuf:"bytes,12,opt,name=host_period_cycle_key,json=hostPeriodCycleKey,proto3" json:"host_period_cycle_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebitGiftResponse) Reset() { @@ -250,6 +281,20 @@ func (x *DebitGiftResponse) GetGiftTypeCode() string { return "" } +func (x *DebitGiftResponse) GetHostPeriodDiamondAdded() int64 { + if x != nil { + return x.HostPeriodDiamondAdded + } + return 0 +} + +func (x *DebitGiftResponse) GetHostPeriodCycleKey() string { + if x != nil { + return x.HostPeriodCycleKey + } + return "" +} + // AssetBalance 是用户某类资产的余额投影。 type AssetBalance struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -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, diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 2adcb187..a9c8bcc2 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -17,6 +17,12 @@ message DebitGiftRequest { string app_code = 8; // region_id 来自 room-service 房间 visible_region_id,用于校验礼物区域可用性。 int64 region_id = 9; + // target_is_host 由 gateway 根据 user-service active host profile 注入,客户端不能直接声明。 + bool target_is_host = 10; + // target_host_region_id 是主播身份所属区域,用于后续按主播区域匹配工资政策。 + int64 target_host_region_id = 11; + // target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。 + int64 target_agency_owner_user_id = 12; } // DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。 @@ -32,6 +38,10 @@ message DebitGiftResponse { string charge_asset_type = 8; int64 charge_amount = 9; string gift_type_code = 10; + // host_period_diamond_added 是本次送礼给主播周期钻石账户增加的钻石;非主播为 0。 + int64 host_period_diamond_added = 11; + // host_period_cycle_key 是 UTC 月周期,例如 2026-05;非主播为空。 + string host_period_cycle_key = 12; } // AssetBalance 是用户某类资产的余额投影。 @@ -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); } diff --git a/api/proto/wallet/v1/wallet_grpc.pb.go b/api/proto/wallet/v1/wallet_grpc.pb.go index c7ba861a..97346b9c 100644 --- a/api/proto/wallet/v1/wallet_grpc.pb.go +++ b/api/proto/wallet/v1/wallet_grpc.pb.go @@ -18,6 +18,188 @@ import ( // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 +const ( + WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryDailySettlementBatch" + WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryHalfMonthSettlementBatch" + WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryMonthEndBatch" +) + +// WalletCronServiceClient is the client API for WalletCronService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。 +type WalletCronServiceClient interface { + ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) + ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) + ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) +} + +type walletCronServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewWalletCronServiceClient(cc grpc.ClientConnInterface) WalletCronServiceClient { + return &walletCronServiceClient{cc} +} + +func (c *walletCronServiceClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CronBatchResponse) + err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletCronServiceClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CronBatchResponse) + err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletCronServiceClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CronBatchResponse) + err := c.cc.Invoke(ctx, WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// WalletCronServiceServer is the server API for WalletCronService service. +// All implementations must embed UnimplementedWalletCronServiceServer +// for forward compatibility. +// +// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。 +type WalletCronServiceServer interface { + ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) + ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) + ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) + mustEmbedUnimplementedWalletCronServiceServer() +} + +// UnimplementedWalletCronServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedWalletCronServiceServer struct{} + +func (UnimplementedWalletCronServiceServer) ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryDailySettlementBatch not implemented") +} +func (UnimplementedWalletCronServiceServer) ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryHalfMonthSettlementBatch not implemented") +} +func (UnimplementedWalletCronServiceServer) ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProcessHostSalaryMonthEndBatch not implemented") +} +func (UnimplementedWalletCronServiceServer) mustEmbedUnimplementedWalletCronServiceServer() {} +func (UnimplementedWalletCronServiceServer) testEmbeddedByValue() {} + +// UnsafeWalletCronServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to WalletCronServiceServer will +// result in compilation errors. +type UnsafeWalletCronServiceServer interface { + mustEmbedUnimplementedWalletCronServiceServer() +} + +func RegisterWalletCronServiceServer(s grpc.ServiceRegistrar, srv WalletCronServiceServer) { + // If the following call panics, it indicates UnimplementedWalletCronServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&WalletCronService_ServiceDesc, srv) +} + +func _WalletCronService_ProcessHostSalaryDailySettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CronBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletCronServiceServer).ProcessHostSalaryDailySettlementBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletCronServiceServer).ProcessHostSalaryDailySettlementBatch(ctx, req.(*CronBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CronBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletCronServiceServer).ProcessHostSalaryHalfMonthSettlementBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletCronServiceServer).ProcessHostSalaryHalfMonthSettlementBatch(ctx, req.(*CronBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CronBatchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletCronServiceServer).ProcessHostSalaryMonthEndBatch(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletCronServiceServer).ProcessHostSalaryMonthEndBatch(ctx, req.(*CronBatchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// WalletCronService_ServiceDesc is the grpc.ServiceDesc for WalletCronService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var WalletCronService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "hyapp.wallet.v1.WalletCronService", + HandlerType: (*WalletCronServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ProcessHostSalaryDailySettlementBatch", + Handler: _WalletCronService_ProcessHostSalaryDailySettlementBatch_Handler, + }, + { + MethodName: "ProcessHostSalaryHalfMonthSettlementBatch", + Handler: _WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_Handler, + }, + { + MethodName: "ProcessHostSalaryMonthEndBatch", + Handler: _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/wallet/v1/wallet.proto", +} + const ( WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift" WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances" @@ -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", diff --git a/docs/flutter对接/用户举报Flutter对接.md b/docs/flutter对接/用户举报Flutter对接.md new file mode 100644 index 00000000..20b821a8 --- /dev/null +++ b/docs/flutter对接/用户举报Flutter对接.md @@ -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;举报发起人只从 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 +X-App-Code: lalu +Content-Type: multipart/form-data + +file= +``` + +请求字段: + +| 字段 | 必填 | 类型 | 说明 | +| --- | --- | --- | --- | +| `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 +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 +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 imageUrls; + + Map 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 状态处理。 diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 433a24e6..99f94dff 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -37,7 +37,9 @@ import ( firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement" healthmodule "hyapp-admin-server/internal/modules/health" + hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy" hostorgmodule "hyapp-admin-server/internal/modules/hostorg" + hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement" jobmodule "hyapp-admin-server/internal/modules/job" levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig" luckygiftmodule "hyapp-admin-server/internal/modules/luckygift" @@ -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) diff --git a/server/admin/internal/integration/walletclient/client.go b/server/admin/internal/integration/walletclient/client.go index a9184f09..c9ac62ed 100644 --- a/server/admin/internal/integration/walletclient/client.go +++ b/server/admin/internal/integration/walletclient/client.go @@ -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) +} diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index a64b3844..b654bcec 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -147,6 +147,108 @@ func (AppExploreTab) TableName() string { return "admin_app_explore_tabs" } +type HostAgencySalaryPolicy struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null;default:lalu" json:"appCode"` + Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"` + // RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。 + RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null" json:"regionId"` + Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"` + // SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。 + SettlementMode string `gorm:"size:24;not null;default:daily" json:"settlementMode"` + // SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。 + SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"` + // 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。 + GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"` + ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"` + // 时间范围采用 epoch ms 半开区间,EffectiveToMS=0 表示长期有效。 + EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"` + EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"` + Description string `gorm:"size:255;not null;default:''" json:"description"` + CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"` + UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"` + // 发布字段只记录 admin 配置同步到 wallet 运行快照的结果;结算仍只读取 wallet-service 自己的运行表。 + PublishStatus string `gorm:"column:publish_status;size:24;not null;default:draft" json:"publishStatus"` + PublishError string `gorm:"column:publish_error;size:255;not null;default:''" json:"publishError"` + PublishedAtMS int64 `gorm:"column:published_at_ms;not null;default:0" json:"publishedAtMs"` + PublishedByAdminID uint `gorm:"column:published_by_admin_id;not null;default:0" json:"publishedByAdminId"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` + Levels []HostAgencySalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"` +} + +func (HostAgencySalaryPolicy) TableName() string { + return "admin_host_agency_salary_policies" +} + +type HostAgencySalaryLevel struct { + ID uint `gorm:"primaryKey" json:"id"` + PolicyID uint `gorm:"uniqueIndex:uk_admin_host_agency_salary_level;index:idx_admin_host_agency_salary_level_policy,not null" json:"policyId"` + // LevelNo 是累计工资差额结算的顺序锚点,必须在同一 policy 下唯一。 + LevelNo int32 `gorm:"column:level_no;uniqueIndex:uk_admin_host_agency_salary_level;not null" json:"level"` + // RequiredDiamonds 保存主播当月钻石门槛,月底清空逻辑不在 admin-server 第一阶段执行。 + RequiredDiamonds int64 `gorm:"column:required_diamonds;index:idx_admin_host_agency_salary_level_policy,not null" json:"requiredDiamonds"` + // 三个权益字段保存“达到该等级后的累计值”,结算任务只发本次等级与已结算等级之间的差额。 + HostSalaryUSD string `gorm:"column:host_salary_usd;type:decimal(12,2);not null;default:0.00" json:"hostSalaryUsd"` + HostCoinReward int64 `gorm:"column:host_coin_reward;not null;default:0" json:"hostCoinReward"` + AgencySalaryUSD string `gorm:"column:agency_salary_usd;type:decimal(12,2);not null;default:0.00" json:"agencySalaryUsd"` + Status string `gorm:"size:24;not null;default:active" json:"status"` + SortOrder int32 `gorm:"column:sort_order;not null;default:0" json:"sortOrder"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (HostAgencySalaryLevel) TableName() string { + return "admin_host_agency_salary_policy_levels" +} + +type TeamSalaryPolicy struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null;default:lalu" json:"appCode"` + // PolicyType 当前只允许 bd/admin;拆表会让两类策略后续结算扩展变重,所以用类型字段保持同一套配置 API。 + PolicyType string `gorm:"column:policy_type;size:24;uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null" json:"policyType"` + Name string `gorm:"size:120;uniqueIndex:uk_admin_team_salary_policy_name;not null" json:"name"` + // RegionID 沿用 Host 政策的区域边界,保证同一区域组织链路可以命中同一套工资配置。 + RegionID int64 `gorm:"uniqueIndex:uk_admin_team_salary_policy_name;index:idx_admin_team_salary_policy_scope,not null" json:"regionId"` + Status string `gorm:"size:24;index:idx_admin_team_salary_policy_scope,not null;default:disabled" json:"status"` + // SettlementTriggerMode 决定次月 5 日窗口由自动任务处理还是进入后台人工批量结算池。 + SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"` + // BD/Admin 都按完整自然月统计,结算日期固定为次月 5 日;这里不再提供日结/半月结节奏字段。 + EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_team_salary_policy_scope,not null;default:0" json:"effectiveFromMs"` + EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"` + Description string `gorm:"size:255;not null;default:''" json:"description"` + CreatedByAdminID uint `gorm:"column:created_by_admin_id;not null;default:0" json:"createdByAdminId"` + UpdatedByAdminID uint `gorm:"column:updated_by_admin_id;not null;default:0" json:"updatedByAdminId"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` + Levels []TeamSalaryLevel `gorm:"foreignKey:PolicyID" json:"levels"` +} + +func (TeamSalaryPolicy) TableName() string { + return "admin_team_salary_policies" +} + +type TeamSalaryLevel struct { + ID uint `gorm:"primaryKey" json:"id"` + PolicyID uint `gorm:"uniqueIndex:uk_admin_team_salary_level;index:idx_admin_team_salary_level_policy,not null" json:"policyId"` + // LevelNo 是差额结算锚点,当前等级累计工资减去已结算累计工资就是本次应发金额。 + LevelNo int32 `gorm:"column:level_no;uniqueIndex:uk_admin_team_salary_level;not null" json:"level"` + // ThresholdUSD 对 BD 表示下属 Agency 管理主播 Host Salary 合计门槛,对 Admin 表示下属 BD Salary 合计门槛。 + ThresholdUSD string `gorm:"column:threshold_usd;type:decimal(12,2);index:idx_admin_team_salary_level_policy,not null;default:0.00" json:"thresholdUsd"` + // RatePercent 保存运营看到的百分比数字,例如 8 表示 8%,结算时再换算成比例。 + RatePercent string `gorm:"column:rate_percent;type:decimal(9,4);not null;default:0.0000" json:"ratePercent"` + // SalaryUSD 是达到该等级后的累计应得工资,实际入账仍按差额结算。 + SalaryUSD string `gorm:"column:salary_usd;type:decimal(12,2);not null;default:0.00" json:"salaryUsd"` + Status string `gorm:"size:24;not null;default:active" json:"status"` + SortOrder int32 `gorm:"column:sort_order;not null;default:0" json:"sortOrder"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (TeamSalaryLevel) TableName() string { + return "admin_team_salary_policy_levels" +} + type RefreshToken struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"index;not null" json:"userId"` diff --git a/server/admin/internal/modules/hostagencypolicy/handler.go b/server/admin/internal/modules/hostagencypolicy/handler.go new file mode 100644 index 00000000..e5d72fbe --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/handler.go @@ -0,0 +1,138 @@ +package hostagencypolicy + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/repository" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service + audit shared.OperationLogger +} + +func New(store *repository.Store, walletDB *sql.DB, audit shared.OperationLogger) *Handler { + return &Handler{service: NewService(store, walletDB), audit: audit} +} + +func (h *Handler) ListPolicies(c *gin.Context) { + options := shared.ListOptions(c) + // query 层保留 snake_case/camelCase 两种写法,兼容生成端和手写端调用。 + items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{ + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + Status: options.Status, + SettlementMode: firstQuery(c, "settlement_mode", "settlementMode"), + // trigger mode 可直接筛出“只允许手动结算”的政策,后续工资结算页会复用同一查询口径。 + SettlementTriggerMode: firstQuery(c, "settlement_trigger_mode", "settlementTriggerMode"), + Page: options.Page, + PageSize: options.PageSize, + }) + if err != nil { + response.ServerError(c, "获取工资政策配置失败") + return + } + response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) +} + +func (h *Handler) CreatePolicy(c *gin.Context) { + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + // 操作人只从登录上下文取,不信任请求体,方便后续审计定位配置变更来源。 + item, err := h.service.Create(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "create-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID)) + response.Created(c, item) +} + +func (h *Handler) UpdatePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + // PUT 语义是整条政策配置替换,等级明细也会由 service/repository 按整包替换。 + item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "update-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d", item.RegionID)) + response.OK(c, item) +} + +func (h *Handler) DeletePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + if err := h.service.Delete(c.Request.Context(), appctx.FromContext(c.Request.Context()), id); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "delete-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(id), 10), "success", "") + response.OK(c, gin.H{"deleted": true}) +} + +func (h *Handler) PublishPolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + item, err := h.service.Publish(c.Request.Context(), appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "publish-host-agency-policy", "admin_host_agency_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("region_id=%d status=%s", item.RegionID, item.Status)) + response.OK(c, item) +} + +func policyID(c *gin.Context) (uint, bool) { + value := strings.TrimSpace(c.Param("policy_id")) + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 { + response.BadRequest(c, "工资政策 ID 不正确") + return 0, false + } + return uint(parsed), true +} + +func queryInt64(c *gin.Context, keys ...string) int64 { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0 + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/hostagencypolicy/request.go b/server/admin/internal/modules/hostagencypolicy/request.go new file mode 100644 index 00000000..a9afb456 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/request.go @@ -0,0 +1,25 @@ +package hostagencypolicy + +type policyRequest struct { + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementMode string `json:"settlement_mode"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"` + ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + Levels []levelRequest `json:"levels"` +} + +type levelRequest struct { + Level int32 `json:"level"` + RequiredDiamonds int64 `json:"required_diamonds"` + HostSalaryUSD string `json:"host_salary_usd"` + HostCoinReward int64 `json:"host_coin_reward"` + AgencySalaryUSD string `json:"agency_salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` +} diff --git a/server/admin/internal/modules/hostagencypolicy/response.go b/server/admin/internal/modules/hostagencypolicy/response.go new file mode 100644 index 00000000..d0ecf127 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/response.go @@ -0,0 +1,122 @@ +package hostagencypolicy + +import ( + "strconv" + + "hyapp-admin-server/internal/model" +) + +type policyDTO struct { + ID uint `json:"id"` + AppCode string `json:"app_code"` + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementMode string `json:"settlement_mode"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"` + ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + CreatedByAdminID uint `json:"created_by_admin_id"` + UpdatedByAdminID uint `json:"updated_by_admin_id"` + PublishStatus string `json:"publish_status"` + PublishError string `json:"publish_error"` + PublishedAtMS int64 `json:"published_at_ms"` + PublishedByAdminID uint `json:"published_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Levels []levelDTO `json:"levels"` +} + +type levelDTO struct { + ID uint `json:"id"` + PolicyID uint `json:"policy_id"` + Level int32 `json:"level"` + RequiredDiamonds int64 `json:"required_diamonds"` + HostSalaryUSD string `json:"host_salary_usd"` + HostCoinReward int64 `json:"host_coin_reward"` + AgencySalaryUSD string `json:"agency_salary_usd"` + TotalSalaryUSD string `json:"total_salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` +} + +func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO { + levels := make([]levelDTO, 0, len(item.Levels)) + for _, level := range item.Levels { + levels = append(levels, levelFromModel(level)) + } + // 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。 + return policyDTO{ + ID: item.ID, + AppCode: item.AppCode, + Name: item.Name, + RegionID: item.RegionID, + Status: item.Status, + SettlementMode: item.SettlementMode, + SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), + GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio), + ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate), + EffectiveFromMS: item.EffectiveFromMS, + EffectiveToMS: item.EffectiveToMS, + Description: item.Description, + CreatedByAdminID: item.CreatedByAdminID, + UpdatedByAdminID: item.UpdatedByAdminID, + PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft), + PublishError: item.PublishError, + PublishedAtMS: item.PublishedAtMS, + PublishedByAdminID: item.PublishedByAdminID, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + Levels: levels, + } +} + +func levelFromModel(item model.HostAgencySalaryLevel) levelDTO { + hostSalary := trimDecimalZeros(item.HostSalaryUSD) + agencySalary := trimDecimalZeros(item.AgencySalaryUSD) + // total_salary_usd 只是后台表格辅助展示字段,结算仍应分别读取主播工资和代理工资做各自入账。 + return levelDTO{ + ID: item.ID, + PolicyID: item.PolicyID, + Level: item.LevelNo, + RequiredDiamonds: item.RequiredDiamonds, + HostSalaryUSD: hostSalary, + HostCoinReward: item.HostCoinReward, + AgencySalaryUSD: agencySalary, + TotalSalaryUSD: formatCents(parseCentsOrZero(hostSalary) + parseCentsOrZero(agencySalary)), + Status: item.Status, + SortOrder: item.SortOrder, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + } +} + +func parseCentsOrZero(value string) int64 { + // 复用服务层 decimal parser,确保展示合计和入库金额使用同一套小数规则。 + _, cents, err := parseFixedDecimal(value, 2, true, "salary") + if err != nil { + return 0 + } + return cents +} + +func formatCents(value int64) string { + whole := value / 100 + fraction := value % 100 + if fraction == 0 { + return strconv.FormatInt(whole, 10) + } + return strconv.FormatInt(whole, 10) + "." + twoDigits(fraction) +} + +func twoDigits(value int64) string { + if value < 10 { + return "0" + strconv.FormatInt(value, 10) + } + return strconv.FormatInt(value, 10) +} diff --git a/server/admin/internal/modules/hostagencypolicy/routes.go b/server/admin/internal/modules/hostagencypolicy/routes.go new file mode 100644 index 00000000..a39e2f00 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/routes.go @@ -0,0 +1,19 @@ +package hostagencypolicy + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + protected.GET("/admin/host-agency-policies", middleware.RequirePermission("host-agency-policy:view"), h.ListPolicies) + protected.POST("/admin/host-agency-policies", middleware.RequirePermission("host-agency-policy:create"), h.CreatePolicy) + protected.PUT("/admin/host-agency-policies/:policy_id", middleware.RequirePermission("host-agency-policy:update"), h.UpdatePolicy) + protected.POST("/admin/host-agency-policies/:policy_id/publish", middleware.RequirePermission("host-agency-policy:publish"), h.PublishPolicy) + protected.DELETE("/admin/host-agency-policies/:policy_id", middleware.RequirePermission("host-agency-policy:delete"), h.DeletePolicy) +} diff --git a/server/admin/internal/modules/hostagencypolicy/service.go b/server/admin/internal/modules/hostagencypolicy/service.go new file mode 100644 index 00000000..13cde9bc --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/service.go @@ -0,0 +1,614 @@ +package hostagencypolicy + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/repository" +) + +const ( + // active/disabled 只表达后台政策是否可用于结算匹配;历史结算记录不应因为停用政策被回写改口径。 + policyStatusActive = "active" + policyStatusDisabled = "disabled" + + // 第一阶段只配置可选结算节奏,真正的日结/半月结算调度会在后续结算任务里消费该字段。 + settlementModeDaily = "daily" + settlementModeHalfMonth = "half_month" + + // 自动触发由 cron-service 扫描结算;手动触发只进入后台待结算池,避免运营要求人工复核时被定时任务提前发放。 + settlementTriggerAutomatic = "automatic" + settlementTriggerManual = "manual" + + // 礼物金币转主播钻石默认 1:1;月底剩余钻石转美元默认不开启,由后台显式配置。 + defaultGiftCoinToDiamondRatio = "1" + defaultResidualDiamondRate = "0" + + publishStatusDraft = "draft" + publishStatusPublished = "published" + publishStatusFailed = "failed" +) + +type Service struct { + store *repository.Store + walletDB *sql.DB +} + +func NewService(store *repository.Store, walletDB *sql.DB) *Service { + return &Service{store: store, walletDB: walletDB} +} + +func (s *Service) List(appCode string, options repository.HostAgencySalaryPolicyListOptions) ([]policyDTO, int64, error) { + // 列表入口统一收敛 app_code、状态和结算方式,避免前端传别名或非法值时把无效条件下推到 DB。 + options.AppCode = appctx.Normalize(appCode) + options.Status = normalizeStatusFilter(options.Status) + options.SettlementMode = normalizeSettlementModeFilter(options.SettlementMode) + options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode) + items, total, err := s.store.ListHostAgencySalaryPolicies(options) + if err != nil { + return nil, 0, err + } + out := make([]policyDTO, 0, len(items)) + for _, item := range items { + out = append(out, policyFromModel(item)) + } + return out, total, nil +} + +func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) { + // 创建时先把请求规整为模型,所有金额和等级递增规则都在这个边界完成,仓储层只负责持久化。 + item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req) + if err != nil { + return policyDTO{}, err + } + // 启用政策必须先做时间段冲突校验,否则同一区域结算时会匹配到两套工资规则。 + if err := s.ensureNoActiveOverlap(item, 0); err != nil { + return policyDTO{}, err + } + if err := s.store.CreateHostAgencySalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) { + // 更新先读原记录,保证 app_code 和主键归属来自数据库,避免请求体越权改租户。 + item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id) + if err != nil { + return policyDTO{}, err + } + // 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。 + updated, err := policyModelFromRequest(item.AppCode, actorID, req) + if err != nil { + return policyDTO{}, err + } + item.Name = updated.Name + item.RegionID = updated.RegionID + item.Status = updated.Status + item.SettlementMode = updated.SettlementMode + item.SettlementTriggerMode = updated.SettlementTriggerMode + item.GiftCoinToDiamondRatio = updated.GiftCoinToDiamondRatio + item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate + item.EffectiveFromMS = updated.EffectiveFromMS + item.EffectiveToMS = updated.EffectiveToMS + item.Description = updated.Description + item.UpdatedByAdminID = actorID + // 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft。 + item.PublishStatus = publishStatusDraft + item.PublishError = "" + // 等级明细按整包替换处理:前端提交的是一套完整政策梯度,不做局部 patch,减少排序和递增口径漂移。 + item.Levels = updated.Levels + if err := s.ensureNoActiveOverlap(item, id); err != nil { + return policyDTO{}, err + } + if err := s.store.UpdateHostAgencySalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Delete(ctx context.Context, appCode string, id uint) error { + // 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。 + if _, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id); err != nil { + return err + } + // 删除后台配置前先清掉 wallet 运行快照,避免 admin 记录消失后结算仍命中过期政策。 + if err := s.deleteRuntimePolicy(ctx, appctx.Normalize(appCode), id); err != nil { + return err + } + return s.store.DeleteHostAgencySalaryPolicy(appctx.Normalize(appCode), id) +} + +func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id uint) (policyDTO, error) { + appCode = appctx.Normalize(appCode) + item, err := s.store.GetHostAgencySalaryPolicy(appCode, id) + if err != nil { + return policyDTO{}, err + } + if err := s.ensureNoActiveOverlap(item, id); err != nil { + return policyDTO{}, err + } + publishedAtMS := time.Now().UTC().UnixMilli() + if err := s.publishRuntimePolicy(ctx, item, publishedAtMS); err != nil { + // 发布失败也写回状态,方便后台列表明确展示“配置未进入 wallet 运行态”的事实。 + _ = s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusFailed, truncateRunes(err.Error(), 255), item.PublishedAtMS, actorID) + return policyDTO{}, err + } + if err := s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusPublished, "", publishedAtMS, actorID); err != nil { + return policyDTO{}, err + } + item.PublishStatus = publishStatusPublished + item.PublishError = "" + item.PublishedAtMS = publishedAtMS + item.PublishedByAdminID = actorID + return policyFromModel(item), nil +} + +func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, excludeID uint) error { + // 停用政策允许提前配置多套草稿;只有 active 才会参与结算匹配,所以冲突检查只约束启用态。 + if item.Status != policyStatusActive { + return nil + } + // 时间段判断采用半开区间:[effective_from_ms, effective_to_ms),effective_to_ms=0 表示长期有效。 + overlap, err := s.store.HasOverlappingActiveHostAgencySalaryPolicy(item.AppCode, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID) + if err != nil { + return err + } + if overlap { + return errors.New("同一区域同一时间只能存在一个启用政策") + } + return nil +} + +func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgencySalaryPolicy, publishedAtMS int64) error { + if s == nil || s.walletDB == nil { + return errors.New("wallet mysql is not configured") + } + if len(item.Levels) == 0 { + return errors.New("至少需要配置一个等级") + } + tx, err := s.walletDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // wallet-service 运行表以 admin policy_id 为主键;每次发布整包覆盖主表和等级,保证结算读取到一套完整梯度。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO host_agency_salary_policies ( + app_code, policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, + gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate, + effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + region_id = VALUES(region_id), + status = VALUES(status), + settlement_mode = VALUES(settlement_mode), + settlement_trigger_mode = VALUES(settlement_trigger_mode), + gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio), + residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms)`, + item.AppCode, + item.ID, + item.Name, + item.RegionID, + item.Status, + item.SettlementMode, + firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), + item.GiftCoinToDiamondRatio, + item.ResidualDiamondToUSDRate, + item.EffectiveFromMS, + item.EffectiveToMS, + nonZeroMS(item.CreatedAtMS, publishedAtMS), + publishedAtMS, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, item.AppCode, item.ID); err != nil { + return err + } + for _, level := range item.Levels { + _, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资") + if err != nil { + return err + } + _, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资") + if err != nil { + return err + } + // 运行表保存美分整数,避免 wallet-service 结算时再解析 decimal 字符串造成重复口径。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO host_agency_salary_policy_levels ( + app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor, + host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + item.AppCode, + item.ID, + level.LevelNo, + level.RequiredDiamonds, + hostSalaryMinor, + level.HostCoinReward, + agencySalaryMinor, + level.Status, + level.SortOrder, + nonZeroMS(level.CreatedAtMS, publishedAtMS), + publishedAtMS, + ); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Service) deleteRuntimePolicy(ctx context.Context, appCode string, policyID uint) error { + if s == nil || s.walletDB == nil { + return errors.New("wallet mysql is not configured") + } + tx, err := s.walletDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policies WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil { + return err + } + return tx.Commit() +} + +func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.HostAgencySalaryPolicy, error) { + name := strings.TrimSpace(req.Name) + if name == "" || len([]rune(name)) > 120 { + return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确") + } + // 当前阶段保持单区域政策:一个区域同一时段只能命中一条 active 规则,后续结算查询也以 region_id 做索引。 + if req.RegionID <= 0 { + return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域") + } + status := normalizeStatus(req.Status) + if status == "" { + // 新建默认停用,避免运营保存草稿时立即影响结算口径。 + status = policyStatusDisabled + } + if !validStatus(status) { + return model.HostAgencySalaryPolicy{}, errors.New("政策状态不正确") + } + settlementMode := normalizeSettlementMode(req.SettlementMode) + if settlementMode == "" { + // 产品当前默认日结;半月结算只是调度节奏差异,等级和差额发放规则相同。 + settlementMode = settlementModeDaily + } + if !validSettlementMode(settlementMode) { + return model.HostAgencySalaryPolicy{}, errors.New("结算方式不正确") + } + settlementTriggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode) + if settlementTriggerMode == "" { + // 默认自动触发,兼容现有政策和现有 cron 日结/半月结任务;需要人工复核时由后台显式切到 manual。 + settlementTriggerMode = settlementTriggerAutomatic + } + if !validSettlementTriggerMode(settlementTriggerMode) { + return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确") + } + if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) { + return model.HostAgencySalaryPolicy{}, errors.New("政策生效时间不正确") + } + // 比例字段用 decimal 字符串入库,避免 float 在金币/钻石/美元换算中产生精度噪声。 + giftRatio := firstNonBlank(req.GiftCoinToDiamondRatio, defaultGiftCoinToDiamondRatio) + giftRatio, _, err := parseFixedDecimal(giftRatio, 6, false, "金币转钻石比例") + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } + residualRate := firstNonBlank(req.ResidualDiamondToUSDRate, defaultResidualDiamondRate) + residualRate, _, err = parseFixedDecimal(residualRate, 12, true, "剩余钻石转美元比例") + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } + description := strings.TrimSpace(req.Description) + if len([]rune(description)) > 255 { + return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符") + } + // 等级表保存的是累计门槛和累计权益;实际发薪时应减去上一已结算等级,只发差额。 + levels, err := levelModelsFromRequest(req.Levels) + if err != nil { + return model.HostAgencySalaryPolicy{}, err + } + return model.HostAgencySalaryPolicy{ + AppCode: appCode, + Name: name, + RegionID: req.RegionID, + Status: status, + SettlementMode: settlementMode, + SettlementTriggerMode: settlementTriggerMode, + GiftCoinToDiamondRatio: giftRatio, + ResidualDiamondToUSDRate: residualRate, + EffectiveFromMS: req.EffectiveFromMS, + EffectiveToMS: req.EffectiveToMS, + Description: description, + CreatedByAdminID: actorID, + UpdatedByAdminID: actorID, + Levels: levels, + }, nil +} + +func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) { + if len(requests) == 0 { + return nil, errors.New("至少需要配置一个等级") + } + // 先按等级号排序再校验,允许前端在编辑过程中临时乱序提交,但最终持久化必须是稳定梯度。 + levels := append([]levelRequest(nil), requests...) + sortLevelRequests(levels) + seen := map[int32]struct{}{} + out := make([]model.HostAgencySalaryLevel, 0, len(levels)) + var previousRequiredDiamonds int64 + var previousHostSalaryCents int64 + var previousHostCoinReward int64 + var previousAgencySalaryCents int64 + for index, req := range levels { + // 等级号是结算差额的顺序锚点,不能为 0,也不能重复。 + if req.Level <= 0 { + return nil, errors.New("等级必须大于 0") + } + if _, ok := seen[req.Level]; ok { + return nil, fmt.Errorf("等级 %d 重复", req.Level) + } + seen[req.Level] = struct{}{} + if req.RequiredDiamonds <= 0 { + return nil, fmt.Errorf("等级 %d 所需钻石必须大于 0", req.Level) + } + // 钻石门槛必须严格递增,否则用户达到高等级时无法确定唯一命中等级。 + if index > 0 && req.RequiredDiamonds <= previousRequiredDiamonds { + return nil, fmt.Errorf("等级 %d 所需钻石必须大于上一等级", req.Level) + } + // 主播美元工资是“累计应得”,后续结算以当前等级累计值减去已结算累计值发差额。 + hostSalary, hostSalaryCents, err := parseFixedDecimal(req.HostSalaryUSD, 2, true, "主播美元工资") + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && hostSalaryCents < previousHostSalaryCents { + return nil, fmt.Errorf("等级 %d 主播美元工资不能小于上一等级", req.Level) + } + if req.HostCoinReward < 0 { + return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于 0", req.Level) + } + // 金币奖励同样是累计配置,不能比上一等级低,否则差额发放会变成负数。 + if index > 0 && req.HostCoinReward < previousHostCoinReward { + return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于上一等级", req.Level) + } + // 代理美元工资跟随主播结算同时发放,也按累计值减已结算累计值计算差额。 + agencySalary, agencySalaryCents, err := parseFixedDecimal(req.AgencySalaryUSD, 2, true, "代理美元工资") + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && agencySalaryCents < previousAgencySalaryCents { + return nil, fmt.Errorf("等级 %d 代理美元工资不能小于上一等级", req.Level) + } + status := normalizeStatus(req.Status) + if status == "" { + // 单个等级默认启用;需要临时屏蔽某个等级时由运营显式停用。 + status = policyStatusActive + } + if !validStatus(status) { + return nil, fmt.Errorf("等级 %d 状态不正确", req.Level) + } + sortOrder := req.SortOrder + if sortOrder == 0 { + // 默认排序跟等级号一致,前端展示和后续结算扫描都能保持自然顺序。 + sortOrder = req.Level + } + out = append(out, model.HostAgencySalaryLevel{ + LevelNo: req.Level, + RequiredDiamonds: req.RequiredDiamonds, + HostSalaryUSD: hostSalary, + HostCoinReward: req.HostCoinReward, + AgencySalaryUSD: agencySalary, + Status: status, + SortOrder: sortOrder, + }) + previousRequiredDiamonds = req.RequiredDiamonds + previousHostSalaryCents = hostSalaryCents + previousHostCoinReward = req.HostCoinReward + previousAgencySalaryCents = agencySalaryCents + } + return out, nil +} + +func sortLevelRequests(items []levelRequest) { + for i := 1; i < len(items); i++ { + item := items[i] + j := i - 1 + for j >= 0 && items[j].Level > item.Level { + items[j+1] = items[j] + j-- + } + items[j+1] = item + } +} + +func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) { + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, "+") { + value = strings.TrimPrefix(value, "+") + } + if value == "" || strings.HasPrefix(value, "-") { + return "", 0, fmt.Errorf("%s不正确", field) + } + parts := strings.Split(value, ".") + if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) { + return "", 0, fmt.Errorf("%s不正确", field) + } + if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) { + return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale) + } + whole, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s过大", field) + } + multiplier := pow10(scale) + if whole > (1<<62)/multiplier { + return "", 0, fmt.Errorf("%s过大", field) + } + var fraction int64 + if len(parts) == 2 { + fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1])) + fraction, err = strconv.ParseInt(fractionText, 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s不正确", field) + } + } + scaled := whole*multiplier + fraction + if !allowZero && scaled <= 0 { + return "", 0, fmt.Errorf("%s必须大于 0", field) + } + return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil +} + +func fixedDigits(value int64, scale int) string { + text := strconv.FormatInt(value, 10) + if len(text) >= scale { + return text + } + return strings.Repeat("0", scale-len(text)) + text +} + +func trimDecimalZeros(value string) string { + value = strings.TrimSpace(value) + if !strings.Contains(value, ".") { + return value + } + value = strings.TrimRight(value, "0") + return strings.TrimRight(value, ".") +} + +func pow10(scale int) int64 { + var out int64 = 1 + for i := 0; i < scale; i++ { + out *= 10 + } + return out +} + +func allDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func nonZeroMS(value int64, fallback int64) int64 { + if value > 0 { + return value + } + return fallback +} + +func truncateRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", policyStatusActive, "enabled": + if strings.TrimSpace(value) == "" { + return "" + } + return policyStatusActive + case policyStatusDisabled, "inactive": + return policyStatusDisabled + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeStatusFilter(value string) string { + status := normalizeStatus(value) + if !validStatus(status) { + return "" + } + return status +} + +func validStatus(value string) bool { + return value == policyStatusActive || value == policyStatusDisabled +} + +func normalizeSettlementMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", settlementModeDaily, "day": + if strings.TrimSpace(value) == "" { + return "" + } + return settlementModeDaily + case settlementModeHalfMonth, "half-month", "semi_month": + return settlementModeHalfMonth + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeSettlementModeFilter(value string) string { + mode := normalizeSettlementMode(value) + if !validSettlementMode(mode) { + return "" + } + return mode +} + +func validSettlementMode(value string) bool { + return value == settlementModeDaily || value == settlementModeHalfMonth +} + +func normalizeSettlementTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", settlementTriggerAutomatic, "auto": + if strings.TrimSpace(value) == "" { + return "" + } + return settlementTriggerAutomatic + case settlementTriggerManual, "manual_settlement": + return settlementTriggerManual + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeSettlementTriggerModeFilter(value string) string { + mode := normalizeSettlementTriggerMode(value) + if !validSettlementTriggerMode(mode) { + return "" + } + return mode +} + +func validSettlementTriggerMode(value string) bool { + return value == settlementTriggerAutomatic || value == settlementTriggerManual +} diff --git a/server/admin/internal/modules/hostagencypolicy/service_test.go b/server/admin/internal/modules/hostagencypolicy/service_test.go new file mode 100644 index 00000000..67c2ab74 --- /dev/null +++ b/server/admin/internal/modules/hostagencypolicy/service_test.go @@ -0,0 +1,95 @@ +package hostagencypolicy + +import "testing" + +func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) { + item, err := policyModelFromRequest("lalu", 7, policyRequest{ + Name: "Lalu 默认政策", + RegionID: 101, + Status: "active", + SettlementMode: "half-month", + SettlementTriggerMode: "manual", + GiftCoinToDiamondRatio: "1", + ResidualDiamondToUSDRate: "0.000001", + Levels: []levelRequest{ + {Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"}, + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err != nil { + t.Fatalf("policy should be valid: %v", err) + } + if item.AppCode != "lalu" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual { + t.Fatalf("policy fields mismatch: %+v", item) + } + if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" { + t.Fatalf("ratio fields mismatch: %+v", item) + } + if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 { + t.Fatalf("levels should be sorted: %+v", item.Levels) + } + if item.Levels[0].HostSalaryUSD != "1.50" || item.Levels[1].AgencySalaryUSD != "0.80" { + t.Fatalf("salary fields should be fixed decimals: %+v", item.Levels) + } +} + +func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid", + RegionID: 101, + SettlementMode: settlementModeDaily, + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"}, + {Level: 2, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err == nil { + t.Fatal("expected non-increasing levels to fail") + } +} + +func TestPolicyModelFromRequestDefaultsSettlementTriggerMode(t *testing.T) { + item, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Default trigger", + RegionID: 101, + SettlementMode: settlementModeDaily, + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err != nil { + t.Fatalf("policy should be valid: %v", err) + } + if item.SettlementTriggerMode != settlementTriggerAutomatic { + t.Fatalf("default trigger mismatch: %+v", item) + } +} + +func TestPolicyModelFromRequestRejectsInvalidSettlementMode(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid", + RegionID: 101, + SettlementMode: "monthly", + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err == nil { + t.Fatal("expected invalid settlement mode to fail") + } +} + +func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + Name: "Invalid", + RegionID: 101, + SettlementMode: settlementModeDaily, + SettlementTriggerMode: "cron", + Levels: []levelRequest{ + {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, + }, + }) + if err == nil { + t.Fatal("expected invalid settlement trigger mode to fail") + } +} diff --git a/server/admin/internal/modules/hostsalarysettlement/dto.go b/server/admin/internal/modules/hostsalarysettlement/dto.go new file mode 100644 index 00000000..698dc167 --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/dto.go @@ -0,0 +1,46 @@ +package hostsalarysettlement + +type userDTO struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +type settlementDTO struct { + SettlementID string `json:"settlement_id"` + CommandID string `json:"command_id"` + TransactionID string `json:"transaction_id"` + SettlementType string `json:"settlement_type"` + UserID string `json:"user_id"` + User userDTO `json:"user"` + AgencyOwnerUserID string `json:"agency_owner_user_id"` + AgencyOwner userDTO `json:"agency_owner"` + CycleKey string `json:"cycle_key"` + PolicyID uint64 `json:"policy_id"` + LevelNo int32 `json:"level_no"` + TotalDiamonds int64 `json:"total_diamonds"` + HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"` + HostSalaryUSDDelta string `json:"host_salary_usd_delta"` + HostCoinRewardDelta int64 `json:"host_coin_reward_delta"` + AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"` + AgencySalaryUSDDelta string `json:"agency_salary_usd_delta"` + ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"` + ResidualUSDDelta string `json:"residual_usd_delta"` + Status string `json:"status"` + Reason string `json:"reason"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type query struct { + Page int + PageSize int + CycleKey string + SettlementType string + Status string + UserID int64 + AgencyOwnerUserID int64 + PolicyID uint64 + StartAtMS int64 + EndAtMS int64 +} diff --git a/server/admin/internal/modules/hostsalarysettlement/handler.go b/server/admin/internal/modules/hostsalarysettlement/handler.go new file mode 100644 index 00000000..ddb4e3f7 --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/handler.go @@ -0,0 +1,115 @@ +package hostsalarysettlement + +import ( + "database/sql" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func New(walletDB *sql.DB, userDB *sql.DB) *Handler { + return &Handler{service: NewService(walletDB, userDB)} +} + +func (h *Handler) List(c *gin.Context) { + req, ok := parseQuery(c) + if !ok { + return + } + items, total, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) + if err != nil { + response.ServerError(c, "获取工资结算记录失败") + return + } + response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) +} + +func parseQuery(c *gin.Context) (query, bool) { + options := shared.ListOptions(c) + userID, ok := optionalInt64(c, "user_id", "userId") + if !ok { + response.BadRequest(c, "主播用户 ID 不正确") + return query{}, false + } + agencyOwnerUserID, ok := optionalInt64(c, "agency_owner_user_id", "agencyOwnerUserId") + if !ok { + response.BadRequest(c, "代理用户 ID 不正确") + return query{}, false + } + policyID, ok := optionalUint64(c, "policy_id", "policyId") + if !ok { + response.BadRequest(c, "政策 ID 不正确") + return query{}, false + } + startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs") + if !ok { + response.BadRequest(c, "开始时间不正确") + return query{}, false + } + endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs") + if !ok { + response.BadRequest(c, "结束时间不正确") + return query{}, false + } + if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS { + response.BadRequest(c, "时间区间不正确") + return query{}, false + } + req := normalizeQuery(query{ + Page: options.Page, + PageSize: options.PageSize, + CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), + SettlementType: normalizeSettlementType(firstQuery(c, "settlement_type", "settlementType")), + Status: normalizeStatus(firstQuery(c, "status")), + UserID: userID, + AgencyOwnerUserID: agencyOwnerUserID, + PolicyID: policyID, + StartAtMS: startAtMS, + EndAtMS: endAtMS, + }) + if rawType := strings.TrimSpace(firstQuery(c, "settlement_type", "settlementType")); rawType != "" && strings.ToLower(rawType) != "all" && req.SettlementType == "" { + response.BadRequest(c, "结算类型不正确") + return query{}, false + } + if rawStatus := strings.TrimSpace(firstQuery(c, "status")); rawStatus != "" && strings.ToLower(rawStatus) != "all" && req.Status == "" { + response.BadRequest(c, "结算状态不正确") + return query{}, false + } + return req, true +} + +func optionalInt64(c *gin.Context, keys ...string) (int64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil && parsed >= 0 +} + +func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseUint(value, 10, 64) + return parsed, err == nil +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/hostsalarysettlement/routes.go b/server/admin/internal/modules/hostsalarysettlement/routes.go new file mode 100644 index 00000000..912f784b --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/routes.go @@ -0,0 +1,15 @@ +package hostsalarysettlement + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + protected.GET("/admin/host-salary-settlements", middleware.RequirePermission("host-salary-settlement:view"), h.List) +} diff --git a/server/admin/internal/modules/hostsalarysettlement/service.go b/server/admin/internal/modules/hostsalarysettlement/service.go new file mode 100644 index 00000000..eecd1483 --- /dev/null +++ b/server/admin/internal/modules/hostsalarysettlement/service.go @@ -0,0 +1,290 @@ +package hostsalarysettlement + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" +) + +const maxPageSize = 100 + +type Service struct { + walletDB *sql.DB + userDB *sql.DB +} + +type userProfile struct { + UserID int64 + DisplayUserID string + Username string + Avatar string +} + +func NewService(walletDB *sql.DB, userDB *sql.DB) *Service { + return &Service{walletDB: walletDB, userDB: userDB} +} + +func (s *Service) List(ctx context.Context, appCode string, req query) ([]settlementDTO, int64, error) { + if s == nil || s.walletDB == nil { + return nil, 0, fmt.Errorf("wallet mysql is not configured") + } + req = normalizeQuery(req) + whereSQL, args := settlementWhere(appCode, req) + + var total int64 + if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM host_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, + cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, + host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta, + status, reason, created_at_ms + FROM host_salary_settlement_records + `+whereSQL+` + ORDER BY created_at_ms DESC, settlement_id DESC + LIMIT ? OFFSET ?`, + append(args, req.PageSize, offset(req.Page, req.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]settlementDTO, 0, req.PageSize) + userIDs := make([]int64, 0, req.PageSize*2) + for rows.Next() { + var item settlementDTO + var userID int64 + var agencyOwnerUserID int64 + if err := rows.Scan( + &item.SettlementID, + &item.CommandID, + &item.TransactionID, + &item.SettlementType, + &userID, + &agencyOwnerUserID, + &item.CycleKey, + &item.PolicyID, + &item.LevelNo, + &item.TotalDiamonds, + &item.HostSalaryUSDMinorDelta, + &item.HostCoinRewardDelta, + &item.AgencySalaryUSDMinorDelta, + &item.ResidualUSDMinorDelta, + &item.Status, + &item.Reason, + &item.CreatedAtMS, + ); err != nil { + return nil, 0, err + } + item.UserID = strconv.FormatInt(userID, 10) + item.AgencyOwnerUserID = formatOptionalUserID(agencyOwnerUserID) + item.User = userDTO{UserID: item.UserID} + item.AgencyOwner = userDTO{UserID: item.AgencyOwnerUserID} + item.HostSalaryUSDDelta = formatUSDMinor(item.HostSalaryUSDMinorDelta) + item.AgencySalaryUSDDelta = formatUSDMinor(item.AgencySalaryUSDMinorDelta) + item.ResidualUSDDelta = formatUSDMinor(item.ResidualUSDMinorDelta) + items = append(items, item) + if userID > 0 { + userIDs = append(userIDs, userID) + } + if agencyOwnerUserID > 0 { + userIDs = append(userIDs, agencyOwnerUserID) + } + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + profiles, err := s.userProfiles(ctx, appCode, userIDs) + if err != nil { + return nil, 0, err + } + for i := range items { + if userID, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil { + items[i].User = userDTOFromProfile(items[i].UserID, profiles[userID]) + } + if agencyID, err := strconv.ParseInt(items[i].AgencyOwnerUserID, 10, 64); err == nil && agencyID > 0 { + items[i].AgencyOwner = userDTOFromProfile(items[i].AgencyOwnerUserID, profiles[agencyID]) + } + } + return items, total, nil +} + +func settlementWhere(appCode string, req query) (string, []any) { + conditions := []string{"app_code = ?"} + args := []any{strings.TrimSpace(appCode)} + if req.CycleKey != "" { + conditions = append(conditions, "cycle_key = ?") + args = append(args, req.CycleKey) + } + if req.SettlementType != "" { + conditions = append(conditions, "settlement_type = ?") + args = append(args, req.SettlementType) + } + if req.Status != "" { + conditions = append(conditions, "status = ?") + args = append(args, req.Status) + } + if req.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, req.UserID) + } + if req.AgencyOwnerUserID > 0 { + conditions = append(conditions, "agency_owner_user_id = ?") + args = append(args, req.AgencyOwnerUserID) + } + if req.PolicyID > 0 { + conditions = append(conditions, "policy_id = ?") + args = append(args, req.PolicyID) + } + if req.StartAtMS > 0 { + conditions = append(conditions, "created_at_ms >= ?") + args = append(args, req.StartAtMS) + } + if req.EndAtMS > 0 { + conditions = append(conditions, "created_at_ms < ?") + args = append(args, req.EndAtMS) + } + // 查询只读 wallet-service 结算事实表,不回推钱包分录;账务对账需要单独走 entries/transactions 明细。 + return "WHERE " + strings.Join(conditions, " AND "), args +} + +func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { + if s == nil || s.userDB == nil || len(userIDs) == 0 { + return map[int64]userProfile{}, nil + } + uniqueIDs := uniquePositiveUserIDs(userIDs) + if len(uniqueIDs) == 0 { + return map[int64]userProfile{}, nil + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",") + args := make([]any, 0, len(uniqueIDs)+1) + args = append(args, strings.TrimSpace(appCode)) + for _, id := range uniqueIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') + FROM users + WHERE app_code = ? AND user_id IN (`+placeholders+`)`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + profiles := make(map[int64]userProfile, len(uniqueIDs)) + for rows.Next() { + var item userProfile + if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil { + return nil, err + } + profiles[item.UserID] = item + } + return profiles, rows.Err() +} + +func normalizeQuery(req query) query { + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.SettlementType = normalizeSettlementType(req.SettlementType) + req.Status = normalizeStatus(req.Status) + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 50 + } + if req.PageSize > maxPageSize { + req.PageSize = maxPageSize + } + return req +} + +func normalizeSettlementType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "daily", "day": + return "daily" + case "half_month", "half-month", "halfmonth": + return "half_month" + case "month_end", "month-end", "monthend": + return "month_end" + default: + return "" + } +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "succeeded", "skipped", "failed": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +func uniquePositiveUserIDs(ids []int64) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func userDTOFromProfile(fallbackID string, profile userProfile) userDTO { + if profile.UserID <= 0 { + return userDTO{UserID: fallbackID} + } + return userDTO{ + UserID: strconv.FormatInt(profile.UserID, 10), + DisplayUserID: profile.DisplayUserID, + Username: profile.Username, + Avatar: profile.Avatar, + } +} + +func formatOptionalUserID(value int64) string { + if value <= 0 { + return "" + } + return strconv.FormatInt(value, 10) +} + +func formatUSDMinor(value int64) string { + sign := "" + if value < 0 { + sign = "-" + value = -value + } + whole := value / 100 + fraction := value % 100 + if fraction == 0 { + return sign + strconv.FormatInt(whole, 10) + } + if fraction < 10 { + return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10) + } + return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10) +} + +func offset(page int, pageSize int) int { + if page <= 1 { + return 0 + } + return (page - 1) * pageSize +} diff --git a/server/admin/internal/modules/redpacket/handler.go b/server/admin/internal/modules/redpacket/handler.go index 8a94c2ac..c67a966c 100644 --- a/server/admin/internal/modules/redpacket/handler.go +++ b/server/admin/internal/modules/redpacket/handler.go @@ -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 == "" { diff --git a/server/admin/internal/modules/redpacket/routes.go b/server/admin/internal/modules/redpacket/routes.go index b87014a7..84fe5eeb 100644 --- a/server/admin/internal/modules/redpacket/routes.go +++ b/server/admin/internal/modules/redpacket/routes.go @@ -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) } diff --git a/server/admin/internal/modules/report/handler.go b/server/admin/internal/modules/report/handler.go new file mode 100644 index 00000000..daf98e6e --- /dev/null +++ b/server/admin/internal/modules/report/handler.go @@ -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 "" +} diff --git a/server/admin/internal/modules/report/request.go b/server/admin/internal/modules/report/request.go new file mode 100644 index 00000000..7c14c2eb --- /dev/null +++ b/server/admin/internal/modules/report/request.go @@ -0,0 +1,11 @@ +package report + +type listQuery struct { + Page int + PageSize int + Keyword string + TargetType string + ReportType string + StartAtMS int64 + EndAtMS int64 +} diff --git a/server/admin/internal/modules/report/response.go b/server/admin/internal/modules/report/response.go new file mode 100644 index 00000000..391772f1 --- /dev/null +++ b/server/admin/internal/modules/report/response.go @@ -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"` +} diff --git a/server/admin/internal/modules/report/routes.go b/server/admin/internal/modules/report/routes.go new file mode 100644 index 00000000..4fb26f3f --- /dev/null +++ b/server/admin/internal/modules/report/routes.go @@ -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) +} diff --git a/server/admin/internal/modules/report/service.go b/server/admin/internal/modules/report/service.go new file mode 100644 index 00000000..46557fee --- /dev/null +++ b/server/admin/internal/modules/report/service.go @@ -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 +} diff --git a/server/admin/internal/modules/report/service_test.go b/server/admin/internal/modules/report/service_test.go new file mode 100644 index 00000000..f357855f --- /dev/null +++ b/server/admin/internal/modules/report/service_test.go @@ -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) + } +} diff --git a/server/admin/internal/modules/teamsalarypolicy/handler.go b/server/admin/internal/modules/teamsalarypolicy/handler.go new file mode 100644 index 00000000..0240d8db --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/handler.go @@ -0,0 +1,121 @@ +package teamsalarypolicy + +import ( + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/repository" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service + audit shared.OperationLogger +} + +func New(store *repository.Store, audit shared.OperationLogger) *Handler { + return &Handler{service: NewService(store), audit: audit} +} + +func (h *Handler) ListPolicies(c *gin.Context) { + options := shared.ListOptions(c) + policyType := firstQuery(c, "policy_type", "policyType") + items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.TeamSalaryPolicyListOptions{ + PolicyType: policyType, + Keyword: options.Keyword, + RegionID: queryInt64(c, "region_id", "regionId"), + Status: options.Status, + SettlementTriggerMode: firstQuery(c, "settlement_trigger_mode", "settlementTriggerMode"), + Page: options.Page, + PageSize: options.PageSize, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) +} + +func (h *Handler) CreatePolicy(c *gin.Context) { + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + item, err := h.service.Create(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "create-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("policy_type=%s region_id=%d", item.PolicyType, item.RegionID)) + response.Created(c, item) +} + +func (h *Handler) UpdatePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + var req policyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "工资政策参数不正确") + return + } + item, err := h.service.Update(appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, id, req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "update-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(item.ID), 10), "success", fmt.Sprintf("policy_type=%s region_id=%d", item.PolicyType, item.RegionID)) + response.OK(c, item) +} + +func (h *Handler) DeletePolicy(c *gin.Context) { + id, ok := policyID(c) + if !ok { + return + } + policyType := firstQuery(c, "policy_type", "policyType") + if err := h.service.Delete(appctx.FromContext(c.Request.Context()), policyType, id); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "delete-team-salary-policy", "admin_team_salary_policies", strconv.FormatUint(uint64(id), 10), "success", fmt.Sprintf("policy_type=%s", normalizePolicyType(policyType))) + response.OK(c, gin.H{"deleted": true}) +} + +func policyID(c *gin.Context) (uint, bool) { + value := strings.TrimSpace(c.Param("policy_id")) + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 { + response.BadRequest(c, "工资政策 ID 不正确") + return 0, false + } + return uint(parsed), true +} + +func queryInt64(c *gin.Context, keys ...string) int64 { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0 + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/teamsalarypolicy/request.go b/server/admin/internal/modules/teamsalarypolicy/request.go new file mode 100644 index 00000000..a3c05a26 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/request.go @@ -0,0 +1,22 @@ +package teamsalarypolicy + +type policyRequest struct { + PolicyType string `json:"policy_type"` + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + Levels []levelRequest `json:"levels"` +} + +type levelRequest struct { + Level int32 `json:"level"` + ThresholdUSD string `json:"threshold_usd"` + RatePercent string `json:"rate_percent"` + SalaryUSD string `json:"salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` +} diff --git a/server/admin/internal/modules/teamsalarypolicy/response.go b/server/admin/internal/modules/teamsalarypolicy/response.go new file mode 100644 index 00000000..b0c888cd --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/response.go @@ -0,0 +1,74 @@ +package teamsalarypolicy + +import "hyapp-admin-server/internal/model" + +type policyDTO struct { + ID uint `json:"id"` + AppCode string `json:"app_code"` + PolicyType string `json:"policy_type"` + Name string `json:"name"` + RegionID int64 `json:"region_id"` + Status string `json:"status"` + SettlementTriggerMode string `json:"settlement_trigger_mode"` + EffectiveFromMS int64 `json:"effective_from_ms"` + EffectiveToMS int64 `json:"effective_to_ms"` + Description string `json:"description"` + CreatedByAdminID uint `json:"created_by_admin_id"` + UpdatedByAdminID uint `json:"updated_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Levels []levelDTO `json:"levels"` +} + +type levelDTO struct { + ID uint `json:"id"` + PolicyID uint `json:"policy_id"` + Level int32 `json:"level"` + ThresholdUSD string `json:"threshold_usd"` + RatePercent string `json:"rate_percent"` + SalaryUSD string `json:"salary_usd"` + Status string `json:"status"` + SortOrder int32 `json:"sort_order"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` +} + +func policyFromModel(item model.TeamSalaryPolicy) policyDTO { + levels := make([]levelDTO, 0, len(item.Levels)) + for _, level := range item.Levels { + levels = append(levels, levelFromModel(level)) + } + // 响应层去掉 decimal 尾零,前端输入框仍以字符串处理金额和百分比,避免 JS 浮点误差。 + return policyDTO{ + ID: item.ID, + AppCode: item.AppCode, + PolicyType: item.PolicyType, + Name: item.Name, + RegionID: item.RegionID, + Status: item.Status, + SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), + EffectiveFromMS: item.EffectiveFromMS, + EffectiveToMS: item.EffectiveToMS, + Description: item.Description, + CreatedByAdminID: item.CreatedByAdminID, + UpdatedByAdminID: item.UpdatedByAdminID, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + Levels: levels, + } +} + +func levelFromModel(item model.TeamSalaryLevel) levelDTO { + return levelDTO{ + ID: item.ID, + PolicyID: item.PolicyID, + Level: item.LevelNo, + ThresholdUSD: trimDecimalZeros(item.ThresholdUSD), + RatePercent: trimDecimalZeros(item.RatePercent), + SalaryUSD: trimDecimalZeros(item.SalaryUSD), + Status: item.Status, + SortOrder: item.SortOrder, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, + } +} diff --git a/server/admin/internal/modules/teamsalarypolicy/routes.go b/server/admin/internal/modules/teamsalarypolicy/routes.go new file mode 100644 index 00000000..b5401d02 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/routes.go @@ -0,0 +1,17 @@ +package teamsalarypolicy + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + protected.GET("/admin/team-salary-policies", middleware.RequirePermission("team-salary-policy:view"), h.ListPolicies) + protected.POST("/admin/team-salary-policies", middleware.RequirePermission("team-salary-policy:create"), h.CreatePolicy) + protected.PUT("/admin/team-salary-policies/:policy_id", middleware.RequirePermission("team-salary-policy:update"), h.UpdatePolicy) + protected.DELETE("/admin/team-salary-policies/:policy_id", middleware.RequirePermission("team-salary-policy:delete"), h.DeletePolicy) +} diff --git a/server/admin/internal/modules/teamsalarypolicy/service.go b/server/admin/internal/modules/teamsalarypolicy/service.go new file mode 100644 index 00000000..d69bd1c7 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/service.go @@ -0,0 +1,406 @@ +package teamsalarypolicy + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/repository" +) + +const ( + policyTypeBD = "bd" + policyTypeAdmin = "admin" + + policyStatusActive = "active" + policyStatusDisabled = "disabled" + + settlementTriggerAutomatic = "automatic" + settlementTriggerManual = "manual" +) + +type Service struct { + store *repository.Store +} + +func NewService(store *repository.Store) *Service { + return &Service{store: store} +} + +func (s *Service) List(appCode string, options repository.TeamSalaryPolicyListOptions) ([]policyDTO, int64, error) { + options.AppCode = appctx.Normalize(appCode) + options.PolicyType = normalizePolicyType(options.PolicyType) + if options.PolicyType == "" { + return nil, 0, errors.New("政策类型不正确") + } + options.Status = normalizeStatusFilter(options.Status) + options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode) + items, total, err := s.store.ListTeamSalaryPolicies(options) + if err != nil { + return nil, 0, err + } + out := make([]policyDTO, 0, len(items)) + for _, item := range items { + out = append(out, policyFromModel(item)) + } + return out, total, nil +} + +func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) { + item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req) + if err != nil { + return policyDTO{}, err + } + if err := s.ensureNoActiveOverlap(item, 0); err != nil { + return policyDTO{}, err + } + if err := s.store.CreateTeamSalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Update(appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) { + policyType := normalizePolicyType(req.PolicyType) + if policyType == "" { + return policyDTO{}, errors.New("政策类型不正确") + } + item, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id) + if err != nil { + return policyDTO{}, err + } + updated, err := policyModelFromRequest(item.AppCode, actorID, req) + if err != nil { + return policyDTO{}, err + } + item.Name = updated.Name + item.RegionID = updated.RegionID + item.Status = updated.Status + item.SettlementTriggerMode = updated.SettlementTriggerMode + item.EffectiveFromMS = updated.EffectiveFromMS + item.EffectiveToMS = updated.EffectiveToMS + item.Description = updated.Description + item.UpdatedByAdminID = actorID + item.Levels = updated.Levels + if err := s.ensureNoActiveOverlap(item, id); err != nil { + return policyDTO{}, err + } + if err := s.store.UpdateTeamSalaryPolicy(&item); err != nil { + return policyDTO{}, err + } + return policyFromModel(item), nil +} + +func (s *Service) Delete(appCode string, policyType string, id uint) error { + policyType = normalizePolicyType(policyType) + if policyType == "" { + return errors.New("政策类型不正确") + } + if _, err := s.store.GetTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id); err != nil { + return err + } + return s.store.DeleteTeamSalaryPolicy(appctx.Normalize(appCode), policyType, id) +} + +func (s *Service) ensureNoActiveOverlap(item model.TeamSalaryPolicy, excludeID uint) error { + if item.Status != policyStatusActive { + return nil + } + overlap, err := s.store.HasOverlappingActiveTeamSalaryPolicy(item.AppCode, item.PolicyType, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID) + if err != nil { + return err + } + if overlap { + return errors.New("同一区域同一时间只能存在一个启用政策") + } + return nil +} + +func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.TeamSalaryPolicy, error) { + policyType := normalizePolicyType(req.PolicyType) + if policyType == "" { + return model.TeamSalaryPolicy{}, errors.New("政策类型不正确") + } + name := strings.TrimSpace(req.Name) + if name == "" || len([]rune(name)) > 120 { + return model.TeamSalaryPolicy{}, errors.New("政策名称不正确") + } + if req.RegionID <= 0 { + return model.TeamSalaryPolicy{}, errors.New("请选择适用区域") + } + status := normalizeStatus(req.Status) + if status == "" { + // BD/Admin 政策默认先保存为停用,运营确认等级表无误后再启用,避免自动结算读到半成品。 + status = policyStatusDisabled + } + if !validStatus(status) { + return model.TeamSalaryPolicy{}, errors.New("政策状态不正确") + } + triggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode) + if triggerMode == "" { + triggerMode = settlementTriggerAutomatic + } + if !validSettlementTriggerMode(triggerMode) { + return model.TeamSalaryPolicy{}, errors.New("结算触发方式不正确") + } + if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) { + return model.TeamSalaryPolicy{}, errors.New("政策生效时间不正确") + } + description := strings.TrimSpace(req.Description) + if len([]rune(description)) > 255 { + return model.TeamSalaryPolicy{}, errors.New("备注不能超过 255 个字符") + } + levels, err := levelModelsFromRequest(policyType, req.Levels) + if err != nil { + return model.TeamSalaryPolicy{}, err + } + return model.TeamSalaryPolicy{ + AppCode: appCode, + PolicyType: policyType, + Name: name, + RegionID: req.RegionID, + Status: status, + SettlementTriggerMode: triggerMode, + EffectiveFromMS: req.EffectiveFromMS, + EffectiveToMS: req.EffectiveToMS, + Description: description, + CreatedByAdminID: actorID, + UpdatedByAdminID: actorID, + Levels: levels, + }, nil +} + +func levelModelsFromRequest(policyType string, requests []levelRequest) ([]model.TeamSalaryLevel, error) { + if len(requests) == 0 { + return nil, errors.New("至少需要配置一个等级") + } + levels := append([]levelRequest(nil), requests...) + sortLevelRequests(levels) + seen := map[int32]struct{}{} + out := make([]model.TeamSalaryLevel, 0, len(levels)) + var previousThresholdCents int64 + var previousSalaryCents int64 + for index, req := range levels { + if req.Level <= 0 { + return nil, errors.New("等级必须大于 0") + } + if _, ok := seen[req.Level]; ok { + return nil, fmt.Errorf("等级 %d 重复", req.Level) + } + seen[req.Level] = struct{}{} + threshold, thresholdCents, err := parseFixedDecimal(req.ThresholdUSD, 2, false, thresholdFieldName(policyType)) + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && thresholdCents <= previousThresholdCents { + return nil, fmt.Errorf("等级 %d 收入门槛必须大于上一等级", req.Level) + } + // rate_percent 保存百分比文本,例如 8 表示 8%;允许 4 位小数应对后续更细比例。 + rate, _, err := parseFixedDecimal(req.RatePercent, 4, false, "提成比例") + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + salary, salaryCents, err := parseFixedDecimal(req.SalaryUSD, 2, true, salaryFieldName(policyType)) + if err != nil { + return nil, fmt.Errorf("等级 %d %w", req.Level, err) + } + if index > 0 && salaryCents < previousSalaryCents { + return nil, fmt.Errorf("等级 %d 累计工资不能小于上一等级", req.Level) + } + status := normalizeStatus(req.Status) + if status == "" { + status = policyStatusActive + } + if !validStatus(status) { + return nil, fmt.Errorf("等级 %d 状态不正确", req.Level) + } + sortOrder := req.SortOrder + if sortOrder == 0 { + sortOrder = req.Level + } + out = append(out, model.TeamSalaryLevel{ + LevelNo: req.Level, + ThresholdUSD: threshold, + RatePercent: rate, + SalaryUSD: salary, + Status: status, + SortOrder: sortOrder, + }) + previousThresholdCents = thresholdCents + previousSalaryCents = salaryCents + } + return out, nil +} + +func thresholdFieldName(policyType string) string { + if policyType == policyTypeAdmin { + return "BD工资门槛" + } + return "下属主播工资门槛" +} + +func salaryFieldName(policyType string) string { + if policyType == policyTypeAdmin { + return "Admin工资" + } + return "BD工资" +} + +func sortLevelRequests(items []levelRequest) { + for i := 1; i < len(items); i++ { + item := items[i] + j := i - 1 + for j >= 0 && items[j].Level > item.Level { + items[j+1] = items[j] + j-- + } + items[j+1] = item + } +} + +func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) { + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, "+") { + value = strings.TrimPrefix(value, "+") + } + if value == "" || strings.HasPrefix(value, "-") { + return "", 0, fmt.Errorf("%s不正确", field) + } + parts := strings.Split(value, ".") + if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) { + return "", 0, fmt.Errorf("%s不正确", field) + } + if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) { + return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale) + } + whole, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s过大", field) + } + multiplier := pow10(scale) + if whole > (1<<62)/multiplier { + return "", 0, fmt.Errorf("%s过大", field) + } + var fraction int64 + if len(parts) == 2 { + fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1])) + fraction, err = strconv.ParseInt(fractionText, 10, 64) + if err != nil { + return "", 0, fmt.Errorf("%s不正确", field) + } + } + scaled := whole*multiplier + fraction + if !allowZero && scaled <= 0 { + return "", 0, fmt.Errorf("%s必须大于 0", field) + } + return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil +} + +func fixedDigits(value int64, scale int) string { + text := strconv.FormatInt(value, 10) + if len(text) >= scale { + return text + } + return strings.Repeat("0", scale-len(text)) + text +} + +func trimDecimalZeros(value string) string { + value = strings.TrimSpace(value) + if !strings.Contains(value, ".") { + return value + } + value = strings.TrimRight(value, "0") + return strings.TrimRight(value, ".") +} + +func pow10(scale int) int64 { + var out int64 = 1 + for i := 0; i < scale; i++ { + out *= 10 + } + return out +} + +func allDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func normalizePolicyType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case policyTypeBD: + return policyTypeBD + case policyTypeAdmin, "super_admin", "super-admin": + return policyTypeAdmin + default: + return "" + } +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", policyStatusActive, "enabled": + if strings.TrimSpace(value) == "" { + return "" + } + return policyStatusActive + case policyStatusDisabled, "inactive": + return policyStatusDisabled + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeStatusFilter(value string) string { + status := normalizeStatus(value) + if !validStatus(status) { + return "" + } + return status +} + +func validStatus(value string) bool { + return value == policyStatusActive || value == policyStatusDisabled +} + +func normalizeSettlementTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", settlementTriggerAutomatic, "auto": + if strings.TrimSpace(value) == "" { + return "" + } + return settlementTriggerAutomatic + case settlementTriggerManual, "manual_settlement": + return settlementTriggerManual + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func normalizeSettlementTriggerModeFilter(value string) string { + mode := normalizeSettlementTriggerMode(value) + if !validSettlementTriggerMode(mode) { + return "" + } + return mode +} + +func validSettlementTriggerMode(value string) bool { + return value == settlementTriggerAutomatic || value == settlementTriggerManual +} diff --git a/server/admin/internal/modules/teamsalarypolicy/service_test.go b/server/admin/internal/modules/teamsalarypolicy/service_test.go new file mode 100644 index 00000000..6b6ef457 --- /dev/null +++ b/server/admin/internal/modules/teamsalarypolicy/service_test.go @@ -0,0 +1,58 @@ +package teamsalarypolicy + +import "testing" + +func TestPolicyModelFromRequestBuildsBDLevels(t *testing.T) { + item, err := policyModelFromRequest("lalu", 7, policyRequest{ + PolicyType: "bd", + Name: "BD 默认政策", + RegionID: 101, + Status: "active", + SettlementTriggerMode: "manual", + Levels: []levelRequest{ + {Level: 2, ThresholdUSD: "200", RatePercent: "8", SalaryUSD: "16"}, + {Level: 1, ThresholdUSD: "100", RatePercent: "8", SalaryUSD: "8"}, + }, + }) + if err != nil { + t.Fatalf("policy should be valid: %v", err) + } + if item.PolicyType != policyTypeBD || item.CreatedByAdminID != 7 || item.SettlementTriggerMode != settlementTriggerManual { + t.Fatalf("policy fields mismatch: %+v", item) + } + if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].ThresholdUSD != "200.00" { + t.Fatalf("levels should be sorted and normalized: %+v", item.Levels) + } + if item.Levels[0].RatePercent != "8.0000" || item.Levels[1].SalaryUSD != "16.00" { + t.Fatalf("level decimals mismatch: %+v", item.Levels) + } +} + +func TestPolicyModelFromRequestRejectsNonIncreasingThreshold(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + PolicyType: "admin", + Name: "Invalid", + RegionID: 101, + Levels: []levelRequest{ + {Level: 1, ThresholdUSD: "500", RatePercent: "20", SalaryUSD: "100"}, + {Level: 2, ThresholdUSD: "200", RatePercent: "20", SalaryUSD: "200"}, + }, + }) + if err == nil { + t.Fatal("expected non-increasing threshold to fail") + } +} + +func TestPolicyModelFromRequestRejectsInvalidPolicyType(t *testing.T) { + _, err := policyModelFromRequest("lalu", 1, policyRequest{ + PolicyType: "host", + Name: "Invalid", + RegionID: 101, + Levels: []levelRequest{ + {Level: 1, ThresholdUSD: "100", RatePercent: "8", SalaryUSD: "8"}, + }, + }) + if err == nil { + t.Fatal("expected invalid policy type to fail") + } +} diff --git a/server/admin/internal/modules/teamsalarysettlement/auto_runner.go b/server/admin/internal/modules/teamsalarysettlement/auto_runner.go new file mode 100644 index 00000000..0bc5240e --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/auto_runner.go @@ -0,0 +1,98 @@ +package teamsalarysettlement + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" +) + +const autoRunnerInterval = time.Hour + +// AutoRunner 是 admin-server 内的轻量自动结算循环;真正的幂等不靠 runner,而靠 wallet 结算进度表。 +type AutoRunner struct { + service *Service + appCode string + worker string + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewAutoRunner 只保存运行参数,不立刻启动 goroutine,便于 main 按 jobs.enabled 控制生命周期。 +func NewAutoRunner(service *Service, appCode string, worker string) *AutoRunner { + return &AutoRunner{ + service: service, + appCode: normalizeAppCode(appCode), + worker: strings.TrimSpace(worker), + } +} + +// Start 启动后台循环;重复调用不创建第二个 worker,避免同进程内重复扫描。 +func (r *AutoRunner) Start() { + if r == nil || r.service == nil || r.cancel != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + r.wg.Add(1) + go r.loop(ctx) +} + +// Close 停止后台循环并等待退出,保证 admin-server 优雅关闭时不会截断正在执行的事务。 +func (r *AutoRunner) Close() { + if r == nil || r.cancel == nil { + return + } + r.cancel() + r.wg.Wait() + r.cancel = nil +} + +// loop 启动后先立即检查一次,再按小时检查;是否真正发薪由 RunAutomaticDue 按日期判断。 +func (r *AutoRunner) loop(ctx context.Context) { + defer r.wg.Done() + r.runOnce(ctx, time.Now()) + ticker := time.NewTicker(autoRunnerInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + r.runOnce(ctx, now) + } + } +} + +// runOnce 给单次自动结算设置超时,避免数据库异常时 goroutine 永久卡住。 +func (r *AutoRunner) runOnce(parent context.Context, now time.Time) { + ctx, cancel := context.WithTimeout(parent, 10*time.Minute) + defer cancel() + // 多节点同时触发时不单独抢锁;结算进度行和 command_id 唯一索引会保证同一周期同一用户只发一次。 + result, err := r.service.RunAutomaticDue(ctx, r.appCode, now) + if err != nil { + slog.Error("team_salary_auto_settlement_failed", "worker", r.worker, "error", err) + return + } + if !result.Due { + return + } + slog.Info("team_salary_auto_settlement_completed", + "worker", r.worker, + "cycle_key", result.CycleKey, + "bd_success", result.Results[policyTypeBD].SuccessCount, + "bd_skipped", result.Results[policyTypeBD].SkippedCount, + "admin_success", result.Results[policyTypeAdmin].SuccessCount, + "admin_skipped", result.Results[policyTypeAdmin].SkippedCount, + ) +} + +// normalizeAppCode 兜底默认应用编码,保证自动任务和后台手动入口都使用同一 app 维度。 +func normalizeAppCode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "lalu" + } + return value +} diff --git a/server/admin/internal/modules/teamsalarysettlement/dto.go b/server/admin/internal/modules/teamsalarysettlement/dto.go new file mode 100644 index 00000000..62319abc --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/dto.go @@ -0,0 +1,102 @@ +package teamsalarysettlement + +type userDTO struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +type pendingDTO struct { + PolicyType string `json:"policy_type"` + UserID string `json:"user_id"` + User userDTO `json:"user"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + RegionName string `json:"region_name"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + LevelNo int32 `json:"level_no"` + IncomeUSDMinor int64 `json:"income_usd_minor"` + IncomeUSD string `json:"income_usd"` + SalaryUSDMinorTarget int64 `json:"salary_usd_minor_target"` + SalaryUSDTarget string `json:"salary_usd_target"` + SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"` + SalaryUSDDelta string `json:"salary_usd_delta"` + SettledLevelNo int32 `json:"settled_level_no"` + SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"` + SettledSalaryUSD string `json:"settled_salary_usd"` + LastSettledIncomeUSDMinor int64 `json:"last_settled_income_usd_minor"` + LastSettledIncomeUSD string `json:"last_settled_income_usd"` + MonthClosedAtMS int64 `json:"month_closed_at_ms"` +} + +type recordDTO struct { + SettlementID string `json:"settlement_id"` + CommandID string `json:"command_id"` + TransactionID string `json:"transaction_id"` + PolicyType string `json:"policy_type"` + TriggerMode string `json:"trigger_mode"` + UserID string `json:"user_id"` + User userDTO `json:"user"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + PolicyID uint64 `json:"policy_id"` + LevelNo int32 `json:"level_no"` + IncomeUSDMinor int64 `json:"income_usd_minor"` + IncomeUSD string `json:"income_usd"` + SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"` + SalaryUSDDelta string `json:"salary_usd_delta"` + Status string `json:"status"` + Reason string `json:"reason"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type pendingQuery struct { + Page int + PageSize int + PolicyType string + TriggerMode string + CycleKey string + RegionID int64 + CountryID int64 + CountryCode string + UserID int64 +} + +type recordQuery struct { + Page int + PageSize int + PolicyType string + TriggerMode string + CycleKey string + Status string + RegionID int64 + CountryID int64 + CountryCode string + UserID int64 + PolicyID uint64 + StartAtMS int64 + EndAtMS int64 +} + +type settleRequest struct { + PolicyType string `json:"policy_type"` + TriggerMode string `json:"trigger_mode"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + CountryID int64 `json:"country_id"` + CountryCode string `json:"country_code"` + UserIDs []int64 `json:"user_ids"` +} + +type settleResultDTO struct { + PolicyType string `json:"policy_type"` + CycleKey string `json:"cycle_key"` + RequestedCount int `json:"requested_count"` + ProcessedCount int `json:"processed_count"` + SuccessCount int `json:"success_count"` + SkippedCount int `json:"skipped_count"` + FailureCount int `json:"failure_count"` + Records []recordDTO `json:"records"` +} diff --git a/server/admin/internal/modules/teamsalarysettlement/handler.go b/server/admin/internal/modules/teamsalarysettlement/handler.go new file mode 100644 index 00000000..1f1adaad --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/handler.go @@ -0,0 +1,209 @@ +package teamsalarysettlement + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + audit shared.OperationLogger + service *Service +} + +// New 只做 HTTP 层组装,具体工资口径全部下沉到 Service,避免 handler 里复制账务逻辑。 +func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler { + return &Handler{audit: audit, service: NewService(adminDB, walletDB, userDB)} +} + +// Service 暴露给自动任务 runner 复用同一个结算引擎,保证手动和自动结算走同一套规则。 +func (h *Handler) Service() *Service { + if h == nil { + return nil + } + return h.service +} + +// ListPending 返回当前筛选条件下可产生正向工资差值的 BD/Admin 用户。 +func (h *Handler) ListPending(c *gin.Context) { + req, ok := parsePendingQuery(c) + if !ok { + return + } + items, total, err := h.service.ListPending(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) + if err != nil { + response.ServerError(c, "获取待结算工资失败") + return + } + response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) +} + +// ListRecords 返回已经生成 settlement_id 的 BD/Admin 结算记录,用于后台查账。 +func (h *Handler) ListRecords(c *gin.Context) { + req, ok := parseRecordQuery(c) + if !ok { + return + } + items, total, err := h.service.ListRecords(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) + if err != nil { + response.ServerError(c, "获取 BD/Admin 结算记录失败") + return + } + response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) +} + +// Settle 执行后台批量手动结算;前端传入 user_ids 后,Service 会再次按候选白名单过滤。 +func (h *Handler) Settle(c *gin.Context) { + var req settleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "结算参数不正确") + return + } + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) + if req.PolicyType == "" { + response.BadRequest(c, "工资类型不正确") + return + } + if req.TriggerMode == "" { + response.BadRequest(c, "触发方式不正确") + return + } + result, err := h.service.Settle(c.Request.Context(), appctx.FromContext(c.Request.Context()), int64(shared.ActorFromContext(c).UserID), req) + if err != nil { + response.ServerError(c, "工资结算失败") + return + } + shared.OperationLog(c, h.audit, "settle-team-salary", "team_salary_settlement_records", "success", fmt.Sprintf("policy_type=%s cycle_key=%s success=%d skipped=%d failed=%d", result.PolicyType, result.CycleKey, result.SuccessCount, result.SkippedCount, result.FailureCount)) + response.OK(c, result) +} + +// parsePendingQuery 兼容 snake_case/camelCase 查询参数,便于前端直接传 API DTO 或内部模型字段。 +func parsePendingQuery(c *gin.Context) (pendingQuery, bool) { + options := shared.ListOptions(c) + req := pendingQuery{ + Page: options.Page, + PageSize: options.PageSize, + PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")), + TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), + CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), + CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), + } + var ok bool + if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { + response.BadRequest(c, "区域 ID 不正确") + return pendingQuery{}, false + } + if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok { + response.BadRequest(c, "国家 ID 不正确") + return pendingQuery{}, false + } + if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok { + response.BadRequest(c, "用户 ID 不正确") + return pendingQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" { + response.BadRequest(c, "工资类型不正确") + return pendingQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" { + response.BadRequest(c, "触发方式不正确") + return pendingQuery{}, false + } + return normalizePendingQuery(req), true +} + +// parseRecordQuery 统一记录列表的分页、枚举和时间区间校验,避免把非法筛选传到 SQL 组装层。 +func parseRecordQuery(c *gin.Context) (recordQuery, bool) { + options := shared.ListOptions(c) + req := recordQuery{ + Page: options.Page, + PageSize: options.PageSize, + PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")), + TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), + CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), + Status: normalizeStatus(firstQuery(c, "status")), + CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), + } + var ok bool + if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { + response.BadRequest(c, "区域 ID 不正确") + return recordQuery{}, false + } + if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok { + response.BadRequest(c, "国家 ID 不正确") + return recordQuery{}, false + } + if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok { + response.BadRequest(c, "用户 ID 不正确") + return recordQuery{}, false + } + if req.PolicyID, ok = optionalUint64(c, "policy_id", "policyId"); !ok { + response.BadRequest(c, "政策 ID 不正确") + return recordQuery{}, false + } + if req.StartAtMS, ok = optionalInt64(c, "start_at_ms", "startAtMs"); !ok { + response.BadRequest(c, "开始时间不正确") + return recordQuery{}, false + } + if req.EndAtMS, ok = optionalInt64(c, "end_at_ms", "endAtMs"); !ok { + response.BadRequest(c, "结束时间不正确") + return recordQuery{}, false + } + if req.StartAtMS > 0 && req.EndAtMS > 0 && req.StartAtMS >= req.EndAtMS { + response.BadRequest(c, "时间区间不正确") + return recordQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" { + response.BadRequest(c, "工资类型不正确") + return recordQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" { + response.BadRequest(c, "触发方式不正确") + return recordQuery{}, false + } + if raw := strings.TrimSpace(firstQuery(c, "status")); raw != "" && strings.ToLower(raw) != "all" && req.Status == "" { + response.BadRequest(c, "状态不正确") + return recordQuery{}, false + } + return normalizeRecordQuery(req), true +} + +// optionalInt64 解析可选正整数查询参数;空值代表不筛选。 +func optionalInt64(c *gin.Context, keys ...string) (int64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil && parsed >= 0 +} + +// optionalUint64 解析可选无符号整数,当前用于 policy_id。 +func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) { + value := strings.TrimSpace(firstQuery(c, keys...)) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseUint(value, 10, 64) + return parsed, err == nil +} + +// firstQuery 按优先级读取同义查询参数,避免每个字段重复写 snake/camel fallback。 +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/teamsalarysettlement/routes.go b/server/admin/internal/modules/teamsalarysettlement/routes.go new file mode 100644 index 00000000..233f4844 --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/routes.go @@ -0,0 +1,18 @@ +package teamsalarysettlement + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + // 三个接口共用“工资结算”菜单权限:view 看待结算/记录,settle 才能真正写钱包和结算记录。 + protected.GET("/admin/team-salary-settlements/pending", middleware.RequirePermission("host-salary-settlement:view"), h.ListPending) + protected.POST("/admin/team-salary-settlements/settle", middleware.RequirePermission("host-salary-settlement:settle"), h.Settle) + protected.GET("/admin/team-salary-settlements/records", middleware.RequirePermission("host-salary-settlement:view"), h.ListRecords) +} diff --git a/server/admin/internal/modules/teamsalarysettlement/service.go b/server/admin/internal/modules/teamsalarysettlement/service.go new file mode 100644 index 00000000..005a3805 --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/service.go @@ -0,0 +1,1457 @@ +package teamsalarysettlement + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "sort" + "strconv" + "strings" + "time" +) + +const ( + // maxPageSize 限制后台列表单次拉取规模,避免待结算计算在页面误传大 page_size 时拖垮三个库的聚合查询。 + maxPageSize = 100 + policyTypeBD = "bd" + policyTypeAdmin = "admin" + triggerAutomatic = "automatic" + triggerManual = "manual" + // assetUSDBalance 必须与 wallet-service 钱包资产类型保持一致,BD/Admin 工资直接入美元余额账户。 + assetUSDBalance = "USD_BALANCE" + bizTypeTeamSettlement = "team_salary_settlement" + statusSucceeded = "succeeded" +) + +// autoSettlementResult 是自动任务内部的聚合结果;它不直接暴露给前端,只用于日志和测试断言。 +type autoSettlementResult struct { + Due bool `json:"due"` + CycleKey string `json:"cycle_key"` + Results map[string]settleResultDTO `json:"results"` +} + +// Service 聚合 admin/user/wallet 三个库:政策在 admin,组织关系在 user,结算进度与钱包流水在 wallet。 +type Service struct { + adminDB *sql.DB + walletDB *sql.DB + userDB *sql.DB +} + +// teamPolicy 是结算时使用的政策快照;工资按等级累计值发差值,因此必须携带完整等级表。 +type teamPolicy struct { + PolicyID uint64 + PolicyType string + Name string + RegionID int64 + TriggerMode string + Levels []teamPolicyLevel +} + +// teamPolicyLevel 中金额统一转为美分整数,避免 decimal 字符串在计算差值时出现浮点误差。 +type teamPolicyLevel struct { + LevelNo int + ThresholdMinor int64 + SalaryMinor int64 +} + +// teamProgress 是幂等核心:同一用户同一周期只记录“已发到哪个累计工资”,重复结算自然得到 0 差值。 +type teamProgress struct { + SettledLevelNo int + SettledSalaryUSDMinor int64 + LastSettledIncomeUSDMinor int64 + LastPolicyID uint64 + MonthClosedAtMS int64 + Version int64 +} + +// teamCandidate 是待结算候选人,已经完成收入聚合、区域归属、政策匹配和差值计算。 +type teamCandidate struct { + PolicyType string + UserID int64 + CycleKey string + RegionID int64 + RegionName string + Policy teamPolicy + Level teamPolicyLevel + IncomeUSDMinor int64 + Progress teamProgress + SalaryUSDMinorDelta int64 +} + +// settlementMetadata 被同时写入钱包交易和 outbox,后续查账可以从任一钱包流水反查政策、周期、档位和操作者。 +type settlementMetadata struct { + AppCode string `json:"app_code"` + PolicyType string `json:"policy_type"` + TriggerMode string `json:"trigger_mode"` + UserID int64 `json:"user_id"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + LevelNo int `json:"level_no"` + IncomeUSDMinor int64 `json:"income_usd_minor"` + SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"` + SettledLevelNo int `json:"settled_level_no"` + SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"` + ProcessedAtMS int64 `json:"processed_at_ms"` + OperatorAdminID int64 `json:"operator_admin_id"` +} + +// userProfile 只用于后台展示,结算主链路永远以 user_id 为准,避免昵称或短 ID 变更影响账务。 +type userProfile struct { + UserID int64 + DisplayUserID string + Username string + Avatar string +} + +// NewService 注入三个数据库连接;调用方负责连接生命周期,Service 只做业务编排。 +func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service { + return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB} +} + +// ListPending 计算当前筛选条件下“现在结算会产生正向工资差值”的 BD/Admin 列表。 +func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQuery) ([]pendingDTO, int64, error) { + if err := s.ensureReady(ctx); err != nil { + return nil, 0, err + } + req = normalizePendingQuery(req) + // 待结算列表复用结算候选计算,保证运营看到的金额与真正点击结算时的金额口径一致。 + candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, req.UserID, time.Now().UTC().UnixMilli()) + if err != nil { + return nil, 0, err + } + // 待结算列表只展示有正向差额的用户;未达档或已结清用户留在记录/进度表里,避免运营批量操作时出现 0 金额行。 + pending := make([]teamCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.SalaryUSDMinorDelta > 0 { + pending = append(pending, candidate) + } + } + sortTeamCandidates(pending) + total := int64(len(pending)) + pageItems := paginateCandidates(pending, req.Page, req.PageSize) + // 用户信息只按分页后的结果加载,避免大区域待结算列表一次性扫全量用户资料。 + profiles, err := s.userProfiles(ctx, appCode, candidateUserIDs(pageItems)) + if err != nil { + return nil, 0, err + } + items := make([]pendingDTO, 0, len(pageItems)) + for _, item := range pageItems { + items = append(items, pendingDTOFromCandidate(item, profiles[item.UserID])) + } + return items, total, nil +} + +// ListRecords 查询 BD/Admin 结算记录;国家筛选会先转成区域集合,再过滤 wallet 记录里的 region_id。 +func (s *Service) ListRecords(ctx context.Context, appCode string, req recordQuery) ([]recordDTO, int64, error) { + if err := s.ensureReady(ctx); err != nil { + return nil, 0, err + } + req = normalizeRecordQuery(req) + whereSQL, args, err := s.recordWhere(ctx, strings.TrimSpace(appCode), req) + if err != nil { + return nil, 0, err + } + var total int64 + if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM team_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT settlement_id, command_id, transaction_id, policy_type, trigger_mode, user_id, + cycle_key, region_id, policy_id, level_no, income_usd_minor, salary_usd_minor_delta, + status, reason, created_at_ms + FROM team_salary_settlement_records + `+whereSQL+` + ORDER BY created_at_ms DESC, settlement_id DESC + LIMIT ? OFFSET ?`, + append(args, req.PageSize, offset(req.Page, req.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]recordDTO, 0, req.PageSize) + userIDs := make([]int64, 0, req.PageSize) + for rows.Next() { + var item recordDTO + var userID int64 + if err := rows.Scan(&item.SettlementID, &item.CommandID, &item.TransactionID, &item.PolicyType, &item.TriggerMode, &userID, &item.CycleKey, &item.RegionID, &item.PolicyID, &item.LevelNo, &item.IncomeUSDMinor, &item.SalaryUSDMinorDelta, &item.Status, &item.Reason, &item.CreatedAtMS); err != nil { + return nil, 0, err + } + item.UserID = strconv.FormatInt(userID, 10) + item.User = userDTO{UserID: item.UserID} + item.IncomeUSD = formatUSDMinor(item.IncomeUSDMinor) + item.SalaryUSDDelta = formatUSDMinor(item.SalaryUSDMinorDelta) + items = append(items, item) + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + profiles, err := s.userProfiles(ctx, appCode, userIDs) + if err != nil { + return nil, 0, err + } + for i := range items { + if id, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil { + items[i].User = userDTOFromProfile(items[i].UserID, profiles[id]) + } + } + return items, total, nil +} + +// Settle 执行手动或自动结算;它只关心“触发方式”和“用户集合”,差值与幂等统一落在 settleCandidate。 +func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID int64, req settleRequest) (settleResultDTO, error) { + if err := s.ensureReady(ctx); err != nil { + return settleResultDTO{}, err + } + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + if req.TriggerMode == "" { + req.TriggerMode = triggerManual + } + if req.CycleKey == "" { + req.CycleKey = previousMonthCycleKey(time.Now().UTC()) + } + userFilter := int64(0) + if len(req.UserIDs) == 1 { + userFilter = req.UserIDs[0] + } + // 单用户结算在候选聚合阶段下推 userFilter;批量结算先算区域候选再做白名单过滤。 + candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, userFilter, time.Now().UTC().UnixMilli()) + if err != nil { + return settleResultDTO{}, err + } + if len(req.UserIDs) > 0 { + allowed := map[int64]struct{}{} + for _, id := range req.UserIDs { + if id > 0 { + allowed[id] = struct{}{} + } + } + // 前端多选只允许结算勾选用户;这里二次过滤,避免客户端篡改国家/区域后误结不在选择范围内的人。 + filtered := candidates[:0] + for _, candidate := range candidates { + if _, ok := allowed[candidate.UserID]; ok { + filtered = append(filtered, candidate) + } + } + candidates = filtered + } + sortTeamCandidates(candidates) + + result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(req.UserIDs), Records: []recordDTO{}} + profiles, _ := s.userProfiles(ctx, appCode, candidateUserIDs(candidates)) + for _, candidate := range candidates { + result.ProcessedCount++ + // 单个候选失败不阻塞同批其他用户,后台返回 failure_count 供运营重试定位。 + record, settled, err := s.settleCandidate(ctx, strings.TrimSpace(appCode), operatorAdminID, req.TriggerMode, candidate) + if err != nil { + result.FailureCount++ + continue + } + if !settled { + result.SkippedCount++ + continue + } + if id, err := strconv.ParseInt(record.UserID, 10, 64); err == nil { + record.User = userDTOFromProfile(record.UserID, profiles[id]) + } + result.SuccessCount++ + result.Records = append(result.Records, record) + } + return result, nil +} + +// RunAutomaticDue 是自动任务入口:只在每月 5 日结上一个完整自然月,并固定先结 BD 再结 Admin。 +func (s *Service) RunAutomaticDue(ctx context.Context, appCode string, now time.Time) (autoSettlementResult, error) { + utcNow := now.UTC() + result := autoSettlementResult{ + Due: utcNow.Day() == 5, + CycleKey: previousMonthCycleKey(utcNow), + Results: map[string]settleResultDTO{}, + } + if !result.Due { + return result, nil + } + // BD 必须先跑:Admin 的收入基数来自同一周期内 BD 已成功入账的工资记录。 + bdResult, err := s.Settle(ctx, appCode, 0, settleRequest{ + PolicyType: policyTypeBD, + TriggerMode: triggerAutomatic, + CycleKey: result.CycleKey, + }) + if err != nil { + return result, err + } + result.Results[policyTypeBD] = bdResult + + adminResult, err := s.Settle(ctx, appCode, 0, settleRequest{ + PolicyType: policyTypeAdmin, + TriggerMode: triggerAutomatic, + CycleKey: result.CycleKey, + }) + if err != nil { + return result, err + } + result.Results[policyTypeAdmin] = adminResult + return result, nil +} + +// collectCandidates 汇总 BD 和 Admin 两类候选;policyType 为空时用于记录页或内部全量扫描。 +func (s *Service) collectCandidates(ctx context.Context, appCode string, policyType string, triggerMode string, cycleKey string, regionID int64, countryID int64, countryCode string, userID int64, nowMS int64) ([]teamCandidate, error) { + policyType = normalizePolicyType(policyType) + triggerMode = normalizeTriggerMode(triggerMode) + if cycleKey == "" { + cycleKey = previousMonthCycleKey(time.Now().UTC()) + } + // 国家筛选在 user 库维护,结算记录只存 region_id,因此先把国家解析为一组可用区域。 + regionFilter, err := s.regionFilter(ctx, appCode, regionID, countryID, countryCode) + if err != nil { + return nil, err + } + var candidates []teamCandidate + if policyType == "" || policyType == policyTypeBD { + items, err := s.collectBDCandidates(ctx, appCode, triggerMode, cycleKey, regionFilter, userID, nowMS) + if err != nil { + return nil, err + } + candidates = append(candidates, items...) + } + if policyType == "" || policyType == policyTypeAdmin { + items, err := s.collectAdminCandidates(ctx, appCode, triggerMode, cycleKey, regionFilter, userID, nowMS) + if err != nil { + return nil, err + } + candidates = append(candidates, items...) + } + return candidates, nil +} + +// collectBDCandidates 以 Agency 为桥接,把 Host/Agency 已成功结算的主播收入汇总到其上级 BD。 +func (s *Service) collectBDCandidates(ctx context.Context, appCode string, triggerMode string, cycleKey string, regions map[int64]struct{}, userID int64, nowMS int64) ([]teamCandidate, error) { + agencyIncome, err := s.hostIncomeByAgencyOwner(ctx, appCode, cycleKey) + if err != nil || len(agencyIncome) == 0 { + return nil, err + } + // host_salary_settlement_records 只知道 agency owner;BD 归属必须回 user 库查 active agency 当前关系。 + agencies, err := s.activeAgenciesByOwner(ctx, appCode, keysInt64(agencyIncome)) + if err != nil { + return nil, err + } + incomeByBDRegion := map[string]int64{} + for ownerID, income := range agencyIncome { + agency, ok := agencies[ownerID] + if !ok || agency.ParentBDUserID <= 0 || income <= 0 { + continue + } + // 区域来自 Agency 配置,不从主播记录推导,保证 BD 政策和组织管理后台保持同一口径。 + if len(regions) > 0 { + if _, ok := regions[agency.RegionID]; !ok { + continue + } + } + if userID > 0 && agency.ParentBDUserID != userID { + continue + } + incomeByBDRegion[teamCandidateKey(agency.ParentBDUserID, agency.RegionID)] += income + } + return s.candidatesFromIncome(ctx, appCode, policyTypeBD, triggerMode, cycleKey, incomeByBDRegion, nowMS) +} + +// collectAdminCandidates 以已发 BD 工资作为 Admin 收入基数,再通过 BD profile 找到上级 Admin。 +func (s *Service) collectAdminCandidates(ctx context.Context, appCode string, triggerMode string, cycleKey string, regions map[int64]struct{}, userID int64, nowMS int64) ([]teamCandidate, error) { + bdIncome, err := s.bdSalaryIncomeByUser(ctx, appCode, cycleKey) + if err != nil || len(bdIncome) == 0 { + return nil, err + } + // Admin 政策定义为“下属 BD 的 BD Salary 合计”,因此只有已成功入账的 BD 记录才进入这里。 + bdProfiles, err := s.bdProfilesByUser(ctx, appCode, keysInt64(bdIncome), "bd") + if err != nil { + return nil, err + } + leaderIDs := make([]int64, 0, len(bdProfiles)) + for _, profile := range bdProfiles { + if profile.ParentLeaderUserID > 0 { + leaderIDs = append(leaderIDs, profile.ParentLeaderUserID) + } + } + leaders, err := s.bdProfilesByUser(ctx, appCode, uniquePositiveUserIDs(leaderIDs), "bd_leader") + if err != nil { + return nil, err + } + incomeByAdminRegion := map[string]int64{} + for bdUserID, income := range bdIncome { + profile, ok := bdProfiles[bdUserID] + if !ok || profile.ParentLeaderUserID <= 0 || income <= 0 { + continue + } + leader, ok := leaders[profile.ParentLeaderUserID] + if !ok { + continue + } + // Admin 的适用区域以 bd_leader profile 为准,避免一个 BD 跨区域时把收入错配到错误政策。 + if len(regions) > 0 { + if _, ok := regions[leader.RegionID]; !ok { + continue + } + } + if userID > 0 && leader.UserID != userID { + continue + } + incomeByAdminRegion[teamCandidateKey(leader.UserID, leader.RegionID)] += income + } + return s.candidatesFromIncome(ctx, appCode, policyTypeAdmin, triggerMode, cycleKey, incomeByAdminRegion, nowMS) +} + +// candidatesFromIncome 将“用户+区域+收入基数”转换为可结算候选,集中处理政策匹配、等级选择和差值计算。 +func (s *Service) candidatesFromIncome(ctx context.Context, appCode string, policyType string, triggerMode string, cycleKey string, incomeByUserRegion map[string]int64, nowMS int64) ([]teamCandidate, error) { + if len(incomeByUserRegion) == 0 { + return nil, nil + } + // 区域名称只是展示字段;读取失败不影响结算主流程,主键 region_id 仍然完整。 + regionNames, _ := s.regionNames(ctx, appCode) + out := make([]teamCandidate, 0, len(incomeByUserRegion)) + for key, income := range incomeByUserRegion { + userID, regionID := parseTeamCandidateKey(key) + // 自动/手动政策同一区域可共存,triggerMode 必须参与政策解析,否则会把人工复核政策提前发放。 + policy, ok, err := s.resolvePolicy(ctx, appCode, policyType, regionID, triggerMode, nowMS) + if err != nil || !ok { + if err != nil { + return nil, err + } + continue + } + progress, err := s.loadProgress(ctx, appCode, policyType, userID, cycleKey) + if err != nil { + return nil, err + } + level := highestTeamLevel(policy.Levels, income) + // 等级薪资是“累计应得”,这里先算一次待结差值供页面展示,真正发放时还会在事务锁内重算。 + delta := positiveDelta(level.SalaryMinor, progress.SettledSalaryUSDMinor) + out = append(out, teamCandidate{ + PolicyType: policyType, + UserID: userID, + CycleKey: cycleKey, + RegionID: regionID, + RegionName: regionNames[regionID], + Policy: policy, + Level: level, + IncomeUSDMinor: income, + Progress: progress, + SalaryUSDMinorDelta: delta, + }) + } + return out, nil +} + +// settleCandidate 在一个 wallet 事务内完成进度锁定、差值重算、钱包入账、流水和结算记录写入。 +func (s *Service) settleCandidate(ctx context.Context, appCode string, operatorAdminID int64, triggerMode string, candidate teamCandidate) (recordDTO, bool, error) { + nowMS := time.Now().UTC().UnixMilli() + tx, err := s.walletDB.BeginTx(ctx, nil) + if err != nil { + return recordDTO{}, false, err + } + defer func() { _ = tx.Rollback() }() + + progress, err := s.lockProgress(ctx, tx, appCode, candidate, nowMS) + if err != nil { + return recordDTO{}, false, err + } + // 结算表里的等级工资是累计值;每次只发“当前应得累计 - 已发累计”,因此重复提交同一批用户不会二次入账。 + salaryDelta := positiveDelta(candidate.Level.SalaryMinor, progress.SettledSalaryUSDMinor) + if salaryDelta <= 0 { + // 未升档但收入基数增加时只刷新 last_settled_income,减少后续待结算列表反复出现 0 金额候选。 + if candidate.IncomeUSDMinor > progress.LastSettledIncomeUSDMinor { + progress.LastSettledIncomeUSDMinor = candidate.IncomeUSDMinor + if err := s.updateProgress(ctx, tx, appCode, candidate, progress, nowMS); err != nil { + return recordDTO{}, false, err + } + } + if err := tx.Commit(); err != nil { + return recordDTO{}, false, err + } + return recordDTO{}, false, nil + } + + // nextProgress 使用 max,确保历史已发累计不会因政策调整或候选回退被写小。 + nextProgress := teamProgress{ + SettledLevelNo: maxInt(progress.SettledLevelNo, candidate.Level.LevelNo), + SettledSalaryUSDMinor: maxInt64(progress.SettledSalaryUSDMinor, candidate.Level.SalaryMinor), + LastSettledIncomeUSDMinor: maxInt64(progress.LastSettledIncomeUSDMinor, candidate.IncomeUSDMinor), + LastPolicyID: candidate.Policy.PolicyID, + MonthClosedAtMS: nowMS, + Version: progress.Version, + } + metadata := settlementMetadata{ + AppCode: appCode, + PolicyType: candidate.PolicyType, + TriggerMode: triggerMode, + UserID: candidate.UserID, + CycleKey: candidate.CycleKey, + RegionID: candidate.RegionID, + PolicyID: candidate.Policy.PolicyID, + PolicyName: candidate.Policy.Name, + LevelNo: candidate.Level.LevelNo, + IncomeUSDMinor: candidate.IncomeUSDMinor, + SalaryUSDMinorDelta: salaryDelta, + SettledLevelNo: nextProgress.SettledLevelNo, + SettledSalaryUSDMinor: nextProgress.SettledSalaryUSDMinor, + ProcessedAtMS: nowMS, + OperatorAdminID: operatorAdminID, + } + // commandID 带上目标等级和收入基数;同一进度重复提交会命中 progress 差值为 0,异常重试也会被唯一键兜住。 + commandID := teamSalaryCommandID(candidate, triggerMode, nextProgress) + transactionID := teamSalaryTransactionID(appCode, commandID) + if err := s.insertWalletTransaction(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil { + return recordDTO{}, false, err + } + balanceAfter, version, err := s.creditWallet(ctx, tx, appCode, transactionID, candidate.UserID, salaryDelta, nowMS) + if err != nil { + return recordDTO{}, false, err + } + if err := s.insertWalletOutbox(ctx, tx, appCode, transactionID, commandID, candidate.UserID, salaryDelta, balanceAfter, version, metadata, nowMS); err != nil { + return recordDTO{}, false, err + } + if err := s.insertRecord(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil { + return recordDTO{}, false, err + } + if err := s.updateProgress(ctx, tx, appCode, candidate, nextProgress, nowMS); err != nil { + return recordDTO{}, false, err + } + if err := tx.Commit(); err != nil { + return recordDTO{}, false, err + } + return recordDTO{ + SettlementID: teamSalarySettlementID(appCode, commandID), + CommandID: commandID, + TransactionID: transactionID, + PolicyType: candidate.PolicyType, + TriggerMode: triggerMode, + UserID: strconv.FormatInt(candidate.UserID, 10), + User: userDTO{UserID: strconv.FormatInt(candidate.UserID, 10)}, + CycleKey: candidate.CycleKey, + RegionID: candidate.RegionID, + PolicyID: candidate.Policy.PolicyID, + LevelNo: int32(candidate.Level.LevelNo), + IncomeUSDMinor: candidate.IncomeUSDMinor, + IncomeUSD: formatUSDMinor(candidate.IncomeUSDMinor), + SalaryUSDMinorDelta: salaryDelta, + SalaryUSDDelta: formatUSDMinor(salaryDelta), + Status: statusSucceeded, + CreatedAtMS: nowMS, + }, true, nil +} + +// hostIncomeByAgencyOwner 读取 Host/Agency 已成功结算记录,BD 只基于主播工资和月底剩余折美元,不计 Agency 自己工资。 +func (s *Service) hostIncomeByAgencyOwner(ctx context.Context, appCode string, cycleKey string) (map[int64]int64, error) { + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT agency_owner_user_id, SUM(host_salary_usd_minor_delta + residual_usd_minor_delta) + FROM host_salary_settlement_records + WHERE app_code = ? AND cycle_key = ? AND status = 'succeeded' AND agency_owner_user_id > 0 + GROUP BY agency_owner_user_id`, appCode, cycleKey) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int64{} + for rows.Next() { + var ownerID int64 + var income sql.NullInt64 + if err := rows.Scan(&ownerID, &income); err != nil { + return nil, err + } + if ownerID > 0 && income.Int64 > 0 { + out[ownerID] = income.Int64 + } + } + return out, rows.Err() +} + +// bdSalaryIncomeByUser 读取同周期已成功发放的 BD 工资,Admin 只按真实入账 BD Salary 计算。 +func (s *Service) bdSalaryIncomeByUser(ctx context.Context, appCode string, cycleKey string) (map[int64]int64, error) { + rows, err := s.walletDB.QueryContext(ctx, ` + SELECT user_id, SUM(salary_usd_minor_delta) + FROM team_salary_settlement_records + WHERE app_code = ? AND policy_type = 'bd' AND cycle_key = ? AND status = 'succeeded' + GROUP BY user_id`, appCode, cycleKey) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int64{} + for rows.Next() { + var userID int64 + var income sql.NullInt64 + if err := rows.Scan(&userID, &income); err != nil { + return nil, err + } + if userID > 0 && income.Int64 > 0 { + out[userID] = income.Int64 + } + } + return out, rows.Err() +} + +// agencyInfo 是 BD 汇总所需的最小组织关系视图。 +type agencyInfo struct { + OwnerUserID int64 + ParentBDUserID int64 + RegionID int64 +} + +// activeAgenciesByOwner 只使用 active agency,避免历史或停用 Agency 继续给 BD 产生工资。 +func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, ownerIDs []int64) (map[int64]agencyInfo, error) { + ownerIDs = uniquePositiveUserIDs(ownerIDs) + if len(ownerIDs) == 0 { + return map[int64]agencyInfo{}, nil + } + placeholders := placeholders(len(ownerIDs)) + args := make([]any, 0, len(ownerIDs)+1) + args = append(args, appCode) + for _, id := range ownerIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT owner_user_id, parent_bd_user_id, region_id + FROM agencies + WHERE app_code = ? AND status = 'active' AND owner_user_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]agencyInfo{} + for rows.Next() { + var item agencyInfo + if err := rows.Scan(&item.OwnerUserID, &item.ParentBDUserID, &item.RegionID); err != nil { + return nil, err + } + out[item.OwnerUserID] = item + } + return out, rows.Err() +} + +// bdProfile 是 Admin 汇总所需的最小 BD/BD Leader 组织关系视图。 +type bdProfile struct { + UserID int64 + Role string + RegionID int64 + ParentLeaderUserID int64 +} + +// bdProfilesByUser 按角色取 active profile,BD 和 BD Leader 共用一张表但语义不同,调用方必须显式传 role。 +func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) { + userIDs = uniquePositiveUserIDs(userIDs) + if len(userIDs) == 0 { + return map[int64]bdProfile{}, nil + } + placeholders := placeholders(len(userIDs)) + args := make([]any, 0, len(userIDs)+2) + args = append(args, appCode, role) + for _, id := range userIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT user_id, role, region_id, COALESCE(parent_leader_user_id, 0) + FROM bd_profiles + WHERE app_code = ? AND role = ? AND status = 'active' AND user_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]bdProfile{} + for rows.Next() { + var item bdProfile + if err := rows.Scan(&item.UserID, &item.Role, &item.RegionID, &item.ParentLeaderUserID); err != nil { + return nil, err + } + out[item.UserID] = item + } + return out, rows.Err() +} + +// resolvePolicy 按 app、类型、区域、触发方式和生效时间解析当前唯一有效政策。 +func (s *Service) resolvePolicy(ctx context.Context, appCode string, policyType string, regionID int64, triggerMode string, nowMS int64) (teamPolicy, bool, error) { + conditions := []string{"app_code = ?", "policy_type = ?", "region_id = ?", "status = 'active'", "effective_from_ms <= ?", "(effective_to_ms = 0 OR effective_to_ms > ?)"} + args := []any{appCode, policyType, regionID, nowMS, nowMS} + if triggerMode != "" { + conditions = append(conditions, "settlement_trigger_mode = ?") + args = append(args, triggerMode) + } + row := s.adminDB.QueryRowContext(ctx, ` + SELECT id, policy_type, name, region_id, settlement_trigger_mode + FROM admin_team_salary_policies + WHERE `+strings.Join(conditions, " AND ")+` + ORDER BY effective_from_ms DESC, id DESC + LIMIT 1`, args...) + var policy teamPolicy + if err := row.Scan(&policy.PolicyID, &policy.PolicyType, &policy.Name, &policy.RegionID, &policy.TriggerMode); err != nil { + if errors.Is(err, sql.ErrNoRows) { + // 没有政策不是系统错误;该区域用户本轮没有可结算候选。 + return teamPolicy{}, false, nil + } + return teamPolicy{}, false, err + } + levels, err := s.policyLevels(ctx, policy.PolicyID) + if err != nil { + return teamPolicy{}, false, err + } + policy.Levels = levels + return policy, true, nil +} + +// policyLevels 读取并转换政策等级;排序按门槛递增,highestTeamLevel 可以线性选择最高达标档。 +func (s *Service) policyLevels(ctx context.Context, policyID uint64) ([]teamPolicyLevel, error) { + rows, err := s.adminDB.QueryContext(ctx, ` + SELECT level_no, CAST(threshold_usd AS CHAR), CAST(salary_usd AS CHAR) + FROM admin_team_salary_policy_levels + WHERE policy_id = ? AND status = 'active' + ORDER BY threshold_usd ASC, level_no ASC`, policyID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []teamPolicyLevel + for rows.Next() { + var level teamPolicyLevel + var threshold string + var salary string + if err := rows.Scan(&level.LevelNo, &threshold, &salary); err != nil { + return nil, err + } + thresholdMinor, err := usdStringToMinor(threshold) + if err != nil { + return nil, err + } + salaryMinor, err := usdStringToMinor(salary) + if err != nil { + return nil, err + } + level.ThresholdMinor = thresholdMinor + level.SalaryMinor = salaryMinor + out = append(out, level) + } + return out, rows.Err() +} + +// loadProgress 为列表展示读取当前进度;没有进度代表本周期尚未发过工资。 +func (s *Service) loadProgress(ctx context.Context, appCode string, policyType string, userID int64, cycleKey string) (teamProgress, error) { + var progress teamProgress + err := s.walletDB.QueryRowContext(ctx, ` + SELECT settled_level_no, settled_salary_usd_minor, last_settled_income_usd_minor, + last_policy_id, month_closed_at_ms, version + FROM team_salary_settlement_progress + WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ?`, + appCode, policyType, userID, cycleKey, + ).Scan(&progress.SettledLevelNo, &progress.SettledSalaryUSDMinor, &progress.LastSettledIncomeUSDMinor, &progress.LastPolicyID, &progress.MonthClosedAtMS, &progress.Version) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return teamProgress{Version: 1}, nil + } + return teamProgress{}, err + } + return progress, nil +} + +// lockProgress 先 INSERT IGNORE 再 SELECT FOR UPDATE,保证并发结算同一用户周期时只有一个事务能重算差值。 +func (s *Service) lockProgress(ctx context.Context, tx *sql.Tx, appCode string, candidate teamCandidate, nowMS int64) (teamProgress, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO team_salary_settlement_progress ( + app_code, policy_type, user_id, cycle_key, region_id, settled_level_no, + settled_salary_usd_minor, last_settled_income_usd_minor, last_policy_id, + month_closed_at_ms, version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 1, ?, ?)`, + appCode, candidate.PolicyType, candidate.UserID, candidate.CycleKey, candidate.RegionID, nowMS, nowMS, + ); err != nil { + return teamProgress{}, err + } + var progress teamProgress + if err := tx.QueryRowContext(ctx, ` + SELECT settled_level_no, settled_salary_usd_minor, last_settled_income_usd_minor, + last_policy_id, month_closed_at_ms, version + FROM team_salary_settlement_progress + WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, appCode, candidate.PolicyType, candidate.UserID, candidate.CycleKey, + ).Scan(&progress.SettledLevelNo, &progress.SettledSalaryUSDMinor, &progress.LastSettledIncomeUSDMinor, &progress.LastPolicyID, &progress.MonthClosedAtMS, &progress.Version); err != nil { + return teamProgress{}, err + } + return progress, nil +} + +// updateProgress 使用 version 乐观校验,防止事务外或异常并发修改覆盖已提交进度。 +func (s *Service) updateProgress(ctx context.Context, tx *sql.Tx, appCode string, candidate teamCandidate, progress teamProgress, nowMS int64) error { + result, err := tx.ExecContext(ctx, ` + UPDATE team_salary_settlement_progress + SET region_id = ?, + settled_level_no = ?, + settled_salary_usd_minor = ?, + last_settled_income_usd_minor = ?, + last_policy_id = ?, + month_closed_at_ms = ?, + version = version + 1, + updated_at_ms = ? + WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ? AND version = ?`, + candidate.RegionID, + progress.SettledLevelNo, + progress.SettledSalaryUSDMinor, + progress.LastSettledIncomeUSDMinor, + progress.LastPolicyID, + progress.MonthClosedAtMS, + nowMS, + appCode, + candidate.PolicyType, + candidate.UserID, + candidate.CycleKey, + progress.Version, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("team salary settlement progress version conflict") + } + return nil +} + +// insertWalletTransaction 写钱包交易主表,command_id 是业务幂等键。 +func (s *Service) insertWalletTransaction(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, metadata settlementMetadata, nowMS int64) error { + payload, err := json.Marshal(metadata) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO wallet_transactions ( + app_code, transaction_id, command_id, biz_type, status, request_hash, + external_ref, metadata_json, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)`, + appCode, transactionID, commandID, bizTypeTeamSettlement, stableHash(string(payload)), metadata.CycleKey, string(payload), nowMS, nowMS, + ) + return err +} + +// creditWallet 锁定美元账户并追加 wallet_entries,使余额、流水和版本在同一事务内一致。 +func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, userID int64, amount int64, nowMS int64) (int64, int64, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO wallet_accounts ( + app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, appCode, userID, assetUSDBalance, nowMS, nowMS); err != nil { + return 0, 0, err + } + var available int64 + var frozen int64 + var version int64 + if err := tx.QueryRowContext(ctx, ` + SELECT available_amount, frozen_amount, version + FROM wallet_accounts + WHERE app_code = ? AND user_id = ? AND asset_type = ? + FOR UPDATE`, appCode, userID, assetUSDBalance, + ).Scan(&available, &frozen, &version); err != nil { + return 0, 0, err + } + after := available + amount + if after < 0 { + return 0, 0, fmt.Errorf("team salary credit overflow") + } + // 工资只会正向入账;version 条件仍保留,和其它钱包写入路径保持并发语义一致。 + if _, err := tx.ExecContext(ctx, ` + UPDATE wallet_accounts + SET available_amount = ?, version = version + 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`, + after, nowMS, appCode, userID, assetUSDBalance, version, + ); err != nil { + return 0, 0, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO wallet_entries ( + app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta, + available_after, frozen_after, counterparty_user_id, room_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, 0, '', ?)`, + appCode, transactionID, userID, assetUSDBalance, amount, after, frozen, nowMS, + ); err != nil { + return 0, 0, err + } + return after, version + 1, nil +} + +// insertWalletOutbox 投递余额变更事件,供后续 MQ/通知链路异步消费。 +func (s *Service) insertWalletOutbox(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, userID int64, amount int64, balanceAfter int64, version int64, metadata settlementMetadata, nowMS int64) error { + payload, err := json.Marshal(map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": userID, + "asset_type": assetUSDBalance, + "available_delta": amount, + "available_after": balanceAfter, + "version": version, + "metadata": metadata, + "created_at_ms": nowMS, + }) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO wallet_outbox ( + app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, payload, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?)`, + appCode, transactionID+":"+assetUSDBalance, transactionID, commandID, userID, assetUSDBalance, amount, string(payload), nowMS, nowMS, + ) + return err +} + +// insertRecord 写业务结算记录,是后台查询 settlement_id 的权威来源。 +func (s *Service) insertRecord(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, metadata settlementMetadata, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO team_salary_settlement_records ( + app_code, settlement_id, command_id, transaction_id, policy_type, trigger_mode, + user_id, cycle_key, region_id, policy_id, level_no, income_usd_minor, + salary_usd_minor_delta, status, reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', '', ?)`, + appCode, + teamSalarySettlementID(appCode, commandID), + commandID, + transactionID, + metadata.PolicyType, + metadata.TriggerMode, + metadata.UserID, + metadata.CycleKey, + metadata.RegionID, + metadata.PolicyID, + metadata.LevelNo, + metadata.IncomeUSDMinor, + metadata.SalaryUSDMinorDelta, + nowMS, + ) + return err +} + +// recordWhere 统一构造记录查询条件,避免列表 API 和后续导出 API 出现筛选口径差异。 +func (s *Service) recordWhere(ctx context.Context, appCode string, req recordQuery) (string, []any, error) { + conditions := []string{"app_code = ?"} + args := []any{appCode} + if req.PolicyType != "" { + conditions = append(conditions, "policy_type = ?") + args = append(args, req.PolicyType) + } + if req.TriggerMode != "" { + conditions = append(conditions, "trigger_mode = ?") + args = append(args, req.TriggerMode) + } + if req.CycleKey != "" { + conditions = append(conditions, "cycle_key = ?") + args = append(args, req.CycleKey) + } + if req.Status != "" { + conditions = append(conditions, "status = ?") + args = append(args, req.Status) + } + if req.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, req.UserID) + } + if req.PolicyID > 0 { + conditions = append(conditions, "policy_id = ?") + args = append(args, req.PolicyID) + } + if req.RegionID > 0 { + conditions = append(conditions, "region_id = ?") + args = append(args, req.RegionID) + } else if req.CountryID > 0 || req.CountryCode != "" { + regions, err := s.regionFilter(ctx, appCode, 0, req.CountryID, req.CountryCode) + if err != nil { + return "", nil, err + } + if len(regions) == 0 { + conditions = append(conditions, "region_id = -1") + } else { + ids := keysInt64FromSet(regions) + conditions = append(conditions, "region_id IN ("+placeholders(len(ids))+")") + for _, id := range ids { + args = append(args, id) + } + } + } + if req.StartAtMS > 0 { + conditions = append(conditions, "created_at_ms >= ?") + args = append(args, req.StartAtMS) + } + if req.EndAtMS > 0 { + conditions = append(conditions, "created_at_ms < ?") + args = append(args, req.EndAtMS) + } + return "WHERE " + strings.Join(conditions, " AND "), args, nil +} + +// regionFilter 把区域、国家 ID、国家代码归一为 region_id 集合;region_id 直传时优先级最高。 +func (s *Service) regionFilter(ctx context.Context, appCode string, regionID int64, countryID int64, countryCode string) (map[int64]struct{}, error) { + if regionID > 0 { + return map[int64]struct{}{regionID: {}}, nil + } + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if countryID > 0 && countryCode == "" { + if err := s.userDB.QueryRowContext(ctx, `SELECT country_code FROM countries WHERE app_code = ? AND country_id = ?`, appCode, countryID).Scan(&countryCode); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return map[int64]struct{}{}, nil + } + return nil, err + } + } + if countryCode == "" { + return nil, nil + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT region_id + FROM region_countries + WHERE app_code = ? AND country_code = ? AND status = 'active'`, appCode, countryCode) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]struct{}{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = struct{}{} + } + return out, rows.Err() +} + +// regionNames 批量读取区域名称,只服务后台展示,不参与任何金额计算。 +func (s *Service) regionNames(ctx context.Context, appCode string) (map[int64]string, error) { + rows, err := s.userDB.QueryContext(ctx, `SELECT region_id, name FROM regions WHERE app_code = ?`, appCode) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]string{} + for rows.Next() { + var id int64 + var name string + if err := rows.Scan(&id, &name); err != nil { + return nil, err + } + out[id] = name + } + return out, rows.Err() +} + +// userProfiles 批量读取用户展示信息;缺失用户资料时上层会保留 user_id。 +func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { + userIDs = uniquePositiveUserIDs(userIDs) + if len(userIDs) == 0 { + return map[int64]userProfile{}, nil + } + args := make([]any, 0, len(userIDs)+1) + args = append(args, appCode) + for _, id := range userIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') + FROM users + WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]userProfile{} + for rows.Next() { + var item userProfile + if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil { + return nil, err + } + out[item.UserID] = item + } + return out, rows.Err() +} + +// ensureReady 做运行时自检并补齐 wallet 运行表;本地测试可以直接跑,线上仍以标准迁移为准。 +func (s *Service) ensureReady(ctx context.Context) error { + if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil { + return fmt.Errorf("team salary settlement dependencies are not configured") + } + // 运行表属于 wallet 账本库;admin-server 在本地和测试环境按需补表,线上仍应由标准 DDL 发布流程先执行 initdb/migration。 + for _, ddl := range teamSalaryRuntimeDDL() { + if _, err := s.walletDB.ExecContext(ctx, ddl); err != nil { + return err + } + } + return nil +} + +// teamSalaryRuntimeDDL 返回 wallet 侧团队工资运行表 DDL;保持与 wallet-service initdb 的结构一致。 +func teamSalaryRuntimeDDL() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS team_salary_settlement_progress ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + policy_type VARCHAR(24) NOT NULL, + user_id BIGINT NOT NULL, + cycle_key VARCHAR(16) NOT NULL, + region_id BIGINT NOT NULL, + settled_level_no INT NOT NULL DEFAULT 0, + settled_salary_usd_minor BIGINT NOT NULL DEFAULT 0, + last_settled_income_usd_minor BIGINT NOT NULL DEFAULT 0, + last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + month_closed_at_ms BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 1, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, policy_type, user_id, cycle_key), + KEY idx_team_salary_progress_cycle (app_code, policy_type, cycle_key, region_id, month_closed_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS team_salary_settlement_records ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + settlement_id VARCHAR(128) NOT NULL, + command_id VARCHAR(128) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + policy_type VARCHAR(24) NOT NULL, + trigger_mode VARCHAR(24) NOT NULL DEFAULT 'manual', + user_id BIGINT NOT NULL, + cycle_key VARCHAR(16) NOT NULL, + region_id BIGINT NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + level_no INT NOT NULL DEFAULT 0, + income_usd_minor BIGINT NOT NULL DEFAULT 0, + salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + status VARCHAR(24) NOT NULL DEFAULT 'succeeded', + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, settlement_id), + UNIQUE KEY uk_team_salary_settlement_command (app_code, command_id), + KEY idx_team_salary_records_user_cycle (app_code, policy_type, user_id, cycle_key, created_at_ms), + KEY idx_team_salary_records_cycle (app_code, policy_type, cycle_key, region_id, created_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + } +} + +// pendingDTOFromCandidate 将内部候选转成前端 DTO,同时保留已结进度方便运营判断为什么只发差值。 +func pendingDTOFromCandidate(item teamCandidate, profile userProfile) pendingDTO { + userID := strconv.FormatInt(item.UserID, 10) + return pendingDTO{ + PolicyType: item.PolicyType, + UserID: userID, + User: userDTOFromProfile(userID, profile), + CycleKey: item.CycleKey, + RegionID: item.RegionID, + RegionName: item.RegionName, + PolicyID: item.Policy.PolicyID, + PolicyName: item.Policy.Name, + LevelNo: int32(item.Level.LevelNo), + IncomeUSDMinor: item.IncomeUSDMinor, + IncomeUSD: formatUSDMinor(item.IncomeUSDMinor), + SalaryUSDMinorTarget: item.Level.SalaryMinor, + SalaryUSDTarget: formatUSDMinor(item.Level.SalaryMinor), + SalaryUSDMinorDelta: item.SalaryUSDMinorDelta, + SalaryUSDDelta: formatUSDMinor(item.SalaryUSDMinorDelta), + SettledLevelNo: int32(item.Progress.SettledLevelNo), + SettledSalaryUSDMinor: item.Progress.SettledSalaryUSDMinor, + SettledSalaryUSD: formatUSDMinor(item.Progress.SettledSalaryUSDMinor), + LastSettledIncomeUSDMinor: item.Progress.LastSettledIncomeUSDMinor, + LastSettledIncomeUSD: formatUSDMinor(item.Progress.LastSettledIncomeUSDMinor), + MonthClosedAtMS: item.Progress.MonthClosedAtMS, + } +} + +// normalizePendingQuery 收敛列表参数默认值;默认看上一个完整月,符合 BD/Admin 次月 5 日结算口径。 +func normalizePendingQuery(req pendingQuery) pendingQuery { + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) + if req.CycleKey == "" { + req.CycleKey = previousMonthCycleKey(time.Now().UTC()) + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 50 + } + if req.PageSize > maxPageSize { + req.PageSize = maxPageSize + } + return req +} + +// normalizeRecordQuery 收敛记录查询参数,非法枚举由 handler 提前报错,这里只做兜底。 +func normalizeRecordQuery(req recordQuery) recordQuery { + req.PolicyType = normalizePolicyType(req.PolicyType) + req.TriggerMode = normalizeTriggerMode(req.TriggerMode) + req.Status = normalizeStatus(req.Status) + req.CycleKey = strings.TrimSpace(req.CycleKey) + req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 50 + } + if req.PageSize > maxPageSize { + req.PageSize = maxPageSize + } + return req +} + +// normalizePolicyType 只允许 bd/admin/all,未知值归空让 handler 能统一判断是否非法。 +func normalizePolicyType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case policyTypeBD: + return policyTypeBD + case policyTypeAdmin: + return policyTypeAdmin + default: + return "" + } +} + +// normalizeTriggerMode 只允许 automatic/manual/all,防止手写字符串绕过政策触发方式匹配。 +func normalizeTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case triggerAutomatic: + return triggerAutomatic + case triggerManual: + return triggerManual + default: + return "" + } +} + +// normalizeStatus 只允许记录表定义的状态值,避免前端筛选拼接任意 SQL 条件。 +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "succeeded", "skipped", "failed": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +// highestTeamLevel 按收入基数找最高已达档;未达任何档时返回零值,后续差值自然为 0。 +func highestTeamLevel(levels []teamPolicyLevel, incomeMinor int64) teamPolicyLevel { + var selected teamPolicyLevel + for _, level := range levels { + if level.ThresholdMinor <= incomeMinor { + selected = level + } + } + return selected +} + +// usdStringToMinor 把 DECIMAL 字符串转成美分整数,所有工资差值都用整数计算。 +func usdStringToMinor(value string) (int64, error) { + rat, ok := new(big.Rat).SetString(strings.TrimSpace(value)) + if !ok || rat.Sign() < 0 { + return 0, fmt.Errorf("invalid usd amount: %s", value) + } + rat.Mul(rat, big.NewRat(100, 1)) + minor := new(big.Int).Quo(rat.Num(), rat.Denom()) + if !minor.IsInt64() || minor.Int64() > math.MaxInt64 { + return 0, fmt.Errorf("usd amount overflow: %s", value) + } + return minor.Int64(), nil +} + +// previousMonthCycleKey 以 UTC 自然月为结算周期,和 wallet Host 周期键保持同一口径。 +func previousMonthCycleKey(now time.Time) string { + return time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -1, 0).Format("2006-01") +} + +// teamSalaryCommandID 是团队工资业务幂等 ID,编码维度覆盖角色、触发方式、周期、用户、等级和收入基数。 +func teamSalaryCommandID(candidate teamCandidate, triggerMode string, progress teamProgress) string { + return fmt.Sprintf("team_salary:%s:%s:%s:%d:%d:%d", candidate.PolicyType, triggerMode, candidate.CycleKey, candidate.UserID, progress.SettledLevelNo, candidate.IncomeUSDMinor) +} + +// teamSalarySettlementID 从 commandID 派生稳定记录 ID,方便重复请求定位到同一业务动作。 +func teamSalarySettlementID(appCode string, commandID string) string { + return fmt.Sprintf("tss_%s_%s", strings.TrimSpace(appCode), stableHash(commandID)) +} + +// teamSalaryTransactionID 从 commandID 派生 wallet 交易 ID,保证记录和钱包交易可双向关联。 +func teamSalaryTransactionID(appCode string, commandID string) string { + return fmt.Sprintf("wtx_%s_%s", strings.TrimSpace(appCode), stableHash(commandID)) +} + +// stableHash 使用 SHA-256 压缩长幂等键,避免 MySQL varchar 索引过长。 +func stableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// teamCandidateKey 把用户和区域组合成 map key,BD/Admin 同一用户跨区域时必须分别匹配政策。 +func teamCandidateKey(userID int64, regionID int64) string { + return strconv.FormatInt(userID, 10) + ":" + strconv.FormatInt(regionID, 10) +} + +// parseTeamCandidateKey 反解 teamCandidateKey;异常 key 返回零值,后续不会命中有效政策。 +func parseTeamCandidateKey(key string) (int64, int64) { + parts := strings.Split(key, ":") + if len(parts) != 2 { + return 0, 0 + } + userID, _ := strconv.ParseInt(parts[0], 10, 64) + regionID, _ := strconv.ParseInt(parts[1], 10, 64) + return userID, regionID +} + +// sortTeamCandidates 让后台列表稳定:先角色、区域,再按收入从高到低,最后按用户 ID。 +func sortTeamCandidates(items []teamCandidate) { + sort.Slice(items, func(i, j int) bool { + if items[i].PolicyType != items[j].PolicyType { + return items[i].PolicyType < items[j].PolicyType + } + if items[i].RegionID != items[j].RegionID { + return items[i].RegionID < items[j].RegionID + } + if items[i].IncomeUSDMinor != items[j].IncomeUSDMinor { + return items[i].IncomeUSDMinor > items[j].IncomeUSDMinor + } + return items[i].UserID < items[j].UserID + }) +} + +// paginateCandidates 在内存候选上分页;候选已被区域/国家/角色过滤,避免 SQL 里重复复杂聚合分页。 +func paginateCandidates(items []teamCandidate, page int, pageSize int) []teamCandidate { + start := offset(page, pageSize) + if start >= len(items) { + return []teamCandidate{} + } + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +// candidateUserIDs 提取候选用户 ID,用于一次性加载展示资料。 +func candidateUserIDs(items []teamCandidate) []int64 { + out := make([]int64, 0, len(items)) + for _, item := range items { + out = append(out, item.UserID) + } + return out +} + +// keysInt64 将 map key 稳定排序,保证 SQL IN 参数顺序和测试输出稳定。 +func keysInt64(values map[int64]int64) []int64 { + out := make([]int64, 0, len(values)) + for key := range values { + out = append(out, key) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// keysInt64FromSet 将 set key 稳定排序,主要用于国家转区域后的记录查询条件。 +func keysInt64FromSet(values map[int64]struct{}) []int64 { + out := make([]int64, 0, len(values)) + for key := range values { + out = append(out, key) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// uniquePositiveUserIDs 去重并过滤无效用户 ID,避免动态 IN () 和重复 profile 查询。 +func uniquePositiveUserIDs(ids []int64) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// userDTOFromProfile 在资料缺失时仍返回 fallback user_id,保证结算记录永远可识别收款用户。 +func userDTOFromProfile(fallbackID string, profile userProfile) userDTO { + if profile.UserID <= 0 { + return userDTO{UserID: fallbackID} + } + return userDTO{ + UserID: strconv.FormatInt(profile.UserID, 10), + DisplayUserID: profile.DisplayUserID, + Username: profile.Username, + Avatar: profile.Avatar, + } +} + +// formatUSDMinor 只负责后端 DTO 展示格式,账务计算不使用格式化字符串。 +func formatUSDMinor(value int64) string { + sign := "" + if value < 0 { + sign = "-" + value = -value + } + whole := value / 100 + fraction := value % 100 + if fraction == 0 { + return sign + strconv.FormatInt(whole, 10) + } + if fraction < 10 { + return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10) + } + return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10) +} + +// placeholders 生成固定数量的 SQL 占位符,调用方已保证 n > 0。 +func placeholders(n int) string { + return strings.TrimRight(strings.Repeat("?,", n), ",") +} + +// offset 把页码转换为 LIMIT offset;第一页和异常页码都回到 0。 +func offset(page int, pageSize int) int { + if page <= 1 { + return 0 + } + return (page - 1) * pageSize +} + +// positiveDelta 是所有差值发薪的最终公式:目标累计 <= 已发累计时不再发钱。 +func positiveDelta(target int64, current int64) int64 { + if target <= current { + return 0 + } + return target - current +} + +// maxInt 防止进度等级被旧候选覆盖回退。 +func maxInt(left int, right int) int { + if right > left { + return right + } + return left +} + +// maxInt64 防止已发金额或收入基数被旧候选覆盖回退。 +func maxInt64(left int64, right int64) int64 { + if right > left { + return right + } + return left +} diff --git a/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go b/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go new file mode 100644 index 00000000..6dfd45ef --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go @@ -0,0 +1,381 @@ +package teamsalarysettlement + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + mysqlDriver "github.com/go-sql-driver/mysql" +) + +func TestRealMySQLTeamSalaryFullFlow(t *testing.T) { + baseDSN := strings.TrimSpace(os.Getenv("TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN")) + if baseDSN == "" { + t.Skip("set TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN to run the real MySQL BD/Admin salary flow") + } + ctx := context.Background() + adminDB := newTeamSalaryTestDB(t, baseDSN, "admin") + userDB := newTeamSalaryTestDB(t, baseDSN, "user") + walletDB := newTeamSalaryTestDB(t, baseDSN, "wallet") + execAll(t, adminDB, teamSalaryAdminDDL()...) + execAll(t, userDB, teamSalaryUserDDL()...) + execAll(t, walletDB, teamSalaryWalletDDL()...) + seedTeamSalaryFlow(t, adminDB, userDB, walletDB) + + service := NewService(adminDB, walletDB, userDB) + pendingBD, totalBD, err := service.ListPending(ctx, "lalu", pendingQuery{ + PolicyType: policyTypeBD, + TriggerMode: triggerAutomatic, + CycleKey: "2026-01", + }) + if err != nil { + t.Fatalf("list bd pending failed: %v", err) + } + if totalBD != 1 || len(pendingBD) != 1 { + t.Fatalf("bd pending count mismatch: total=%d len=%d", totalBD, len(pendingBD)) + } + if pendingBD[0].IncomeUSDMinor != 1_000_000 || pendingBD[0].SalaryUSDMinorDelta != 100_000 { + t.Fatalf("bd pending amount mismatch: income=%d salary_delta=%d", pendingBD[0].IncomeUSDMinor, pendingBD[0].SalaryUSDMinorDelta) + } + + firstRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 8, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("first automatic settlement failed: %v", err) + } + if !firstRun.Due || firstRun.CycleKey != "2026-01" { + t.Fatalf("automatic due state mismatch: due=%v cycle=%s", firstRun.Due, firstRun.CycleKey) + } + if firstRun.Results[policyTypeBD].SuccessCount != 1 || firstRun.Results[policyTypeAdmin].SuccessCount != 1 { + t.Fatalf("first settlement success mismatch: bd=%+v admin=%+v", firstRun.Results[policyTypeBD], firstRun.Results[policyTypeAdmin]) + } + + secondRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 9, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("second automatic settlement failed: %v", err) + } + if secondRun.Results[policyTypeBD].SuccessCount != 0 || secondRun.Results[policyTypeBD].SkippedCount != 1 { + t.Fatalf("bd duplicate guard mismatch: %+v", secondRun.Results[policyTypeBD]) + } + if secondRun.Results[policyTypeAdmin].SuccessCount != 0 || secondRun.Results[policyTypeAdmin].SkippedCount != 1 { + t.Fatalf("admin duplicate guard mismatch: %+v", secondRun.Results[policyTypeAdmin]) + } + + records, totalRecords, err := service.ListRecords(ctx, "lalu", recordQuery{CycleKey: "2026-01"}) + if err != nil { + t.Fatalf("list team records failed: %v", err) + } + if totalRecords != 2 || len(records) != 2 { + t.Fatalf("record count mismatch: total=%d len=%d records=%+v", totalRecords, len(records), records) + } + assertWalletBalance(t, walletDB, 4001, 100_000) + assertWalletBalance(t, walletDB, 5001, 20_000) + assertScalar(t, walletDB, `SELECT COUNT(*) FROM team_salary_settlement_records`, int64(2)) + assertScalar(t, walletDB, `SELECT COUNT(*) FROM wallet_transactions WHERE biz_type = 'team_salary_settlement'`, int64(2)) + + notDue, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 4, 8, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("non-due automatic check failed: %v", err) + } + if notDue.Due { + t.Fatalf("non-due day should not run settlement") + } +} + +func newTeamSalaryTestDB(t *testing.T, baseDSN string, suffix string) *sql.DB { + t.Helper() + cfg, err := mysqlDriver.ParseDSN(baseDSN) + if err != nil { + t.Fatalf("parse test dsn failed: %v", err) + } + dbName := fmt.Sprintf("hy_team_salary_%s_%d", suffix, time.Now().UnixNano()) + adminCfg := cfg.Clone() + adminCfg.DBName = "" + adminDB, err := sql.Open("mysql", adminCfg.FormatDSN()) + if err != nil { + t.Fatalf("open mysql admin connection failed: %v", err) + } + if _, err := adminDB.Exec("CREATE DATABASE " + quoteTeamSalaryIdentifier(dbName) + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + _ = adminDB.Close() + t.Fatalf("create test database failed: %v", err) + } + testCfg := cfg.Clone() + testCfg.DBName = dbName + db, err := sql.Open("mysql", testCfg.FormatDSN()) + if err != nil { + _ = adminDB.Close() + t.Fatalf("open test database failed: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + _, _ = adminDB.Exec("DROP DATABASE IF EXISTS " + quoteTeamSalaryIdentifier(dbName)) + _ = adminDB.Close() + }) + return db +} + +func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) { + t.Helper() + nowMS := int64(1_769_971_200_000) + execAll(t, adminDB, + `INSERT INTO admin_team_salary_policies + (id, app_code, policy_type, name, region_id, status, settlement_trigger_mode, effective_from_ms, effective_to_ms, description, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms) + VALUES + (7001, 'lalu', 'bd', 'BD automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1), + (8001, 'lalu', 'admin', 'Admin automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1)`, + `INSERT INTO admin_team_salary_policy_levels + (policy_id, level_no, threshold_usd, rate_percent, salary_usd, status, sort_order, created_at_ms, updated_at_ms) + VALUES + (7001, 1, 100.00, 8.0000, 8.00, 'active', 1, 1, 1), + (7001, 2, 200.00, 8.0000, 16.00, 'active', 2, 1, 1), + (7001, 3, 500.00, 8.0000, 40.00, 'active', 3, 1, 1), + (7001, 4, 1200.00, 8.0000, 96.00, 'active', 4, 1, 1), + (7001, 5, 2000.00, 8.0000, 160.00, 'active', 5, 1, 1), + (7001, 6, 5000.00, 9.0000, 450.00, 'active', 6, 1, 1), + (7001, 7, 10000.00, 10.0000, 1000.00, 'active', 7, 1, 1), + (8001, 1, 500.00, 20.0000, 100.00, 'active', 1, 1, 1), + (8001, 2, 1000.00, 20.0000, 200.00, 'active', 2, 1, 1), + (8001, 3, 3000.00, 20.0000, 600.00, 'active', 3, 1, 1), + (8001, 4, 5000.00, 20.0000, 1000.00, 'active', 4, 1, 1), + (8001, 5, 10000.00, 20.0000, 2000.00, 'active', 5, 1, 1), + (8001, 6, 20000.00, 20.0000, 4000.00, 'active', 6, 1, 1)`, + ) + execAll(t, userDB, + `INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`, + `INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`, + `INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`, + `INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar) VALUES + ('lalu', 3001, 'AG3001', 'Agency One', ''), + ('lalu', 3002, 'AG3002', 'Agency Two', ''), + ('lalu', 4001, 'BD4001', 'BD One', ''), + ('lalu', 5001, 'AD5001', 'Admin One', '')`, + `INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES + ('lalu', 3001, 4001, 686, 'active'), + ('lalu', 3002, 4001, 686, 'active')`, + `INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES + ('lalu', 4001, 'bd', 686, 5001, 'active'), + ('lalu', 5001, 'bd_leader', 686, 0, 'active')`, + ) + execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records + (app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, + cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, host_coin_reward_delta, + agency_salary_usd_minor_delta, residual_usd_minor_delta, status, reason, created_at_ms) + VALUES + ('lalu', 'hs_1', 'hcmd_1', 'htx_1', 'month_end', 1001, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_2', 'hcmd_2', 'htx_2', 'month_end', 1002, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_3', 'hcmd_3', 'htx_3', 'month_end', 1003, 3001, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_4', 'hcmd_4', 'htx_4', 'month_end', 1004, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_5', 'hcmd_5', 'htx_5', 'month_end', 1005, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), + ('lalu', 'hs_6', 'hcmd_6', 'htx_6', 'month_end', 1006, 3002, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d)`, nowMS, nowMS, nowMS, nowMS, nowMS, nowMS)) +} + +func teamSalaryAdminDDL() []string { + return []string{ + `CREATE TABLE admin_team_salary_policies ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + policy_type VARCHAR(24) NOT NULL, + name VARCHAR(120) NOT NULL, + region_id BIGINT NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'disabled', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic', + effective_from_ms BIGINT NOT NULL DEFAULT 0, + effective_to_ms BIGINT NOT NULL DEFAULT 0, + description VARCHAR(255) NOT NULL DEFAULT '', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + INDEX idx_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms) + )`, + `CREATE TABLE admin_team_salary_policy_levels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + policy_id BIGINT UNSIGNED NOT NULL, + level_no INT NOT NULL, + threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00, + rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000, + salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00, + status VARCHAR(24) NOT NULL DEFAULT 'active', + sort_order INT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + INDEX idx_policy_level (policy_id, threshold_usd) + )`, + } +} + +func teamSalaryUserDDL() []string { + return []string{ + `CREATE TABLE users ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + current_display_user_id VARCHAR(64) NOT NULL DEFAULT '', + username VARCHAR(128) NULL, + avatar VARCHAR(512) NULL, + PRIMARY KEY (app_code, user_id) + )`, + `CREATE TABLE regions ( + app_code VARCHAR(32) NOT NULL, + region_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + PRIMARY KEY (app_code, region_id) + )`, + `CREATE TABLE countries ( + app_code VARCHAR(32) NOT NULL, + country_id BIGINT NOT NULL, + country_code VARCHAR(16) NOT NULL, + name VARCHAR(128) NOT NULL, + PRIMARY KEY (app_code, country_id) + )`, + `CREATE TABLE region_countries ( + app_code VARCHAR(32) NOT NULL, + region_id BIGINT NOT NULL, + country_code VARCHAR(16) NOT NULL, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, region_id, country_code) + )`, + `CREATE TABLE agencies ( + app_code VARCHAR(32) NOT NULL, + owner_user_id BIGINT NOT NULL, + parent_bd_user_id BIGINT NOT NULL DEFAULT 0, + region_id BIGINT NOT NULL, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, owner_user_id) + )`, + `CREATE TABLE bd_profiles ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(24) NOT NULL, + region_id BIGINT NOT NULL, + parent_leader_user_id BIGINT NOT NULL DEFAULT 0, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, user_id, role) + )`, + } +} + +func teamSalaryWalletDDL() []string { + return []string{ + `CREATE TABLE wallet_accounts ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + user_id BIGINT NOT NULL, + asset_type VARCHAR(32) NOT NULL, + available_amount BIGINT NOT NULL, + frozen_amount BIGINT NOT NULL, + version BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id, asset_type) + )`, + `CREATE TABLE wallet_transactions ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + transaction_id VARCHAR(96) NOT NULL, + command_id VARCHAR(128) NOT NULL, + biz_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + request_hash VARCHAR(128) NOT NULL, + external_ref VARCHAR(128) NOT NULL DEFAULT '', + metadata_json JSON NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, transaction_id), + UNIQUE KEY uk_wallet_tx_command (app_code, command_id) + )`, + `CREATE TABLE wallet_entries ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + transaction_id VARCHAR(96) NOT NULL, + user_id BIGINT NOT NULL, + asset_type VARCHAR(32) NOT NULL, + available_delta BIGINT NOT NULL, + frozen_delta BIGINT NOT NULL, + available_after BIGINT NOT NULL, + frozen_after BIGINT NOT NULL, + counterparty_user_id BIGINT NOT NULL DEFAULT 0, + room_id VARCHAR(96) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL + )`, + `CREATE TABLE wallet_outbox ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + command_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + asset_type VARCHAR(32) NOT NULL, + available_delta BIGINT NOT NULL, + frozen_delta BIGINT NOT NULL, + payload JSON NOT NULL, + status VARCHAR(32) NOT NULL, + worker_id VARCHAR(128) NOT NULL DEFAULT '', + lock_until_ms BIGINT NULL, + retry_count INT NOT NULL DEFAULT 0, + next_retry_at_ms BIGINT NULL, + last_error TEXT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id) + )`, + `CREATE TABLE host_salary_settlement_records ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + settlement_id VARCHAR(128) NOT NULL, + command_id VARCHAR(128) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + settlement_type VARCHAR(24) NOT NULL, + user_id BIGINT NOT NULL, + agency_owner_user_id BIGINT NOT NULL DEFAULT 0, + cycle_key VARCHAR(16) NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + level_no INT NOT NULL DEFAULT 0, + total_diamonds BIGINT NOT NULL DEFAULT 0, + host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + host_coin_reward_delta BIGINT NOT NULL DEFAULT 0, + agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0, + status VARCHAR(24) NOT NULL DEFAULT 'succeeded', + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, settlement_id), + UNIQUE KEY uk_host_command (app_code, command_id) + )`, + } +} + +func execAll(t *testing.T, db *sql.DB, statements ...string) { + t.Helper() + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("exec statement failed: %v\n%s", err, statement) + } + } +} + +func assertWalletBalance(t *testing.T, db *sql.DB, userID int64, expected int64) { + t.Helper() + var amount int64 + if err := db.QueryRow(`SELECT available_amount FROM wallet_accounts WHERE app_code = 'lalu' AND user_id = ? AND asset_type = 'USD_BALANCE'`, userID).Scan(&amount); err != nil { + t.Fatalf("query wallet balance failed: %v", err) + } + if amount != expected { + t.Fatalf("wallet balance mismatch for user %d: got=%d want=%d", userID, amount, expected) + } +} + +func assertScalar(t *testing.T, db *sql.DB, query string, expected int64) { + t.Helper() + var got int64 + if err := db.QueryRow(query).Scan(&got); err != nil { + t.Fatalf("query scalar failed: %v", err) + } + if got != expected { + t.Fatalf("scalar mismatch for %s: got=%d want=%d", query, got, expected) + } +} + +func quoteTeamSalaryIdentifier(value string) string { + return "`" + strings.ReplaceAll(value, "`", "``") + "`" +} diff --git a/server/admin/internal/repository/host_agency_salary_policy_repository.go b/server/admin/internal/repository/host_agency_salary_policy_repository.go new file mode 100644 index 00000000..3793c402 --- /dev/null +++ b/server/admin/internal/repository/host_agency_salary_policy_repository.go @@ -0,0 +1,154 @@ +package repository + +import ( + "strings" + + "hyapp-admin-server/internal/model" + + "gorm.io/gorm" +) + +type HostAgencySalaryPolicyListOptions struct { + AppCode string + Keyword string + RegionID int64 + Status string + SettlementMode string + SettlementTriggerMode string + Page int + PageSize int +} + +func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListOptions) ([]model.HostAgencySalaryPolicy, int64, error) { + // 工资政策是应用隔离配置,所有查询都必须先限定 app_code,再叠加运营筛选条件。 + db := s.db.Model(&model.HostAgencySalaryPolicy{}).Where("app_code = ?", strings.TrimSpace(options.AppCode)) + if options.RegionID > 0 { + // 第一阶段仍按单区域保存,区域筛选直接命中主表索引。 + db = db.Where("region_id = ?", options.RegionID) + } + if status := strings.TrimSpace(options.Status); status != "" { + db = db.Where("status = ?", status) + } + if mode := strings.TrimSpace(options.SettlementMode); mode != "" { + db = db.Where("settlement_mode = ?", mode) + } + if triggerMode := strings.TrimSpace(options.SettlementTriggerMode); triggerMode != "" { + // 触发方式是自动任务和人工结算入口的分界,列表需要能直接筛出需要人工处理的政策。 + db = db.Where("settlement_trigger_mode = ?", triggerMode) + } + if keyword := strings.TrimSpace(options.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + page, pageSize := normalizePage(options.Page, options.PageSize) + var items []model.HostAgencySalaryPolicy + // 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。 + err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + return tx.Order("level_no ASC") + }).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error + return items, total, err +} + +func (s *Store) GetHostAgencySalaryPolicy(appCode string, id uint) (model.HostAgencySalaryPolicy, error) { + var item model.HostAgencySalaryPolicy + // 单条读取也预加载等级,更新时会用完整等级包替换原明细。 + err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + return tx.Order("level_no ASC") + }).Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error + return item, err +} + +func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // GORM 创建主表时不让它自动级联 levels;先拿到 policy_id,再批量写明细,约束更直观。 + levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Create(item).Error; err != nil { + return err + } + for index := range levels { + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + // 恢复内存对象,service 可以把创建后的完整对象直接转成响应。 + item.Levels = levels + return nil + }) +} + +func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 等级是整包配置,不做逐条 diff;主表保存成功后删除旧明细,再插入新梯度。 + levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Save(item).Error; err != nil { + return err + } + if err := tx.Where("policy_id = ?", item.ID).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil { + return err + } + for index := range levels { + levels[index].ID = 0 + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + // 重新挂回最新明细,保证返回给前端的是本次提交后的等级集。 + item.Levels = levels + return nil + }) +} + +func (s *Store) UpdateHostAgencySalaryPolicyPublishState(appCode string, id uint, status string, publishError string, publishedAtMS int64, actorID uint) error { + // 发布状态属于 admin 配置到 wallet 运行快照的同步结果,不影响政策主配置字段和等级明细。 + return s.db.Model(&model.HostAgencySalaryPolicy{}). + Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id). + Updates(map[string]any{ + "publish_status": strings.TrimSpace(status), + "publish_error": strings.TrimSpace(publishError), + "published_at_ms": publishedAtMS, + "published_by_admin_id": actorID, + "updated_by_admin_id": actorID, + }).Error +} + +func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 先删明细再删主表,兼容没有外键级联的部署环境。 + if err := tx.Where("policy_id = ?", id).Delete(&model.HostAgencySalaryLevel{}).Error; err != nil { + return err + } + return tx.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.HostAgencySalaryPolicy{}).Error + }) +} + +func (s *Store) HasOverlappingActiveHostAgencySalaryPolicy(appCode string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) { + // 与当前政策开始时间相交:已有长期有效或已有结束时间晚于当前开始。 + db := s.db.Model(&model.HostAgencySalaryPolicy{}). + Where("app_code = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), regionID, "active"). + Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS) + if effectiveToMS > 0 { + // 当前政策不是长期有效时,再要求已有政策开始时间早于当前结束,形成标准半开区间相交判断。 + db = db.Where("effective_from_ms < ?", effectiveToMS) + } + if excludeID > 0 { + db = db.Where("id <> ?", excludeID) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index f4ecf0ca..8b630098 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -66,6 +66,10 @@ func (s *Store) AutoMigrate() error { &model.AppBanner{}, &model.AppVersion{}, &model.AppExploreTab{}, + &model.HostAgencySalaryPolicy{}, + &model.HostAgencySalaryLevel{}, + &model.TeamSalaryPolicy{}, + &model.TeamSalaryLevel{}, &model.RefreshToken{}, &model.LoginLog{}, &model.OperationLog{}, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 86533e53..8bd8e9a3 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -63,6 +63,17 @@ var defaultPermissions = []model.Permission{ {Name: "区域更新", Code: "region:update", Kind: "button"}, {Name: "区域状态", Code: "region:status", Kind: "button"}, {Name: "主播查看", Code: "host:view", Kind: "menu"}, + {Name: "主播代理工资政策查看", Code: "host-agency-policy:view", Kind: "menu"}, + {Name: "主播代理工资政策创建", Code: "host-agency-policy:create", Kind: "button"}, + {Name: "主播代理工资政策更新", Code: "host-agency-policy:update", Kind: "button"}, + {Name: "主播代理工资政策删除", Code: "host-agency-policy:delete", Kind: "button"}, + {Name: "主播代理工资政策发布", Code: "host-agency-policy:publish", Kind: "button"}, + {Name: "BD/Admin 工资政策查看", Code: "team-salary-policy:view", Kind: "button"}, + {Name: "BD/Admin 工资政策创建", Code: "team-salary-policy:create", Kind: "button"}, + {Name: "BD/Admin 工资政策更新", Code: "team-salary-policy:update", Kind: "button"}, + {Name: "BD/Admin 工资政策删除", Code: "team-salary-policy:delete", Kind: "button"}, + {Name: "工资结算查看", Code: "host-salary-settlement:view", Kind: "menu"}, + {Name: "工资手动结算", Code: "host-salary-settlement:settle", Kind: "button"}, {Name: "Agency 查看", Code: "agency:view", Kind: "menu"}, {Name: "Agency 创建", Code: "agency:create", Kind: "button"}, {Name: "Agency 状态", Code: "agency:status", Kind: "button"}, @@ -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 } diff --git a/server/admin/internal/repository/team_salary_policy_repository.go b/server/admin/internal/repository/team_salary_policy_repository.go new file mode 100644 index 00000000..89492a04 --- /dev/null +++ b/server/admin/internal/repository/team_salary_policy_repository.go @@ -0,0 +1,132 @@ +package repository + +import ( + "strings" + + "hyapp-admin-server/internal/model" + + "gorm.io/gorm" +) + +type TeamSalaryPolicyListOptions struct { + AppCode string + PolicyType string + Keyword string + RegionID int64 + Status string + SettlementTriggerMode string + Page int + PageSize int +} + +func (s *Store) ListTeamSalaryPolicies(options TeamSalaryPolicyListOptions) ([]model.TeamSalaryPolicy, int64, error) { + // BD/Admin 政策共表保存,查询必须同时限定 app_code 和 policy_type,避免两个 tab 的配置互相混入。 + db := s.db.Model(&model.TeamSalaryPolicy{}). + Where("app_code = ? AND policy_type = ?", strings.TrimSpace(options.AppCode), strings.TrimSpace(options.PolicyType)) + if options.RegionID > 0 { + db = db.Where("region_id = ?", options.RegionID) + } + if status := strings.TrimSpace(options.Status); status != "" { + db = db.Where("status = ?", status) + } + if triggerMode := strings.TrimSpace(options.SettlementTriggerMode); triggerMode != "" { + db = db.Where("settlement_trigger_mode = ?", triggerMode) + } + if keyword := strings.TrimSpace(options.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + page, pageSize := normalizePage(options.Page, options.PageSize) + var items []model.TeamSalaryPolicy + err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + // 等级顺序是差额结算的业务顺序,列表和编辑都必须使用同一排序。 + return tx.Order("level_no ASC") + }).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error + return items, total, err +} + +func (s *Store) GetTeamSalaryPolicy(appCode string, policyType string, id uint) (model.TeamSalaryPolicy, error) { + var item model.TeamSalaryPolicy + err := s.db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { + return tx.Order("level_no ASC") + }).Where("app_code = ? AND policy_type = ? AND id = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), id).First(&item).Error + return item, err +} + +func (s *Store) CreateTeamSalaryPolicy(item *model.TeamSalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 主表和等级整包保存,避免部分等级成功造成一套不可结算的政策梯度。 + levels := append([]model.TeamSalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Create(item).Error; err != nil { + return err + } + for index := range levels { + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + item.Levels = levels + return nil + }) +} + +func (s *Store) UpdateTeamSalaryPolicy(item *model.TeamSalaryPolicy) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 编辑按整包替换等级,保证 UI 提交内容就是下一次结算读取的完整梯度。 + levels := append([]model.TeamSalaryLevel(nil), item.Levels...) + item.Levels = nil + if err := tx.Save(item).Error; err != nil { + return err + } + if err := tx.Where("policy_id = ?", item.ID).Delete(&model.TeamSalaryLevel{}).Error; err != nil { + return err + } + for index := range levels { + levels[index].ID = 0 + levels[index].PolicyID = item.ID + } + if len(levels) > 0 { + if err := tx.Create(&levels).Error; err != nil { + return err + } + } + item.Levels = levels + return nil + }) +} + +func (s *Store) DeleteTeamSalaryPolicy(appCode string, policyType string, id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("policy_id = ?", id).Delete(&model.TeamSalaryLevel{}).Error; err != nil { + return err + } + return tx.Where("app_code = ? AND policy_type = ? AND id = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), id).Delete(&model.TeamSalaryPolicy{}).Error + }) +} + +func (s *Store) HasOverlappingActiveTeamSalaryPolicy(appCode string, policyType string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) { + // 同一类型、同一区域、同一时间只能有一条启用政策,避免结算时 BD/Admin 可命中多套比例。 + db := s.db.Model(&model.TeamSalaryPolicy{}). + Where("app_code = ? AND policy_type = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), strings.TrimSpace(policyType), regionID, "active"). + Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS) + if effectiveToMS > 0 { + db = db.Where("effective_from_ms < ?", effectiveToMS) + } + if excludeID > 0 { + db = db.Where("id <> ?", excludeID) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 24c21854..33db5cc3 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -17,7 +17,9 @@ import ( "hyapp-admin-server/internal/modules/firstrechargereward" gamemanagement "hyapp-admin-server/internal/modules/gamemanagement" "hyapp-admin-server/internal/modules/health" + "hyapp-admin-server/internal/modules/hostagencypolicy" "hyapp-admin-server/internal/modules/hostorg" + "hyapp-admin-server/internal/modules/hostsalarysettlement" "hyapp-admin-server/internal/modules/job" "hyapp-admin-server/internal/modules/levelconfig" "hyapp-admin-server/internal/modules/luckygift" @@ -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) diff --git a/server/admin/migrations/027_host_agency_salary_policy.sql b/server/admin/migrations/027_host_agency_salary_policy.sql new file mode 100644 index 00000000..446ff2b8 --- /dev/null +++ b/server/admin/migrations/027_host_agency_salary_policy.sql @@ -0,0 +1,88 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policies ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + name VARCHAR(120) NOT NULL COMMENT '政策名称', + region_id BIGINT NOT NULL COMMENT '适用区域 ID', + status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled', + settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式:daily/half_month', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual', + gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例', + residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例', + effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms', + effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效', + description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '后台备注', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_admin_host_agency_salary_policy_name (app_code, region_id, name), + INDEX idx_admin_host_agency_salary_policy_region (app_code, region_id, status, effective_from_ms, effective_to_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策表'; + +CREATE TABLE IF NOT EXISTS admin_host_agency_salary_policy_levels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID', + level_no INT NOT NULL COMMENT '等级', + required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石', + host_salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '主播累计美元工资', + host_coin_reward BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计金币奖励', + agency_salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '代理累计美元工资', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_admin_host_agency_salary_level (policy_id, level_no), + INDEX idx_admin_host_agency_salary_level_policy (policy_id, required_diamonds) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级表'; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('主播代理工资政策查看', 'host-agency-policy:view', 'menu', '', @now_ms, @now_ms), + ('主播代理工资政策创建', 'host-agency-policy:create', 'button', '', @now_ms, @now_ms), + ('主播代理工资政策更新', 'host-agency-policy:update', 'button', '', @now_ms, @now_ms), + ('主播代理工资政策删除', 'host-agency-policy:delete', 'button', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '工资政策', 'host-agency-policy', '/host/salary-policies', 'wallet', 'host-agency-policy:view', 85, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'host-org' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +UPDATE admin_menus +SET sort = 86, updated_at_ms = @now_ms +WHERE code = 'host-org-coin-sellers' AND sort < 86; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ( + 'host-agency-policy:view', + 'host-agency-policy:create', + 'host-agency-policy:update', + 'host-agency-policy:delete' + ); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'host-agency-policy:view'; diff --git a/server/admin/migrations/028_report_navigation.sql b/server/admin/migrations/028_report_navigation.sql new file mode 100644 index 00000000..958888d9 --- /dev/null +++ b/server/admin/migrations/028_report_navigation.sql @@ -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'; diff --git a/server/admin/migrations/029_host_salary_policy_publish_and_settlement.sql b/server/admin/migrations/029_host_salary_policy_publish_and_settlement.sql new file mode 100644 index 00000000..1d7fc8eb --- /dev/null +++ b/server/admin/migrations/029_host_salary_policy_publish_and_settlement.sql @@ -0,0 +1,83 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'publish_status') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN publish_status VARCHAR(24) NOT NULL DEFAULT ''draft'' COMMENT ''发布状态:draft/published/failed'' AFTER updated_by_admin_id', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'publish_error') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN publish_error VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''最近一次发布失败原因'' AFTER publish_status', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'published_at_ms') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''最近一次成功发布时间,UTC epoch ms'' AFTER publish_error', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'published_by_admin_id') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN published_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最近一次成功发布管理员 ID'' AFTER published_at_ms', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('主播代理工资政策发布', 'host-agency-policy:publish', 'button', '', @now_ms, @now_ms), + ('主播工资结算记录查看', 'host-salary-settlement:view', 'menu', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '结算记录', 'host-salary-settlement', '/host/salary-settlements', 'receipt', 'host-salary-settlement:view', 86, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'host-org' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +UPDATE admin_menus +SET sort = 87, updated_at_ms = @now_ms +WHERE code = 'host-org-coin-sellers' AND sort < 87; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ( + 'host-agency-policy:publish', + 'host-salary-settlement:view' + ); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'host-salary-settlement:view'; diff --git a/server/admin/migrations/030_salary_policy_trigger_mode.sql b/server/admin/migrations/030_salary_policy_trigger_mode.sql new file mode 100644 index 00000000..ba7685eb --- /dev/null +++ b/server/admin/migrations/030_salary_policy_trigger_mode.sql @@ -0,0 +1,24 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- 触发方式是自动任务和人工结算的配置边界;老环境执行过 027 时需要幂等补列。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'settlement_trigger_mode') = 0, + 'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT ''automatic'' COMMENT ''结算触发方式:automatic/manual'' AFTER settlement_mode', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 产品菜单统一叫“工资结算”,页面可同时承载待结算与历史记录查询。 +UPDATE admin_menus +SET title = '工资结算', + updated_at_ms = @now_ms +WHERE code = 'host-salary-settlement'; + +UPDATE admin_permissions +SET name = '工资结算查看', + updated_at_ms = @now_ms +WHERE code = 'host-salary-settlement:view'; diff --git a/server/admin/migrations/031_team_salary_policy.sql b/server/admin/migrations/031_team_salary_policy.sql new file mode 100644 index 00000000..1411d03b --- /dev/null +++ b/server/admin/migrations/031_team_salary_policy.sql @@ -0,0 +1,67 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +CREATE TABLE IF NOT EXISTS admin_team_salary_policies ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + policy_type VARCHAR(24) NOT NULL COMMENT '政策类型:bd/admin', + name VARCHAR(120) NOT NULL COMMENT '政策名称', + region_id BIGINT NOT NULL COMMENT '适用区域 ID', + status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled', + settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual', + effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms', + effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,0 表示长期有效', + description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '后台备注', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_admin_team_salary_policy_name (app_code, policy_type, region_id, name), + INDEX idx_admin_team_salary_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资政策表'; + +CREATE TABLE IF NOT EXISTS admin_team_salary_policy_levels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID', + level_no INT NOT NULL COMMENT '等级', + threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '收入门槛美元;BD 为下属主播工资合计,Admin 为下属 BD 工资合计', + rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000 COMMENT '提成比例百分比,例如 8 表示 8%', + salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '达到该等级后的累计应得工资', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '等级状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_admin_team_salary_level (policy_id, level_no), + INDEX idx_admin_team_salary_level_policy (policy_id, threshold_usd) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD/Admin 工资政策等级表'; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('BD/Admin 工资政策查看', 'team-salary-policy:view', 'button', '', @now_ms, @now_ms), + ('BD/Admin 工资政策创建', 'team-salary-policy:create', 'button', '', @now_ms, @now_ms), + ('BD/Admin 工资政策更新', 'team-salary-policy:update', 'button', '', @now_ms, @now_ms), + ('BD/Admin 工资政策删除', 'team-salary-policy:delete', 'button', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ( + 'team-salary-policy:view', + 'team-salary-policy:create', + 'team-salary-policy:update', + 'team-salary-policy:delete' + ); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'team-salary-policy:view'; diff --git a/server/admin/migrations/032_team_salary_settlement.sql b/server/admin/migrations/032_team_salary_settlement.sql new file mode 100644 index 00000000..5ef5ada8 --- /dev/null +++ b/server/admin/migrations/032_team_salary_settlement.sql @@ -0,0 +1,12 @@ +SET @now_ms := UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000; + +INSERT IGNORE INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) +VALUES + ('工资手动结算', 'host-salary-settlement:settle', 'button', '允许在工资结算菜单批量手动结算 Host/Agency/BD/Admin 工资', @now_ms, @now_ms); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code = 'host-salary-settlement:settle'; diff --git a/services/activity-service/internal/app/app.go b/services/activity-service/internal/app/app.go index 82694772..894a1d7e 100644 --- a/services/activity-service/internal/app/app.go +++ b/services/activity-service/internal/app/app.go @@ -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 } diff --git a/services/activity-service/internal/domain/broadcast/broadcast.go b/services/activity-service/internal/domain/broadcast/broadcast.go index 64eb3f98..1117fe9a 100644 --- a/services/activity-service/internal/domain/broadcast/broadcast.go +++ b/services/activity-service/internal/domain/broadcast/broadcast.go @@ -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 diff --git a/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go b/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go index 93d6ff81..b962cc9b 100644 --- a/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go +++ b/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go @@ -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) +} diff --git a/services/activity-service/internal/service/broadcast/service.go b/services/activity-service/internal/service/broadcast/service.go index a9e80ded..e69f2bd6 100644 --- a/services/activity-service/internal/service/broadcast/service.go +++ b/services/activity-service/internal/service/broadcast/service.go @@ -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 diff --git a/services/cron-service/internal/app/app.go b/services/cron-service/internal/app/app.go index 21873897..1ffc9789 100644 --- a/services/cron-service/internal/app/app.go +++ b/services/cron-service/internal/app/app.go @@ -14,6 +14,7 @@ import ( activityv1 "hyapp.local/api/proto/activity/v1" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/grpchealth" "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" @@ -35,6 +36,7 @@ type App struct { userConn *grpc.ClientConn activityConn *grpc.ClientConn gameConn *grpc.ClientConn + walletConn *grpc.ClientConn workerCtx context.Context workerCancel context.CancelFunc workerWG sync.WaitGroup @@ -78,6 +80,15 @@ func New(cfg config.Config) (*App, error) { _ = repository.Close() return nil, err } + walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + _ = gameConn.Close() + _ = activityConn.Close() + _ = userConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service"))) health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{ @@ -87,6 +98,7 @@ func New(cfg config.Config) (*App, error) { healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) if err != nil { + _ = walletConn.Close() _ = gameConn.Close() _ = activityConn.Close() _ = userConn.Close() @@ -99,14 +111,18 @@ func New(cfg config.Config) (*App, error) { userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn)) activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn)) gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn)) + walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn)) taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{ - "login_ip_risk": userCron.ProcessLoginIPRiskBatch, - "user_region_rebuild": userCron.ProcessRegionRebuildBatch, - "mic_open_session_compensation": userCron.CompensateMicOpenSessions, - "message_fanout": activityCron.ProcessMessageFanoutBatch, - "growth_level_reward": activityCron.ProcessLevelRewardBatch, - "achievement_reward": activityCron.ProcessAchievementRewardBatch, - "game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch, + "login_ip_risk": userCron.ProcessLoginIPRiskBatch, + "user_region_rebuild": userCron.ProcessRegionRebuildBatch, + "mic_open_session_compensation": userCron.CompensateMicOpenSessions, + "message_fanout": activityCron.ProcessMessageFanoutBatch, + "growth_level_reward": activityCron.ProcessLevelRewardBatch, + "achievement_reward": activityCron.ProcessAchievementRewardBatch, + "game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch, + "host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch, + "host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch, + "host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch, }) return &App{ @@ -119,6 +135,7 @@ func New(cfg config.Config) (*App, error) { userConn: userConn, activityConn: activityConn, gameConn: gameConn, + walletConn: walletConn, workerCtx: workerCtx, workerCancel: workerCancel, }, nil @@ -170,6 +187,9 @@ func (a *App) Close() { if a.gameConn != nil { _ = a.gameConn.Close() } + if a.walletConn != nil { + _ = a.walletConn.Close() + } }) } diff --git a/services/cron-service/internal/config/config.go b/services/cron-service/internal/config/config.go index e7a706f4..edf5e743 100644 --- a/services/cron-service/internal/config/config.go +++ b/services/cron-service/internal/config/config.go @@ -172,6 +172,30 @@ func defaultTasks() map[string]TaskConfig { BatchSize: 100, AppCodes: []string{defaultAppCode}, }, + "host_salary_daily_settlement": { + Enabled: true, + Interval: "24h", + Timeout: "30s", + LockTTL: "2m", + BatchSize: 200, + AppCodes: []string{defaultAppCode}, + }, + "host_salary_half_month_settlement": { + Enabled: true, + Interval: "24h", + Timeout: "30s", + LockTTL: "2m", + BatchSize: 200, + AppCodes: []string{defaultAppCode}, + }, + "host_salary_month_end": { + Enabled: true, + Interval: "24h", + Timeout: "60s", + LockTTL: "5m", + BatchSize: 200, + AppCodes: []string{defaultAppCode}, + }, "mic_open_session_compensation": { Enabled: true, Interval: "60s", diff --git a/services/cron-service/internal/config/config_test.go b/services/cron-service/internal/config/config_test.go new file mode 100644 index 00000000..d7b8ddbf --- /dev/null +++ b/services/cron-service/internal/config/config_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +func TestDefaultHostSalaryCronTasksAreEnabled(t *testing.T) { + cfg := Default() + for _, name := range []string{ + "host_salary_daily_settlement", + "host_salary_half_month_settlement", + "host_salary_month_end", + } { + task, ok := cfg.Tasks[name] + if !ok { + t.Fatalf("host salary cron task %s missing", name) + } + if !task.Enabled { + t.Fatalf("host salary cron task %s should be enabled globally; policy settlement_mode decides who is processed", name) + } + } +} diff --git a/services/cron-service/internal/integration/wallet.go b/services/cron-service/internal/integration/wallet.go new file mode 100644 index 00000000..79c823f7 --- /dev/null +++ b/services/cron-service/internal/integration/wallet.go @@ -0,0 +1,80 @@ +package integration + +import ( + "context" + "time" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/services/cron-service/internal/scheduler" +) + +// WalletCronClient delegates wallet-owned salary settlement batches to wallet-service. +type WalletCronClient struct { + client walletv1.WalletCronServiceClient +} + +var walletCronNowUTC = func() time.Time { + return time.Now().UTC() +} + +// NewWalletCronClient creates scheduler handlers backed by wallet-service gRPC. +func NewWalletCronClient(client walletv1.WalletCronServiceClient) *WalletCronClient { + return &WalletCronClient{client: client} +} + +func (c *WalletCronClient) ProcessHostSalaryDailySettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) { + resp, err := c.client.ProcessHostSalaryDailySettlementBatch(ctx, walletCronBatchRequest(req)) + return walletCronBatchResult(resp), err +} + +func (c *WalletCronClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) { + if !isHostSalaryHalfMonthDue(walletCronNowUTC()) { + // 半月结是否适用于某个主播由后台工资政策 settlement_mode 决定;cron 这里只负责在月中打开一次全局触发窗口。 + return scheduler.BatchResult{}, nil + } + resp, err := c.client.ProcessHostSalaryHalfMonthSettlementBatch(ctx, walletCronBatchRequest(req)) + return walletCronBatchResult(resp), err +} + +func (c *WalletCronClient) ProcessHostSalaryMonthEndBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) { + if !isHostSalaryMonthEndDue(walletCronNowUTC()) { + // 月末批次负责最终等级差额、剩余钻石折美元和周期关闭;非月末触发只记录一次空跑,不进入 wallet 账务。 + return scheduler.BatchResult{}, nil + } + resp, err := c.client.ProcessHostSalaryMonthEndBatch(ctx, walletCronBatchRequest(req)) + return walletCronBatchResult(resp), err +} + +func isHostSalaryHalfMonthDue(now time.Time) bool { + return now.UTC().Day() == 15 +} + +func isHostSalaryMonthEndDue(now time.Time) bool { + utc := now.UTC() + return utc.AddDate(0, 0, 1).Day() == 1 +} + +func walletCronBatchRequest(req scheduler.BatchRequest) *walletv1.CronBatchRequest { + // scheduler 的 RunID 同时作为请求幂等标识传给 wallet;wallet 内部会继续按主播和周期生成账务幂等键。 + return &walletv1.CronBatchRequest{ + RequestId: req.RunID, + AppCode: req.AppCode, + RunId: req.RunID, + WorkerId: req.WorkerID, + BatchSize: int32(req.BatchSize), + LockTtlMs: req.LockTTL.Milliseconds(), + } +} + +func walletCronBatchResult(resp *walletv1.CronBatchResponse) scheduler.BatchResult { + if resp == nil { + return scheduler.BatchResult{} + } + return scheduler.BatchResult{ + ClaimedCount: int(resp.GetClaimedCount()), + ProcessedCount: int(resp.GetProcessedCount()), + SuccessCount: int(resp.GetSuccessCount()), + FailureCount: int(resp.GetFailureCount()), + HasMore: resp.GetHasMore(), + } +} diff --git a/services/cron-service/internal/integration/wallet_test.go b/services/cron-service/internal/integration/wallet_test.go new file mode 100644 index 00000000..5b443d35 --- /dev/null +++ b/services/cron-service/internal/integration/wallet_test.go @@ -0,0 +1,30 @@ +package integration + +import ( + "testing" + "time" +) + +func TestHostSalarySettlementDueDates(t *testing.T) { + tests := []struct { + name string + now time.Time + wantHalfMonth bool + wantMonthEnd bool + }{ + {name: "normal day", now: time.Date(2026, time.May, 14, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: false}, + {name: "half month day", now: time.Date(2026, time.May, 15, 10, 0, 0, 0, time.UTC), wantHalfMonth: true, wantMonthEnd: false}, + {name: "month end in 31 day month", now: time.Date(2026, time.May, 31, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: true}, + {name: "month end in 28 day month", now: time.Date(2026, time.February, 28, 10, 0, 0, 0, time.UTC), wantHalfMonth: false, wantMonthEnd: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isHostSalaryHalfMonthDue(tt.now); got != tt.wantHalfMonth { + t.Fatalf("half-month due mismatch: got %v want %v", got, tt.wantHalfMonth) + } + if got := isHostSalaryMonthEndDue(tt.now); got != tt.wantMonthEnd { + t.Fatalf("month-end due mismatch: got %v want %v", got, tt.wantMonthEnd) + } + }) + } +} diff --git a/services/gateway-service/internal/client/user_client.go b/services/gateway-service/internal/client/user_client.go index e2fdf20b..cad45b37 100644 --- a/services/gateway-service/internal/client/user_client.go +++ b/services/gateway-service/internal/client/user_client.go @@ -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) } diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index f91fcb66..7ef19858 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -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() { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 5a543f29..9cacd918 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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{ { diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index 541ab66f..c604af2d 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -16,6 +16,7 @@ type Handler struct { roomQueryClient client.RoomQueryClient userProfileClient client.UserProfileClient userSocialClient client.UserSocialClient + userHostClient client.UserHostClient walletClient client.WalletClient rtcTokenConfig tencentrtc.TokenConfig } @@ -26,6 +27,7 @@ type Config struct { RoomQueryClient client.RoomQueryClient UserProfileClient client.UserProfileClient UserSocialClient client.UserSocialClient + UserHostClient client.UserHostClient WalletClient client.WalletClient RTCTokenConfig tencentrtc.TokenConfig } @@ -37,6 +39,7 @@ func New(config Config) *Handler { roomQueryClient: config.RoomQueryClient, userProfileClient: config.UserProfileClient, userSocialClient: config.UserSocialClient, + userHostClient: config.UserHostClient, walletClient: config.WalletClient, rtcTokenConfig: config.RTCTokenConfig, } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index c67ee8b7..3e83aaf6 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -1259,19 +1259,50 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } + targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs)) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{ - Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), - TargetType: targetType, - TargetUserIds: targetUserIDs, - TargetUserId: firstUserID(targetUserIDs), - GiftId: body.GiftID, - GiftCount: body.GiftCount, - PoolId: body.PoolID, + Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), + TargetType: targetType, + TargetUserIds: targetUserIDs, + TargetUserId: firstUserID(targetUserIDs), + GiftId: body.GiftID, + GiftCount: body.GiftCount, + PoolId: body.PoolID, + TargetIsHost: targetIsHost, + TargetHostRegionId: targetHostRegionID, + TargetAgencyOwnerUserId: targetAgencyOwnerUserID, }) httpkit.Write(writer, request, resp, err) } +func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) { + if targetUserID <= 0 || h.userHostClient == nil { + // 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。 + return false, 0, 0, nil + } + resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{ + Meta: httpkit.UserMeta(request, ""), + UserId: targetUserID, + }) + if err != nil { + if xerr.ReasonFromGRPC(err) == xerr.NotFound { + return false, 0, 0, nil + } + return false, 0, 0, err + } + profile := resp.GetHostProfile() + if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 { + // 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。 + return false, 0, 0, nil + } + return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil +} + // batchRoomDisplayProfiles 返回房间和公屏专用展示资料。 func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) { if h.userProfileClient == nil { diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 8c35629f..8eb73abb 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -27,6 +27,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { RoomQueryClient: h.roomQueryClient, UserProfileClient: h.userProfileClient, UserSocialClient: h.userSocialClient, + UserHostClient: h.userHostClient, WalletClient: h.walletClient, RTCTokenConfig: tencentrtc.TokenConfig{ Enabled: h.tencentRTC.Enabled, @@ -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, diff --git a/services/gateway-service/internal/transport/http/social_handler_test.go b/services/gateway-service/internal/transport/http/social_handler_test.go index e833a645..8b14ddc3 100644 --- a/services/gateway-service/internal/transport/http/social_handler_test.go +++ b/services/gateway-service/internal/transport/http/social_handler_test.go @@ -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{}) diff --git a/services/gateway-service/internal/transport/http/userapi/handler.go b/services/gateway-service/internal/transport/http/userapi/handler.go index c4397cc1..ec829bbb 100644 --- a/services/gateway-service/internal/transport/http/userapi/handler.go +++ b/services/gateway-service/internal/transport/http/userapi/handler.go @@ -61,6 +61,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers { ListMyFriendApplications: h.listMyFriendApplications, GetMyProfile: h.getMyProfile, UserSocialAction: h.userSocialAction, + SubmitReport: h.submitReport, } } diff --git a/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go b/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go index 3dde2b05..00a2f6a4 100644 --- a/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/host_agency_handler.go @@ -17,6 +17,8 @@ type hostAgencyData struct { RegionID int64 `json:"region_id"` ParentBDUserID string `json:"parent_bd_user_id,omitempty"` Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + HostCount int32 `json:"host_count"` Status string `json:"status"` JoinEnabled bool `json:"join_enabled"` MaxHosts int32 `json:"max_hosts"` @@ -48,17 +50,13 @@ func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.R if keyword == "" { keyword = strings.TrimSpace(request.URL.Query().Get("keyword")) } - if keyword == "" { - httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") - return - } - pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) + pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 100) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } - if pageSize > 20 { - pageSize = 20 + if pageSize > 100 { + pageSize = 100 } resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{ Meta: httpkit.UserMeta(request, ""), @@ -123,6 +121,8 @@ func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData { RegionID: agency.GetRegionId(), ParentBDUserID: int64String(agency.GetParentBdUserId()), Name: agency.GetName(), + Avatar: agency.GetAvatar(), + HostCount: agency.GetHostCount(), Status: agency.GetStatus(), JoinEnabled: agency.GetJoinEnabled(), MaxHosts: agency.GetMaxHosts(), diff --git a/services/gateway-service/internal/transport/http/userapi/report_handler.go b/services/gateway-service/internal/transport/http/userapi/report_handler.go new file mode 100644 index 00000000..e9795de8 --- /dev/null +++ b/services/gateway-service/internal/transport/http/userapi/report_handler.go @@ -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(), + } +} diff --git a/services/gateway-service/internal/transport/http/walletapi/handler.go b/services/gateway-service/internal/transport/http/walletapi/handler.go index 47e07c1b..1cdf12f5 100644 --- a/services/gateway-service/internal/transport/http/walletapi/handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/handler.go @@ -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, } } diff --git a/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go b/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go index 562114f0..174207e5 100644 --- a/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go @@ -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(), + } +} diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index 4c9f4cef..5f984096 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -368,6 +368,12 @@ type SendGift struct { PoolID string `json:"pool_id,omitempty"` // GiftCount 是礼物数量,必须为正数。 GiftCount int32 `json:"gift_count"` + // TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。 + TargetIsHost bool `json:"target_is_host,omitempty"` + // TargetHostRegionID 是工资政策匹配区域;room-service 不自行推导主播身份或区域。 + TargetHostRegionID int64 `json:"target_host_region_id,omitempty"` + // TargetAgencyOwnerUserID 是 gateway 从 user-service active host profile 注入的代理收款人快照。 + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"` // BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。 BillingReceiptID string `json:"billing_receipt_id,omitempty"` // CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。 @@ -380,6 +386,10 @@ type SendGift struct { PriceVersion string `json:"price_version,omitempty"` // GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。 GiftTypeCode string `json:"gift_type_code,omitempty"` + // HostPeriodDiamondAdded 是 wallet-service 给主播周期钻石账户本次入账的钻石数。 + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added,omitempty"` + // HostPeriodCycleKey 是本次周期钻石入账所属 UTC 月周期。 + HostPeriodCycleKey string `json:"host_period_cycle_key,omitempty"` // TreasureTouched 表示本次送礼已经计算过宝箱状态,恢复时直接使用下列确定性快照。 TreasureTouched bool `json:"treasure_touched,omitempty"` // TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。 @@ -473,6 +483,8 @@ func IdempotencyPayload(payload []byte) ([]byte, error) { delete(values, "heat_value") delete(values, "price_version") delete(values, "gift_type_code") + delete(values, "host_period_diamond_added") + delete(values, "host_period_cycle_key") delete(values, "treasure_touched") delete(values, "treasure_added_energy") delete(values, "treasure_effective_energy") diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index eb4135ff..94eed286 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -30,6 +30,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r GiftID: req.GetGiftId(), PoolID: strings.TrimSpace(req.GetPoolId()), GiftCount: req.GetGiftCount(), + TargetIsHost: req.GetTargetIsHost(), + // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 + TargetHostRegionID: req.GetTargetHostRegionId(), + TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), } if cmd.TargetType == "" { cmd.TargetType = "user" @@ -97,14 +101,17 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 walletStartedAt := time.Now() billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{ - CommandId: cmd.ID(), - RoomId: cmd.RoomID(), - SenderUserId: cmd.ActorUserID(), - TargetUserId: cmd.TargetUserID, - GiftId: cmd.GiftID, - GiftCount: cmd.GiftCount, - AppCode: appcode.FromContext(ctx), - RegionId: roomMeta.VisibleRegionID, + CommandId: cmd.ID(), + RoomId: cmd.RoomID(), + SenderUserId: cmd.ActorUserID(), + TargetUserId: cmd.TargetUserID, + GiftId: cmd.GiftID, + GiftCount: cmd.GiftCount, + AppCode: appcode.FromContext(ctx), + RegionId: roomMeta.VisibleRegionID, + TargetIsHost: cmd.TargetIsHost, + TargetHostRegionId: cmd.TargetHostRegionID, + TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID, }) walletDebitMS := elapsedMS(walletStartedAt) if err != nil { @@ -121,6 +128,8 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r settledCommand.HeatValue = heatValue settledCommand.PriceVersion = billing.GetPriceVersion() settledCommand.GiftTypeCode = billing.GetGiftTypeCode() + settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded() + settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey() if !luckyEnabled { // 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。 luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode()) @@ -255,13 +264,15 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r RoomHeat: current.Heat, RoomVersion: current.Version, Attributes: map[string]string{ - "gift_id": cmd.GiftID, - "pool_id": cmd.PoolID, - "gift_count": fmt.Sprintf("%d", cmd.GiftCount), - "billing_receipt_id": billing.GetBillingReceiptId(), - "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), - "gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded), - "price_version": settledCommand.PriceVersion, + "gift_id": cmd.GiftID, + "pool_id": cmd.PoolID, + "gift_count": fmt.Sprintf("%d", cmd.GiftCount), + "billing_receipt_id": billing.GetBillingReceiptId(), + "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), + "gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded), + "price_version": settledCommand.PriceVersion, + "host_period_diamond_added": fmt.Sprintf("%d", settledCommand.HostPeriodDiamondAdded), + "host_period_cycle_key": settledCommand.HostPeriodCycleKey, }, }, }, records, nil diff --git a/services/room-service/internal/room/service/online_users_test.go b/services/room-service/internal/room/service/online_users_test.go index f06167eb..025e605a 100644 --- a/services/room-service/internal/room/service/online_users_test.go +++ b/services/room-service/internal/room/service/online_users_test.go @@ -93,3 +93,42 @@ func TestListRoomOnlineUsersReturnsOwnerAdminRoleAndGiftValue(t *testing.T) { } t.Logf("online users response items: %s", payload) } + +func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{ + BillingReceiptId: "receipt-host-period", + CoinSpent: 14, + GiftPointAdded: 6, + HeatValue: 22, + GiftTypeCode: "normal", + HostPeriodDiamondAdded: 14, + HostPeriodCycleKey: "2026-05", + }}} + now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)} + svc := newTreasureTestService(t, repository, wallet, now) + + roomID := "room-host-period-scope" + senderID := int64(2001) + hostID := int64(2002) + createTreasureRoom(t, ctx, svc, roomID, senderID, 9001) + joinTreasureRoom(t, ctx, svc, roomID, hostID) + + if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: treasureMeta(roomID, senderID, "host-period-scope"), + TargetType: "user", + TargetUserId: hostID, + GiftId: "gift-host-period", + GiftCount: 2, + TargetIsHost: true, + TargetHostRegionId: 8801, + TargetAgencyOwnerUserId: 30001, + }); err != nil { + t.Fatalf("send host period gift failed: %v", err) + } + + if wallet.lastDebit == nil || !wallet.lastDebit.GetTargetIsHost() || wallet.lastDebit.GetTargetHostRegionId() != 8801 || wallet.lastDebit.GetTargetAgencyOwnerUserId() != 30001 { + t.Fatalf("room-service must pass host period scope to wallet: %+v", wallet.lastDebit) + } +} diff --git a/services/room-service/internal/room/service/room_treasure_test.go b/services/room-service/internal/room/service/room_treasure_test.go index acc47c2e..f8997ddf 100644 --- a/services/room-service/internal/room/service/room_treasure_test.go +++ b/services/room-service/internal/room/service/room_treasure_test.go @@ -29,11 +29,13 @@ func (c *fixedRoomTreasureClock) Now() time.Time { } type treasureTestWallet struct { - debits []*walletv1.DebitGiftResponse - grants []*walletv1.GrantResourceGroupRequest + debits []*walletv1.DebitGiftResponse + grants []*walletv1.GrantResourceGroupRequest + lastDebit *walletv1.DebitGiftRequest } -func (w *treasureTestWallet) DebitGift(_ context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { +func (w *treasureTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { + w.lastDebit = req if len(w.debits) == 0 { return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil } diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 7d1911d3..079ac0c2 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -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 '下一个值', diff --git a/services/user-service/internal/domain/host/host.go b/services/user-service/internal/domain/host/host.go index d890c8c0..96a29a92 100644 --- a/services/user-service/internal/domain/host/host.go +++ b/services/user-service/internal/domain/host/host.go @@ -135,10 +135,12 @@ type HostProfile struct { RegionID int64 CurrentAgencyID int64 CurrentMembershipID int64 - Source string - FirstBecameHostAtMs int64 - CreatedAtMs int64 - UpdatedAtMs int64 + // CurrentAgencyOwnerUserID 是当前 Agency 的 owner 用户 ID,供 wallet 在送礼时固化代理工资收款人。 + CurrentAgencyOwnerUserID int64 + Source string + FirstBecameHostAtMs int64 + CreatedAtMs int64 + UpdatedAtMs int64 } // Agency 是主播组织事实,由一个用户拥有并归属于 BD。 @@ -148,6 +150,8 @@ type Agency struct { RegionID int64 ParentBDUserID int64 Name string + OwnerAvatar string + ActiveHostCount int32 Status string JoinEnabled bool MaxHosts int32 diff --git a/services/user-service/internal/domain/user/user.go b/services/user-service/internal/domain/user/user.go index 939e2c2a..31fed33f 100644 --- a/services/user-service/internal/domain/user/user.go +++ b/services/user-service/internal/domain/user/user.go @@ -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 { diff --git a/services/user-service/internal/service/host/commands.go b/services/user-service/internal/service/host/commands.go index a0bf1b26..51158198 100644 --- a/services/user-service/internal/service/host/commands.go +++ b/services/user-service/internal/service/host/commands.go @@ -1,6 +1,6 @@ package host -// SearchAgenciesCommand 描述同区域 Agency 搜索条件。 +// SearchAgenciesCommand 描述同国家 Agency 搜索条件。 type SearchAgenciesCommand struct { UserID int64 Keyword string diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index 320c6b4f..673c86f0 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -86,7 +86,7 @@ func WithClock(now func() time.Time) Option { } } -// SearchAgencies 返回当前用户同区域可加入的有效 Agency。 +// SearchAgencies 返回当前用户同国家可加入的有效 Agency。 func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword string, pageSize int32) ([]hostdomain.Agency, error) { if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "host repository is not configured") @@ -100,7 +100,7 @@ func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword stri limit = 50 } - // 业务层只负责输入归一化;用户区域过滤由持久化层读取 users 当前事实完成。 + // 业务层只负责输入归一化;用户国家过滤由持久化层读取 users 当前事实完成。 return s.repository.SearchAgencies(ctx, SearchAgenciesCommand{ UserID: userID, Keyword: strings.TrimSpace(keyword), diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index b75bdc8a..9462e76a 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -31,13 +31,19 @@ func newHostService(repository *mysqltest.Repository, firstID int64, nowMs int64 } func seedActiveUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) { + t.Helper() + seedActiveUserInCountry(t, repository, userID, regionID, "CN", "") +} + +func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64, country string, avatar string) { t.Helper() repository.PutUser(userdomain.User{ UserID: userID, DefaultDisplayUserID: displayID(userID), CurrentDisplayUserID: displayID(userID), - Country: "CN", + Country: country, RegionID: regionID, + Avatar: avatar, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, @@ -360,8 +366,9 @@ func TestAdminCreateBDLeaderRejectsDisabledRegion(t *testing.T) { func TestAdminCreateAgencyControlsAppSearch(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) - seedActiveUser(t, repository, 901, 30) + seedActiveUserInCountry(t, repository, 901, 30, "CN", "https://cdn.example/owner-901.png") seedActiveUser(t, repository, 902, 30) + seedActiveUserInCountry(t, repository, 903, 30, "US", "") repository.PutBDProfile(hostdomain.BDProfile{ UserID: 900, Role: hostdomain.BDRoleLeader, @@ -393,7 +400,27 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) { if created.Membership.MembershipType != hostdomain.MembershipTypeOwner || created.Membership.Status != hostdomain.MembershipStatusActive { t.Fatalf("owner membership mismatch: %+v", created.Membership) } - agencies, err := svc.SearchAgencies(ctx, 902, "Admin Seed", 20) + if _, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ + CommandID: "admin-create-agency-903", + AdminUserID: 1, + OwnerUserID: 903, + ParentBDUserID: 900, + Name: "Other Country Agency", + JoinEnabled: true, + MaxHosts: 100, + Reason: "seed agency", + RequestID: "req-agency-2", + }); err != nil { + t.Fatalf("CreateAgency in other country failed: %v", err) + } + agencies, err := svc.SearchAgencies(ctx, 902, "", 20) + if err != nil { + t.Fatalf("SearchAgencies list failed: %v", err) + } + if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID || agencies[0].OwnerAvatar != "https://cdn.example/owner-901.png" || agencies[0].ActiveHostCount != 1 { + t.Fatalf("created agency list should be scoped by country with display fields: %+v", agencies) + } + agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20) if err != nil { t.Fatalf("SearchAgencies failed: %v", err) } diff --git a/services/user-service/internal/service/user/moderation_test.go b/services/user-service/internal/service/user/moderation_test.go index fcde8318..43db0e73 100644 --- a/services/user-service/internal/service/user/moderation_test.go +++ b/services/user-service/internal/service/user/moderation_test.go @@ -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 } diff --git a/services/user-service/internal/service/user/report.go b/services/user-service/internal/service/user/report.go new file mode 100644 index 00000000..3a36a577 --- /dev/null +++ b/services/user-service/internal/service/user/report.go @@ -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 +} diff --git a/services/user-service/internal/service/user/report_test.go b/services/user-service/internal/service/user/report_test.go new file mode 100644 index 00000000..7da0fcaa --- /dev/null +++ b/services/user-service/internal/service/user/report_test.go @@ -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) + } + }) + } +} diff --git a/services/user-service/internal/service/user/service.go b/services/user-service/internal/service/user/service.go index a501a1d3..9f9ec2c4 100644 --- a/services/user-service/internal/service/user/service.go +++ b/services/user-service/internal/service/user/service.go @@ -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 批量查询用户主状态,缺失用户不应返回占位对象。 diff --git a/services/user-service/internal/storage/mysql/host/common.go b/services/user-service/internal/storage/mysql/host/common.go index ef1871af..69dd9e86 100644 --- a/services/user-service/internal/storage/mysql/host/common.go +++ b/services/user-service/internal/storage/mysql/host/common.go @@ -31,8 +31,16 @@ const ( // 可空外键在存储边界统一转成 0,让领域结构体保持普通值类型。 hostProfileColumns = ` user_id, status, region_id, COALESCE(current_agency_id, 0), - COALESCE(current_membership_id, 0), source, first_became_host_at_ms, - created_at_ms, updated_at_ms` + COALESCE(current_membership_id, 0), + COALESCE(( + SELECT owner_user_id + FROM agencies + WHERE agencies.app_code = host_profiles.app_code + AND agencies.agency_id = host_profiles.current_agency_id + AND agencies.status = 'active' + LIMIT 1 + ), 0), + source, first_became_host_at_ms, created_at_ms, updated_at_ms` agencyColumns = ` agency_id, owner_user_id, region_id, parent_bd_user_id, name, status, join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms` @@ -97,6 +105,27 @@ func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64, return regionID, nil } +func (r *Repository) userCountry(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (string, error) { + // App 侧 Agency 推荐按用户当前国家收敛;国家来自用户资料事实,不能由客户端传入。 + query := "SELECT COALESCE(country, ''), status FROM users WHERE app_code = ? AND user_id = ?" + if strings.TrimSpace(lockClause) != "" { + query += " " + lockClause + } + var country string + var status string + err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&country, &status) + if err == sql.ErrNoRows { + return "", xerr.New(xerr.NotFound, "user not found") + } + if err != nil { + return "", err + } + if status != "active" { + return "", xerr.New(xerr.UserDisabled, "user is not active") + } + return strings.TrimSpace(country), nil +} + func requireActiveRegion(ctx context.Context, q sqlQueryer, regionID int64) error { if regionID <= 0 { return xerr.New(xerr.InvalidArgument, "region_id is required") diff --git a/services/user-service/internal/storage/mysql/host/queries.go b/services/user-service/internal/storage/mysql/host/queries.go index 5ca03912..5da73090 100644 --- a/services/user-service/internal/storage/mysql/host/queries.go +++ b/services/user-service/internal/storage/mysql/host/queries.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "strconv" "strings" "hyapp/pkg/appcode" @@ -12,33 +13,68 @@ import ( hostservice "hyapp/services/user-service/internal/service/host" ) -// SearchAgencies 返回用户当前区域内可加入的有效 Agency。 +// SearchAgencies 返回用户当前国家内可加入的有效 Agency。 func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.SearchAgenciesCommand) ([]hostdomain.Agency, error) { - regionID, err := r.userRegion(ctx, r.db, command.UserID, "") + country, err := r.userCountry(ctx, r.db, command.UserID, "") if err != nil { return nil, err } - if regionID <= 0 { - // 区域是 Agency 搜索和申请的硬边界,缺失时不能退化成全局搜索。 - return nil, xerr.New(xerr.InvalidArgument, "user region is required") + if country == "" { + // 国家是 App 推荐列表的硬边界,缺失时不能退化成跨国或全局列表。 + return nil, xerr.New(xerr.InvalidArgument, "user country is required") } - query := fmt.Sprintf(` - SELECT %s - FROM agencies - WHERE app_code = ? AND region_id = ? AND status = ? AND join_enabled = TRUE - `, agencyColumns) - args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive} + query := ` + SELECT + a.agency_id, + a.owner_user_id, + a.region_id, + a.parent_bd_user_id, + a.name, + a.status, + a.join_enabled, + a.max_hosts, + a.created_by_user_id, + a.created_at_ms, + a.updated_at_ms, + COALESCE(owner.avatar, ''), + COUNT(active_member.membership_id) + FROM agencies a + INNER JOIN users owner + ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id + LEFT JOIN agency_memberships active_member + ON active_member.app_code = a.app_code + AND active_member.agency_id = a.agency_id + AND active_member.status = ? + WHERE a.app_code = ? + AND owner.country = ? + AND owner.status = 'active' + AND a.status = ? + AND a.join_enabled = TRUE + ` + args := []any{hostdomain.MembershipStatusActive, appcode.FromContext(ctx), country, hostdomain.AgencyStatusActive} if strings.TrimSpace(command.Keyword) != "" { keyword := strings.TrimSpace(command.Keyword) - query += ` AND (name LIKE ? OR owner_user_id IN ( - SELECT user_id - FROM users - WHERE app_code = ? AND current_display_user_id = ? - ))` - args = append(args, "%"+keyword+"%", appcode.FromContext(ctx), keyword) + query += ` AND (a.name LIKE ? OR a.agency_id = ? OR owner.current_display_user_id = ?)` + args = append(args, "%"+keyword+"%", int64FromKeyword(keyword), keyword) } - query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?" + query += ` + GROUP BY + a.agency_id, + a.owner_user_id, + a.region_id, + a.parent_bd_user_id, + a.name, + a.status, + a.join_enabled, + a.max_hosts, + a.created_by_user_id, + a.created_at_ms, + a.updated_at_ms, + owner.avatar + ORDER BY a.created_at_ms DESC, a.agency_id DESC + LIMIT ? + ` args = append(args, command.PageSize) rows, err := r.db.QueryContext(ctx, query, args...) @@ -49,7 +85,7 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea agencies := make([]hostdomain.Agency, 0, command.PageSize) for rows.Next() { - agency, err := scanAgency(rows) + agency, err := scanSearchAgency(rows) if err != nil { return nil, err } @@ -314,6 +350,7 @@ func scanHostProfile(scanner rowScanner) (hostdomain.HostProfile, error) { &profile.RegionID, &profile.CurrentAgencyID, &profile.CurrentMembershipID, + &profile.CurrentAgencyOwnerUserID, &profile.Source, &profile.FirstBecameHostAtMs, &profile.CreatedAtMs, @@ -382,6 +419,34 @@ func scanAgency(scanner rowScanner) (hostdomain.Agency, error) { return agency, err } +func scanSearchAgency(scanner rowScanner) (hostdomain.Agency, error) { + var agency hostdomain.Agency + err := scanner.Scan( + &agency.AgencyID, + &agency.OwnerUserID, + &agency.RegionID, + &agency.ParentBDUserID, + &agency.Name, + &agency.Status, + &agency.JoinEnabled, + &agency.MaxHosts, + &agency.CreatedByUserID, + &agency.CreatedAtMs, + &agency.UpdatedAtMs, + &agency.OwnerAvatar, + &agency.ActiveHostCount, + ) + return agency, err +} + +func int64FromKeyword(keyword string) int64 { + value, err := strconv.ParseInt(strings.TrimSpace(keyword), 10, 64) + if err != nil { + return 0 + } + return value +} + func queryBDProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.BDProfile, error) { clause, args = appScopedClause(ctx, clause, args...) profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_profiles %s`, bdProfileColumns, clause), args...)) diff --git a/services/user-service/internal/storage/mysql/user/report_repository.go b/services/user-service/internal/storage/mysql/user/report_repository.go new file mode 100644 index 00000000..51c2cf40 --- /dev/null +++ b/services/user-service/internal/storage/mysql/user/report_repository.go @@ -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 +} diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index f9b7a7af..c4279c4f 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -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) diff --git a/services/user-service/internal/transport/grpc/convert.go b/services/user-service/internal/transport/grpc/convert.go index 3dfbd00b..50392bf9 100644 --- a/services/user-service/internal/transport/grpc/convert.go +++ b/services/user-service/internal/transport/grpc/convert.go @@ -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, } } diff --git a/services/user-service/internal/transport/grpc/host.go b/services/user-service/internal/transport/grpc/host.go index d7dd0a7b..3df908e0 100644 --- a/services/user-service/internal/transport/grpc/host.go +++ b/services/user-service/internal/transport/grpc/host.go @@ -12,7 +12,7 @@ import ( // 这个文件保持薄传输层:只做 proto 与业务层的数据转换和错误映射。 // 角色授权、区域校验、幂等和事务都在业务层/持久化层,避免不同入口行为分叉。 -// SearchAgencies 查询当前用户同区域可加入 Agency。 +// SearchAgencies 查询当前用户同国家可加入 Agency。 func (s *Server) SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) { if s.hostSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) diff --git a/services/user-service/internal/transport/grpc/server.go b/services/user-service/internal/transport/grpc/server.go index 071b7341..77f79440 100644 --- a/services/user-service/internal/transport/grpc/server.go +++ b/services/user-service/internal/transport/grpc/server.go @@ -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()) diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 9ba48965..6572be03 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -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', diff --git a/services/wallet-service/internal/app/app.go b/services/wallet-service/internal/app/app.go index 3b5a891a..7cdcb2a5 100644 --- a/services/wallet-service/internal/app/app.go +++ b/services/wallet-service/internal/app/app.go @@ -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, diff --git a/services/wallet-service/internal/domain/ledger/ledger.go b/services/wallet-service/internal/domain/ledger/ledger.go index 2b5ac24b..8cf4a747 100644 --- a/services/wallet-service/internal/domain/ledger/ledger.go +++ b/services/wallet-service/internal/domain/ledger/ledger.go @@ -11,6 +11,8 @@ const ( AssetGiftPoint = "GIFT_POINT" // AssetDiamond 是后续兑换用资产,当前只在余额模型中保留。 AssetDiamond = "DIAMOND" + // AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。 + AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND" // AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。 AssetUSDBalance = "USD_BALANCE" @@ -46,6 +48,17 @@ const ( // WithdrawalStatusPending 表示主播美元余额提现已提交,等待后台人工审核和线下转账。 WithdrawalStatusPending = "pending" + // HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。 + HostSalarySettlementModeDaily = "daily" + // HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。 + HostSalarySettlementModeHalfMonth = "half_month" + // HostSalarySettlementTypeMonthEnd 表示月底清算等级增量、剩余钻石折美元和周期关闭。 + HostSalarySettlementTypeMonthEnd = "month_end" + // HostSalarySettlementTriggerAutomatic 表示由 cron-service 自动扫描结算的政策。 + HostSalarySettlementTriggerAutomatic = "automatic" + // HostSalarySettlementTriggerManual 表示只允许后台工资结算页人工触发的政策。 + HostSalarySettlementTriggerManual = "manual" + // VipStatusActive 表示 VIP 配置或用户会员状态当前有效。 VipStatusActive = "active" // VipStatusDisabled 表示 VIP 配置被后台停用。 @@ -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 diff --git a/services/wallet-service/internal/service/wallet/red_packet.go b/services/wallet-service/internal/service/wallet/red_packet.go index 6819a692..c0a482f7 100644 --- a/services/wallet-service/internal/service/wallet/red_packet.go +++ b/services/wallet-service/internal/service/wallet/red_packet.go @@ -28,18 +28,23 @@ func (s *Service) UpdateRedPacketConfig(ctx context.Context, config ledger.RedPa config.AppCode = appcode.Normalize(config.AppCode) config.CountTiers = normalizeCountTiers(config.CountTiers) config.AmountTiers = normalizeAmountTiers(config.AmountTiers) + config.RuleURL = strings.TrimSpace(config.RuleURL) + config.ExpireSeconds = ledger.RedPacketExpireSeconds if config.UpdatedByAdminID <= 0 { return ledger.RedPacketConfig{}, xerr.New(xerr.InvalidArgument, "operator_user_id is required") } if len(config.CountTiers) == 0 || len(config.AmountTiers) == 0 { return ledger.RedPacketConfig{}, xerr.New(xerr.InvalidArgument, "red packet tiers are required") } - if config.DelayedOpenSeconds < 0 || config.ExpireSeconds <= 0 || config.DailySendLimit < 0 { + if config.DelayedOpenSeconds < 0 || config.DailySendLimit < 0 { return ledger.RedPacketConfig{}, xerr.New(xerr.InvalidArgument, "red packet config is invalid") } - if config.DelayedOpenSeconds >= config.ExpireSeconds { + if config.DelayedOpenSeconds >= ledger.RedPacketExpireSeconds { return ledger.RedPacketConfig{}, xerr.New(xerr.InvalidArgument, "red packet expire_seconds must be greater than delayed_open_seconds") } + if len(config.CountTiers) > 0 && len(config.AmountTiers) > 0 && config.AmountTiers[0] < int64(config.CountTiers[len(config.CountTiers)-1]) { + return ledger.RedPacketConfig{}, xerr.New(xerr.InvalidArgument, "red packet amount tiers must cover count tiers") + } ctx = appcode.WithContext(ctx, config.AppCode) return s.repository.UpdateRedPacketConfig(ctx, config) } @@ -127,6 +132,19 @@ func (s *Service) ExpireRedPackets(ctx context.Context, appCode string, batchSiz return s.repository.ExpireRedPackets(ctx, appCode, batchSize) } +func (s *Service) RetryRedPacketRefund(ctx context.Context, appCode string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) { + if s.repository == nil { + return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + packetID = strings.TrimSpace(packetID) + if packetID == "" || operatorUserID <= 0 { + return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.InvalidArgument, "red packet refund retry command is incomplete") + } + appCode = appcode.Normalize(appCode) + ctx = appcode.WithContext(ctx, appCode) + return s.repository.RetryRedPacketRefund(ctx, appCode, packetID, operatorUserID) +} + func normalizeCountTiers(values []int32) []int32 { seen := make(map[int32]bool, len(values)) out := make([]int32, 0, len(values)) diff --git a/services/wallet-service/internal/service/wallet/service.go b/services/wallet-service/internal/service/wallet/service.go index 99387511..6bd2274c 100644 --- a/services/wallet-service/internal/service/wallet/service.go +++ b/services/wallet-service/internal/service/wallet/service.go @@ -32,6 +32,7 @@ type Repository interface { ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) GetRedPacket(ctx context.Context, appCode string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error) ExpireRedPackets(ctx context.Context, appCode string, batchSize int32) (ledger.RedPacketExpireResult, error) + RetryRedPacketRefund(ctx context.Context, appCode string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) @@ -53,6 +54,7 @@ type Repository interface { GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) + ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) @@ -125,6 +127,10 @@ func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand if command.GiftCount <= 0 { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") } + if command.TargetIsHost && command.TargetHostRegionID <= 0 { + // 主播周期钻石后续要按区域工资政策结算;只有 active host profile 且带区域时才允许入账。 + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required") + } if s.repository == nil { return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } @@ -134,6 +140,41 @@ func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand return s.repository.DebitGift(ctx, command) } +// ProcessHostSalarySettlementBatch 执行主播/代理工资结算批处理;日结和半月结不清空周期钻石,月底只做逻辑清算标记。 +func (s *Service) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { + if s.repository == nil { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + switch strings.TrimSpace(command.SettlementType) { + case ledger.HostSalarySettlementModeDaily, ledger.HostSalarySettlementModeHalfMonth, ledger.HostSalarySettlementTypeMonthEnd: + default: + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") + } + if command.BatchSize <= 0 { + command.BatchSize = 100 + } + if command.BatchSize > 500 { + command.BatchSize = 500 + } + if strings.TrimSpace(command.RunID) == "" || strings.TrimSpace(command.WorkerID) == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "run_id and worker_id are required") + } + command.AppCode = appcode.Normalize(command.AppCode) + if command.NowMs <= 0 { + command.NowMs = s.now().UTC().UnixMilli() + } + if strings.TrimSpace(command.CycleKey) == "" { + command.CycleKey = hostSalaryCycleKey(command.NowMs) + } + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.ProcessHostSalarySettlementBatch(ctx, command) +} + +func hostSalaryCycleKey(nowMs int64) string { + // 结算周期按 UTC 月锚定,和送礼入账的 host_period_diamond_accounts.cycle_key 保持同一口径。 + return time.UnixMilli(nowMs).UTC().Format("2006-01") +} + // GetBalances 返回用户多资产余额;鉴权范围由 gateway 或后台入口控制。 func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { if userID <= 0 { diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index e3f504ff..0a6f7e4d 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -63,6 +63,641 @@ func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) { if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 3 { t.Fatalf("gift debit should write balance and gift outbox events, got %d", got) } + if receipt.HostPeriodDiamondAdded != 0 || receipt.HostPeriodCycleKey != "" { + t.Fatalf("non-host gift must not credit host period diamonds: %+v", receipt) + } + if got := repository.CountRows("host_period_diamond_accounts", "user_id = ?", int64(10002)); got != 0 { + t.Fatalf("non-host gift must not create host period diamond account, got %d", got) + } +} + +// TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。 +func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "v9", 7, 3, 11) + svc := walletservice.New(repository) + cycleBefore := time.Now().UTC().Format("2006-01") + + command := ledger.DebitGiftCommand{ + CommandID: "cmd-gift-host-period", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 2, + PriceVersion: "v9", + TargetIsHost: true, + TargetHostRegionID: 8801, + } + first, err := svc.DebitGift(context.Background(), command) + if err != nil { + t.Fatalf("host DebitGift failed: %v", err) + } + cycleAfter := time.Now().UTC().Format("2006-01") + + if first.HostPeriodDiamondAdded != 14 || first.HostPeriodCycleKey == "" { + t.Fatalf("host period diamond receipt mismatch: %+v", first) + } + if first.HostPeriodCycleKey != cycleBefore && first.HostPeriodCycleKey != cycleAfter { + t.Fatalf("host period cycle must use current UTC month, got %q", first.HostPeriodCycleKey) + } + if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND region_id = ? AND total_diamonds = ? AND gift_diamond_total = ?", int64(10002), first.HostPeriodCycleKey, int64(8801), int64(14), int64(14)); got != 1 { + t.Fatalf("host period diamond account mismatch, got %d", got) + } + if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ? AND diamond_delta = ? AND diamond_after = ?", int64(10002), first.HostPeriodCycleKey, int64(14), int64(14)); got != 1 { + t.Fatalf("host period diamond entry mismatch, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "HostPeriodDiamondCredited"); got != 1 { + t.Fatalf("host period diamond outbox event mismatch, got %d", got) + } + + second, err := svc.DebitGift(context.Background(), command) + if err != nil { + t.Fatalf("host DebitGift replay failed: %v", err) + } + if second.TransactionID != first.TransactionID || second.HostPeriodDiamondAdded != 14 || second.HostPeriodCycleKey != first.HostPeriodCycleKey { + t.Fatalf("host period idempotent receipt mismatch: first=%+v second=%+v", first, second) + } + if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND total_diamonds = ?", int64(10002), first.HostPeriodCycleKey, int64(14)); got != 1 { + t.Fatalf("host period replay must not add diamonds again, got %d", got) + } + if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 1 { + t.Fatalf("host period replay must not add duplicate entries, got %d", got) + } +} + +// TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。 +func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "default", 10, 10, 10) + svc := walletservice.New(repository) + + _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-gift-host-no-region", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + TargetIsHost: true, + }) + if !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("expected INVALID_ARGUMENT, got %v", err) + } + if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-gift-host-no-region"); got != 0 { + t.Fatalf("invalid host period command must not write transaction, got %d", got) + } +} + +// TestHostSalarySettlementCreditsIncrementalRewards 走真实送礼入钻石账户后,验证日结只发放等级累计差额。 +func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 1000) + repository.SetGiftPrice("rose", "salary", 200, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 101, + Name: "daily salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, + }, + }) + svc := walletservice.New(repository) + + first, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-gift-1", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("first salary gift failed: %v", err) + } + result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-run-1", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: first.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("first salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("first settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50) + + second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-gift-2", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("second salary gift failed: %v", err) + } + if second.HostPeriodCycleKey != first.HostPeriodCycleKey { + t.Fatalf("salary gifts should be in same cycle: first=%q second=%q", first.HostPeriodCycleKey, second.HostPeriodCycleKey) + } + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-run-2", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: first.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("second salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("second settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 300) + assertBalance(t, svc, 10002, ledger.AssetCoin, 198) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 80) + if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 { + t.Fatalf("salary settlement records mismatch, got %d", got) + } + + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-run-3", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: first.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("noop salary settlement failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("settlement without new diamonds should not claim rows: %+v", result) + } +} + +// TestHostSalaryManualPolicyRequiresManualTrigger 验证手动政策不会被 cron 默认自动结算,必须由后台人工入口显式传 manual。 +func TestHostSalaryManualPolicyRequiresManualTrigger(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 500) + repository.SetGiftPrice("rose", "salary-manual", 200, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 105, + Name: "manual salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + }, + }) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-manual-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-manual", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("manual salary gift failed: %v", err) + } + + result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-manual-auto", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("automatic scan for manual policy failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("manual policy must not be claimed by automatic scan: %+v", result) + } + + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-manual-explicit", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + TriggerMode: ledger.HostSalarySettlementTriggerManual, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("manual salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 { + t.Fatalf("manual settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50) +} + +// TestHostSalaryMonthEndCreditsResidualAndClearsCycle 验证月底结算补发剩余钻石折美元,并逻辑关闭当月周期。 +func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 1000) + repository.SetGiftPrice("rose", "salary-residual", 350, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 102, + Name: "daily residual", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + ResidualDiamondToUSDRate: "0.01", + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + {LevelNo: 2, RequiredDiamonds: 500, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, + }, + }) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-salary-residual-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-residual", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("residual salary gift failed: %v", err) + } + if _, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-residual-daily", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("daily residual salary settlement failed: %v", err) + } + result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-residual-month-end", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementTypeMonthEnd, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("month-end residual salary settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("month-end settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 400) + if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), receipt.HostPeriodCycleKey, int64(250)); got != 1 { + t.Fatalf("month-end progress should store residual and cleared marker, got %d", got) + } + result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "salary-residual-after-clear", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("after-clear salary settlement failed: %v", err) + } + if result.ClaimedCount != 0 { + t.Fatalf("cleared cycle must not be claimed again: %+v", result) + } +} + +// TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle 走通“已发布政策 -> 主播收礼 -> 多次增量结算 -> 月底清算 -> 下周期重新起算”的真实账务链路。 +func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 2000) + repository.SetGiftPrice("rose", "salary-full-lifecycle", 120, 10, 10) + // 管理后台发布后 wallet-service 消费的是 host_agency_salary_policies 运行时快照;这里直接 seed 该表来锁定送礼和结算链路。 + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 301, + Name: "published daily salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + ResidualDiamondToUSDRate: "0.01", + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, + }, + }) + svc := walletservice.New(repository) + ctx := context.Background() + previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01") + + first, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-full-lifecycle-gift-1", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-full-lifecycle", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("first lifecycle gift failed: %v", err) + } + if first.HostPeriodDiamondAdded != 120 { + t.Fatalf("first gift should add host diamonds: %+v", first) + } + // DebitGift 使用真实当前月份入账;测试把真实送礼产生的账户移动到上月,模拟月底清算前的完整历史周期。 + repository.MoveHostPeriodCycle(10002, first.HostPeriodCycleKey, previousCycle) + + result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-daily-level-1", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("level 1 daily settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("level 1 daily settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 50) + previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle) + if len(previousIDs) != 1 || previousIDs[0] == "" { + t.Fatalf("level 1 settlement id missing: %v", previousIDs) + } + t.Logf("settlement ids after level 1: %v", previousIDs) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-daily-repeat", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("repeat daily settlement failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("repeat daily settlement should not claim unchanged account: %+v", result) + } + if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) { + t.Fatalf("repeat daily settlement must not create record: before=%v after=%v", previousIDs, ids) + } + + second, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-full-lifecycle-gift-2", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 3, + PriceVersion: "salary-full-lifecycle", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("second lifecycle gift failed: %v", err) + } + if second.HostPeriodDiamondAdded != 360 { + t.Fatalf("second gift should add host diamonds: %+v", second) + } + repository.MoveHostPeriodCycle(10002, second.HostPeriodCycleKey, previousCycle) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-daily-level-2", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("level 2 daily settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("level 2 daily settlement result mismatch: %+v", result) + } + // 第二次只补发累计 2 级与已发 1 级之间的差额,最终余额等于 2 级累计权益。 + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 300) + assertBalance(t, svc, 10002, ledger.AssetCoin, 198) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 80) + previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) + if len(previousIDs) != 2 { + t.Fatalf("level 2 settlement should create second record: %v", previousIDs) + } + t.Logf("settlement ids after level 2: %v", previousIDs) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-month-end", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementTypeMonthEnd, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("month-end lifecycle settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("month-end lifecycle result mismatch: %+v", result) + } + // 480 钻石达到 2 级后剩余 80 钻石,按 0.01 USD/diamond 折算为 80 美分并标记周期已清算。 + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 380) + if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND last_settled_total_diamonds = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), previousCycle, int64(480), int64(80)); got != 1 { + t.Fatalf("month-end progress should store residual and clear marker, got %d", got) + } + previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) + if len(previousIDs) != 3 { + t.Fatalf("month-end settlement should create third record: %v", previousIDs) + } + t.Logf("settlement ids after month-end clear: %v", previousIDs) + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-month-end-repeat", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementTypeMonthEnd, + CycleKey: previousCycle, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("repeat month-end settlement failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("cleared cycle must not be claimed by repeat month-end: %+v", result) + } + if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) { + t.Fatalf("repeat month-end must not create record: before=%v after=%v", previousIDs, ids) + } + + third, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-full-lifecycle-gift-next-cycle", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10002, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-full-lifecycle", + TargetIsHost: true, + TargetHostRegionID: 8801, + TargetAgencyOwnerUserID: 30001, + }) + if err != nil { + t.Fatalf("next-cycle gift failed: %v", err) + } + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "full-lifecycle-next-cycle-level-1", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: third.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("next-cycle daily settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("next-cycle daily result mismatch: %+v", result) + } + // 新 cycle_key 使用独立进度,因此月末清算后的下一周期从 1 级累计权益重新开始发放。 + assertBalance(t, svc, 10002, ledger.AssetUSDBalance, 530) + assertBalance(t, svc, 10002, ledger.AssetCoin, 288) + assertBalance(t, svc, 30001, ledger.AssetUSDBalance, 130) + nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey) + if len(nextIDs) != 1 || nextIDs[0] == "" { + t.Fatalf("next-cycle settlement id missing: %v", nextIDs) + } + t.Logf("settlement ids after next cycle level 1: %v", nextIDs) +} + +// TestHostSalaryHalfMonthSettlementUsesPolicyMode 验证半月结政策不会被日结批次误结算,只会由 half-month 批次处理。 +func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 500) + repository.SetGiftPrice("rose", "salary-half-month", 200, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 302, + Name: "published half-month salary", + RegionID: 8802, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeHalfMonth, + Levels: []ledger.HostSalaryPolicyLevel{ + {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, + }, + }) + svc := walletservice.New(repository) + ctx := context.Background() + + receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-half-month-policy-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10003, + GiftID: "rose", + GiftCount: 1, + PriceVersion: "salary-half-month", + TargetIsHost: true, + TargetHostRegionID: 8802, + TargetAgencyOwnerUserID: 30002, + }) + if err != nil { + t.Fatalf("half-month gift failed: %v", err) + } + result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "half-month-daily-should-skip", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeDaily, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("daily settlement for half-month policy failed: %v", err) + } + if result.ClaimedCount != 0 || result.ProcessedCount != 0 { + t.Fatalf("daily batch must skip half-month policy: %+v", result) + } + + result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: "lalu", + RunID: "half-month-policy-settlement", + WorkerID: "salary-test", + BatchSize: 10, + SettlementType: ledger.HostSalarySettlementModeHalfMonth, + CycleKey: receipt.HostPeriodCycleKey, + NowMs: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("half-month settlement failed: %v", err) + } + if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { + t.Fatalf("half-month settlement result mismatch: %+v", result) + } + assertBalance(t, svc, 10003, ledger.AssetUSDBalance, 150) + assertBalance(t, svc, 10003, ledger.AssetCoin, 90) + assertBalance(t, svc, 30002, ledger.AssetUSDBalance, 50) + if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" { + t.Fatalf("half-month settlement id missing: %v", ids) + } } // TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼,扣 COIN 和收 GIFT_POINT 仍然分别落账。 @@ -143,11 +778,12 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 255 { t.Fatalf("gift wall summary mismatch: %+v", wall) } - if len(wall.Items) != 2 || wall.Items[0].GiftID != "rose" || wall.Items[0].GiftCount != 5 || wall.Items[0].TotalValue != 35 { + rose, ok := giftWallItemByID(wall.Items, "rose") + if len(wall.Items) != 2 || !ok || rose.GiftCount != 5 || rose.TotalValue != 35 { t.Fatalf("rose gift wall item mismatch: %+v", wall.Items) } - if wall.Items[0].Resource.ResourceID == 0 || wall.Items[0].ResourceSnapshotJSON == "" { - t.Fatalf("gift wall should keep display resource snapshot: %+v", wall.Items[0]) + if rose.Resource.ResourceID == 0 || rose.ResourceSnapshotJSON == "" { + t.Fatalf("gift wall should keep display resource snapshot: %+v", rose) } if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 { t.Fatalf("gift wall should keep one row per gift_id, got %d", got) @@ -161,6 +797,7 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { func TestDebitGiftIsIdempotent(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{ CommandID: "cmd-1", @@ -192,6 +829,7 @@ func TestDebitGiftIsIdempotent(t *testing.T) { func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1} if _, err := svc.DebitGift(context.Background(), command); err != nil { @@ -208,6 +846,7 @@ func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) { func TestDebitGiftInsufficientBalance(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 1) + repository.SetGiftPrice("rose", "default", 10, 10, 10) svc := walletservice.New(repository) _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ @@ -2305,6 +2944,27 @@ func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 { return -1 } +func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) { + t.Helper() + + balances, err := svc.GetBalances(context.Background(), userID, []string{assetType}) + if err != nil { + t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err) + } + if got := balanceAmount(balances, assetType); got != want { + t.Fatalf("balance user=%d asset=%s mismatch: got %d want %d", userID, assetType, got, want) + } +} + +func giftWallItemByID(items []ledger.GiftWallItem, giftID string) (ledger.GiftWallItem, bool) { + for _, item := range items { + if item.GiftID == giftID { + return item, true + } + } + return ledger.GiftWallItem{}, false +} + func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool { for _, item := range items { if item.GiftID == giftID { diff --git a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go new file mode 100644 index 00000000..0b444a34 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go @@ -0,0 +1,642 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "math/big" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +type hostSalaryCandidate struct { + // UserID 与 CycleKey 唯一定位主播当月工资周期账户;后续所有结算进度都挂在这组键上。 + UserID int64 + CycleKey string + RegionID int64 + AgencyOwnerUserID int64 + TotalDiamonds int64 + GiftDiamondTotal int64 +} + +type hostSalaryProgress struct { + // 这些 settled_* 字段保存“已发累计权益”,结算时只对比当前等级累计值来补差额,天然防重复发放。 + SettledLevelNo int + SettledHostSalaryUSDMinor int64 + SettledHostCoinReward int64 + SettledAgencySalaryUSDMinor int64 + LastSettledTotalDiamonds int64 + ResidualUSDMinor int64 + MonthEndClearedAtMS int64 + LastPolicyID uint64 + Version int64 +} + +type hostSalarySettlementMetadata struct { + // metadata 是审计快照:钱包交易、outbox、结算记录共享同一份口径,方便后续从任一链路追账。 + AppCode string `json:"app_code"` + SettlementType string `json:"settlement_type"` + HostUserID int64 `json:"host_user_id"` + AgencyOwnerUserID int64 `json:"agency_owner_user_id"` + CycleKey string `json:"cycle_key"` + RegionID int64 `json:"region_id"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + LevelNo int `json:"level_no"` + RequiredDiamonds int64 `json:"required_diamonds"` + TotalDiamonds int64 `json:"total_diamonds"` + HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"` + HostCoinRewardDelta int64 `json:"host_coin_reward_delta"` + AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"` + ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"` + HostUSDBalanceAfter int64 `json:"host_usd_balance_after"` + HostCoinBalanceAfter int64 `json:"host_coin_balance_after"` + AgencyUSDBalanceAfter int64 `json:"agency_usd_balance_after"` + SettledLevelNo int `json:"settled_level_no"` + SettledHostSalaryUSDMinor int64 `json:"settled_host_salary_usd_minor"` + SettledHostCoinReward int64 `json:"settled_host_coin_reward"` + SettledAgencySalaryUSDMinor int64 `json:"settled_agency_salary_usd_minor"` + ResidualUSDMinor int64 `json:"residual_usd_minor"` + MonthEndClearedAtMS int64 `json:"month_end_cleared_at_ms"` + ProcessedAtMS int64 `json:"processed_at_ms"` +} + +// ProcessHostSalarySettlementBatch 按周期账户扫描并结算主播美元、主播金币奖励和代理美元工资。 +// 每个主播单独开事务,避免某个主播配置异常拖垮整个批次,也让 cron-service 可按 has_more 继续拉下一页。 +func (r *Repository) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { + if r == nil || r.db == nil { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.SettlementType = normalizeHostSalarySettlementType(command.SettlementType) + if command.SettlementType == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") + } + command.TriggerMode = normalizeHostSalarySettlementTriggerMode(command.TriggerMode) + if command.TriggerMode == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "trigger_mode is invalid") + } + if command.NowMs <= 0 { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "now_ms is required") + } + if command.BatchSize <= 0 { + command.BatchSize = 100 + } + limit := command.BatchSize + 1 + candidates, err := r.listHostSalaryCandidates(ctx, command, limit) + if err != nil { + return ledger.HostSalarySettlementBatchResult{}, err + } + hasMore := len(candidates) > command.BatchSize + if hasMore { + candidates = candidates[:command.BatchSize] + } + + result := ledger.HostSalarySettlementBatchResult{ClaimedCount: len(candidates), HasMore: hasMore} + var firstErr error + for _, candidate := range candidates { + processed, err := r.processHostSalaryCandidate(ctx, command, candidate) + result.ProcessedCount++ + if err != nil { + if firstErr == nil { + firstErr = err + } + result.FailureCount++ + continue + } + if processed { + result.SuccessCount++ + } + } + if result.ClaimedCount > 0 && result.SuccessCount == 0 && firstErr != nil { + return result, firstErr + } + return result, nil +} + +func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledger.HostSalarySettlementBatchCommand, limit int) ([]hostSalaryCandidate, error) { + policyModeClause := "" + args := []any{appcode.FromContext(ctx), command.CycleKey, command.NowMs, command.NowMs, command.TriggerMode} + if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd { + // 日结和半月结必须精确匹配政策配置的结算模式;月底清算复用有效政策处理剩余钻石和周期关闭。 + policyModeClause = "AND p.settlement_mode = ?" + args = append(args, command.SettlementType) + } + // 日结/半月结只扫描“本周期钻石比上次结算更多”的主播,避免 cron 重跑时反复锁定无新增收益的账户。 + // 月底批次必须扫描全部未关闭周期账户,因为它还负责剩余钻石折美元和周期逻辑关闭。 + progressClause := "AND a.total_diamonds > COALESCE(progress.last_settled_total_diamonds, 0)" + if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { + progressClause = "" + } + args = append(args, limit) + query := fmt.Sprintf(` + SELECT a.user_id, a.cycle_key, a.region_id, a.agency_owner_user_id, a.total_diamonds, a.gift_diamond_total + FROM host_period_diamond_accounts a + LEFT JOIN host_salary_settlement_progress progress + ON progress.app_code = a.app_code AND progress.user_id = a.user_id AND progress.cycle_key = a.cycle_key + WHERE a.app_code = ? + AND a.cycle_key = ? + AND a.total_diamonds > 0 + AND COALESCE(progress.month_end_cleared_at_ms, 0) = 0 + %s + AND EXISTS ( + SELECT 1 + FROM host_agency_salary_policies p + WHERE p.app_code = a.app_code + AND p.region_id = a.region_id + AND p.status = 'active' + AND p.effective_from_ms <= ? + AND (p.effective_to_ms = 0 OR p.effective_to_ms > ?) + AND p.settlement_trigger_mode = ? + %s + ) + ORDER BY a.updated_at_ms ASC, a.user_id ASC + LIMIT ?`, progressClause, policyModeClause) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + candidates := make([]hostSalaryCandidate, 0, limit) + for rows.Next() { + var candidate hostSalaryCandidate + if err := rows.Scan(&candidate.UserID, &candidate.CycleKey, &candidate.RegionID, &candidate.AgencyOwnerUserID, &candidate.TotalDiamonds, &candidate.GiftDiamondTotal); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +func (r *Repository) processHostSalaryCandidate(ctx context.Context, command ledger.HostSalarySettlementBatchCommand, selected hostSalaryCandidate) (bool, error) { + // 账务、进度和 outbox 必须在同一个主播级事务中提交;批次层只负责继续处理下一位主播。 + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + + account, exists, err := r.lockHostSalaryCandidate(ctx, tx, selected.UserID, selected.CycleKey) + if err != nil || !exists { + return false, err + } + progress, err := r.lockHostSalaryProgress(ctx, tx, account.UserID, account.CycleKey, command.NowMs) + if err != nil { + return false, err + } + if progress.MonthEndClearedAtMS > 0 { + return true, tx.Commit() + } + // 非月底批次不关闭周期,若同一周期已结算到当前钻石数,则本次只作为幂等空跑通过。 + if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd && account.TotalDiamonds <= progress.LastSettledTotalDiamonds { + return true, tx.Commit() + } + + // 政策按周期账户里的区域快照解析;该快照由同周期最近一次主播收礼写入。 + policy, ok, err := r.resolveHostSalaryPolicy(ctx, tx, account.RegionID, command.SettlementType, command.TriggerMode, command.NowMs) + if err != nil || !ok { + return false, err + } + // 工资表是累计值,因此每次只发“目标累计值 - 已发累计值”;这对应产品规则里的 2 级只补 2 级减 1 级。 + level := highestHostSalaryLevel(policy.Levels, account.TotalDiamonds) + residualTarget := progress.ResidualUSDMinor + residualDelta := int64(0) + if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { + // 月底不物理清零历史钻石,改为把剩余钻石折算入账并写 month_end_cleared_at_ms,保留审计和重放依据。 + residualDiamonds := residualDiamondRemainder(account.TotalDiamonds, level) + residualTarget, err = usdMinorFromDiamondRate(residualDiamonds, policy.ResidualDiamondToUSDRate) + if err != nil { + return false, err + } + residualDelta = positiveDelta(residualTarget, progress.ResidualUSDMinor) + } + + hostSalaryDelta := positiveDelta(level.HostSalaryUSDMinor, progress.SettledHostSalaryUSDMinor) + hostCoinDelta := positiveDelta(level.HostCoinReward, progress.SettledHostCoinReward) + agencyEntitlementDelta := positiveDelta(level.AgencySalaryUSDMinor, progress.SettledAgencySalaryUSDMinor) + agencyPaidDelta := agencyEntitlementDelta + if account.AgencyOwnerUserID <= 0 { + // 没有代理收款人时只把该等级代理权益标记为已处理,不在未来组织绑定后补发历史月份工资。 + agencyPaidDelta = 0 + } + + nextProgress := hostSalaryProgress{ + SettledLevelNo: maxInt(progress.SettledLevelNo, level.LevelNo), + SettledHostSalaryUSDMinor: maxInt64(progress.SettledHostSalaryUSDMinor, level.HostSalaryUSDMinor), + SettledHostCoinReward: maxInt64(progress.SettledHostCoinReward, level.HostCoinReward), + SettledAgencySalaryUSDMinor: maxInt64(progress.SettledAgencySalaryUSDMinor, level.AgencySalaryUSDMinor), + LastSettledTotalDiamonds: account.TotalDiamonds, + ResidualUSDMinor: maxInt64(progress.ResidualUSDMinor, residualTarget), + MonthEndClearedAtMS: progress.MonthEndClearedAtMS, + LastPolicyID: policy.PolicyID, + Version: progress.Version, + } + if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { + nextProgress.MonthEndClearedAtMS = command.NowMs + } + + paidHostUSDDelta, err := checkedAdd(hostSalaryDelta, residualDelta) + if err != nil { + return false, err + } + metadata := hostSalarySettlementMetadata{ + AppCode: appcode.FromContext(ctx), + SettlementType: command.SettlementType, + HostUserID: account.UserID, + AgencyOwnerUserID: account.AgencyOwnerUserID, + CycleKey: account.CycleKey, + RegionID: account.RegionID, + PolicyID: policy.PolicyID, + PolicyName: policy.Name, + LevelNo: level.LevelNo, + RequiredDiamonds: level.RequiredDiamonds, + TotalDiamonds: account.TotalDiamonds, + HostSalaryUSDMinorDelta: hostSalaryDelta, + HostCoinRewardDelta: hostCoinDelta, + AgencySalaryUSDMinorDelta: agencyPaidDelta, + ResidualUSDMinorDelta: residualDelta, + SettledLevelNo: nextProgress.SettledLevelNo, + SettledHostSalaryUSDMinor: nextProgress.SettledHostSalaryUSDMinor, + SettledHostCoinReward: nextProgress.SettledHostCoinReward, + SettledAgencySalaryUSDMinor: nextProgress.SettledAgencySalaryUSDMinor, + ResidualUSDMinor: nextProgress.ResidualUSDMinor, + MonthEndClearedAtMS: nextProgress.MonthEndClearedAtMS, + ProcessedAtMS: command.NowMs, + } + + if paidHostUSDDelta > 0 || hostCoinDelta > 0 || agencyPaidDelta > 0 { + // settlement commandID 带上已结算等级和当前钻石,确保同一增量重复执行会命中交易幂等键。 + commandID := hostSalarySettlementCommandID(command, account, nextProgress) + transactionID := transactionID(appcode.FromContext(ctx), commandID) + if err := r.insertTransaction(ctx, tx, transactionID, commandID, bizTypeHostSalarySettlement, hostSalarySettlementRequestHash(metadata), fmt.Sprintf("%s:%d", account.CycleKey, account.UserID), metadata, command.NowMs); err != nil { + return false, err + } + events, err := r.applyHostSalaryCredits(ctx, tx, transactionID, commandID, metadata, paidHostUSDDelta, hostCoinDelta, agencyPaidDelta, command.NowMs) + if err != nil { + return false, err + } + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { + return false, err + } + if err := r.insertHostSalarySettlementRecord(ctx, tx, transactionID, commandID, metadata, command.NowMs); err != nil { + return false, err + } + } + if err := r.updateHostSalaryProgress(ctx, tx, account.UserID, account.CycleKey, nextProgress, command.NowMs); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} + +func (r *Repository) lockHostSalaryCandidate(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (hostSalaryCandidate, bool, error) { + // 锁定周期账户后再读取总钻石,防止送礼入账与结算批次并发时结算到半更新状态。 + row := tx.QueryRowContext(ctx, ` + SELECT user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total + FROM host_period_diamond_accounts + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, appcode.FromContext(ctx), userID, cycleKey) + var account hostSalaryCandidate + if err := row.Scan(&account.UserID, &account.CycleKey, &account.RegionID, &account.AgencyOwnerUserID, &account.TotalDiamonds, &account.GiftDiamondTotal); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return hostSalaryCandidate{}, false, nil + } + return hostSalaryCandidate{}, false, err + } + return account, true, nil +} + +func (r *Repository) lockHostSalaryProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, nowMs int64) (hostSalaryProgress, error) { + // INSERT IGNORE 让第一次结算自动创建进度行;随后 FOR UPDATE 串行化同主播同周期的所有结算请求。 + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO host_salary_settlement_progress ( + app_code, user_id, cycle_key, settled_level_no, settled_host_salary_usd_minor, + settled_host_coin_reward, settled_agency_salary_usd_minor, last_settled_total_diamonds, + residual_usd_minor, month_end_cleared_at_ms, last_policy_id, version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 1, ?, ?)`, + appcode.FromContext(ctx), userID, cycleKey, nowMs, nowMs, + ); err != nil { + return hostSalaryProgress{}, err + } + row := tx.QueryRowContext(ctx, ` + SELECT settled_level_no, settled_host_salary_usd_minor, settled_host_coin_reward, + settled_agency_salary_usd_minor, last_settled_total_diamonds, residual_usd_minor, + month_end_cleared_at_ms, last_policy_id, version + FROM host_salary_settlement_progress + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, appcode.FromContext(ctx), userID, cycleKey) + var progress hostSalaryProgress + if err := row.Scan(&progress.SettledLevelNo, &progress.SettledHostSalaryUSDMinor, &progress.SettledHostCoinReward, &progress.SettledAgencySalaryUSDMinor, &progress.LastSettledTotalDiamonds, &progress.ResidualUSDMinor, &progress.MonthEndClearedAtMS, &progress.LastPolicyID, &progress.Version); err != nil { + return hostSalaryProgress{}, err + } + return progress, nil +} + +func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, regionID int64, settlementType string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { + modeClause := "" + args := []any{appcode.FromContext(ctx), regionID, nowMs, nowMs, triggerMode} + if settlementType != ledger.HostSalarySettlementTypeMonthEnd { + // 人工/自动触发模式也参与政策匹配,避免自动任务误结算只允许手动处理的政策。 + modeClause = "AND settlement_mode = ?" + args = append(args, settlementType) + } + query := fmt.Sprintf(` + SELECT policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, + CAST(gift_coin_to_diamond_ratio AS CHAR), + CAST(residual_diamond_to_usd_rate AS CHAR), + effective_from_ms, effective_to_ms + FROM host_agency_salary_policies + WHERE app_code = ? + AND region_id = ? + AND status = 'active' + AND effective_from_ms <= ? + AND (effective_to_ms = 0 OR effective_to_ms > ?) + AND settlement_trigger_mode = ? + %s + ORDER BY effective_from_ms DESC, policy_id DESC + LIMIT 1`, modeClause) + var policy ledger.HostSalaryPolicy + err := tx.QueryRowContext(ctx, query, args...).Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.Status, &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.EffectiveFromMs, &policy.EffectiveToMs) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ledger.HostSalaryPolicy{}, false, nil + } + return ledger.HostSalaryPolicy{}, false, err + } + levels, err := r.listHostSalaryPolicyLevels(ctx, tx, policy.PolicyID) + if err != nil { + return ledger.HostSalaryPolicy{}, false, err + } + policy.Levels = levels + return policy, true, nil +} + +func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, tx *sql.Tx, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) { + // 等级必须按 required_diamonds 升序返回,highestHostSalaryLevel 依赖这个顺序找到最高已达档位。 + rows, err := tx.QueryContext(ctx, ` + SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, + agency_salary_usd_minor, status, sort_order + FROM host_agency_salary_policy_levels + WHERE app_code = ? AND policy_id = ? AND status = 'active' + ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID) + if err != nil { + return nil, err + } + defer rows.Close() + var levels []ledger.HostSalaryPolicyLevel + for rows.Next() { + var level ledger.HostSalaryPolicyLevel + if err := rows.Scan(&level.LevelNo, &level.RequiredDiamonds, &level.HostSalaryUSDMinor, &level.HostCoinReward, &level.AgencySalaryUSDMinor, &level.Status, &level.SortOrder); err != nil { + return nil, err + } + levels = append(levels, level) + } + return levels, rows.Err() +} + +func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata hostSalarySettlementMetadata, hostUSDDelta int64, hostCoinDelta int64, agencyUSDDelta int64, nowMs int64) ([]walletOutboxEvent, error) { + // 主播美元、主播金币、代理美元共用同一 transaction_id,确保账务上可视为一次工资结算。 + events := make([]walletOutboxEvent, 0, 3) + if hostUSDDelta > 0 { + balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetUSDBalance, hostUSDDelta, 0, nowMs) + if err != nil { + return nil, err + } + metadata.HostUSDBalanceAfter = balanceAfter + events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetUSDBalance, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs)) + } + if hostCoinDelta > 0 { + balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, nowMs) + if err != nil { + return nil, err + } + metadata.HostCoinBalanceAfter = balanceAfter + events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, balanceAfter, 0, version, metadata, nowMs)) + } + if agencyUSDDelta > 0 { + balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.AgencyOwnerUserID, ledger.AssetUSDBalance, agencyUSDDelta, metadata.HostUserID, nowMs) + if err != nil { + return nil, err + } + metadata.AgencyUSDBalanceAfter = balanceAfter + events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetUSDBalance, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs)) + } + return events, nil +} + +func (r *Repository) creditSettlementAsset(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string, amount int64, counterpartyUserID int64, nowMs int64) (int64, int64, error) { + // 工资入账只增加可用余额,不走冻结;counterparty 仅用于代理工资反查来源主播。 + account, err := r.lockAccount(ctx, tx, userID, assetType, true, nowMs) + if err != nil { + return 0, 0, err + } + if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil { + return 0, 0, err + } + after := account.AvailableAmount + amount + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: userID, + AssetType: assetType, + AvailableDelta: amount, + FrozenDelta: 0, + AvailableAfter: after, + FrozenAfter: account.FrozenAmount, + CounterpartyUserID: counterpartyUserID, + CreatedAtMS: nowMs, + }); err != nil { + return 0, 0, err + } + return after, account.Version + 1, nil +} + +func (r *Repository) insertHostSalarySettlementRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata hostSalarySettlementMetadata, nowMs int64) error { + // 结算记录保存每次实际发放的增量;BD 结算后续从这里汇总主播工资和剩余钻石折美元。 + _, err := tx.ExecContext(ctx, ` + INSERT INTO host_salary_settlement_records ( + app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, + agency_owner_user_id, cycle_key, policy_id, level_no, total_diamonds, + host_salary_usd_minor_delta, host_coin_reward_delta, agency_salary_usd_minor_delta, + residual_usd_minor_delta, status, reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', '', ?)`, + appcode.FromContext(ctx), + hostSalarySettlementID(metadata.AppCode, commandID), + commandID, + transactionID, + metadata.SettlementType, + metadata.HostUserID, + metadata.AgencyOwnerUserID, + metadata.CycleKey, + metadata.PolicyID, + metadata.LevelNo, + metadata.TotalDiamonds, + metadata.HostSalaryUSDMinorDelta, + metadata.HostCoinRewardDelta, + metadata.AgencySalaryUSDMinorDelta, + metadata.ResidualUSDMinorDelta, + nowMs, + ) + return err +} + +func (r *Repository) updateHostSalaryProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, progress hostSalaryProgress, nowMs int64) error { + // version 乐观锁补充 FOR UPDATE 之外的防线:异常重入不会悄悄覆盖已推进的进度。 + result, err := tx.ExecContext(ctx, ` + UPDATE host_salary_settlement_progress + SET settled_level_no = ?, + settled_host_salary_usd_minor = ?, + settled_host_coin_reward = ?, + settled_agency_salary_usd_minor = ?, + last_settled_total_diamonds = ?, + residual_usd_minor = ?, + month_end_cleared_at_ms = ?, + last_policy_id = ?, + version = version + 1, + updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND cycle_key = ? AND version = ?`, + progress.SettledLevelNo, + progress.SettledHostSalaryUSDMinor, + progress.SettledHostCoinReward, + progress.SettledAgencySalaryUSDMinor, + progress.LastSettledTotalDiamonds, + progress.ResidualUSDMinor, + progress.MonthEndClearedAtMS, + progress.LastPolicyID, + nowMs, + appcode.FromContext(ctx), + userID, + cycleKey, + progress.Version, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return xerr.New(xerr.LedgerConflict, "host salary settlement progress version conflict") + } + return nil +} + +func highestHostSalaryLevel(levels []ledger.HostSalaryPolicyLevel, totalDiamonds int64) ledger.HostSalaryPolicyLevel { + // 未达到任何等级时返回零值,后续 delta 都为 0;仍可在月底用于只折算剩余钻石。 + var selected ledger.HostSalaryPolicyLevel + for _, level := range levels { + if level.RequiredDiamonds <= totalDiamonds { + selected = level + } + } + return selected +} + +func residualDiamondRemainder(totalDiamonds int64, level ledger.HostSalaryPolicyLevel) int64 { + // 剩余钻石只计算最高已达等级以上的部分,避免把已获得等级工资的门槛钻石重复折美元。 + if level.LevelNo <= 0 || totalDiamonds <= level.RequiredDiamonds { + return 0 + } + return totalDiamonds - level.RequiredDiamonds +} + +func usdMinorFromDiamondRate(diamonds int64, rate string) (int64, error) { + // 使用 big.Rat 做精确十进制换算,避免 residual_diamond_to_usd_rate 走 float 后出现分位误差。 + if diamonds <= 0 || strings.TrimSpace(rate) == "" { + return 0, nil + } + rat, ok := new(big.Rat).SetString(strings.TrimSpace(rate)) + if !ok || rat.Sign() < 0 { + return 0, xerr.New(xerr.InvalidArgument, "residual diamond rate is invalid") + } + value := new(big.Rat).Mul(new(big.Rat).SetInt64(diamonds), rat) + value.Mul(value, big.NewRat(100, 1)) + result := new(big.Int).Quo(value.Num(), value.Denom()) + if !result.IsInt64() || result.Int64() > math.MaxInt64 { + return 0, xerr.New(xerr.InvalidArgument, "residual diamond amount overflow") + } + return result.Int64(), nil +} + +func normalizeHostSalarySettlementType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case ledger.HostSalarySettlementModeDaily: + return ledger.HostSalarySettlementModeDaily + case ledger.HostSalarySettlementModeHalfMonth: + return ledger.HostSalarySettlementModeHalfMonth + case ledger.HostSalarySettlementTypeMonthEnd: + return ledger.HostSalarySettlementTypeMonthEnd + default: + return "" + } +} + +func normalizeHostSalarySettlementTriggerMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", ledger.HostSalarySettlementTriggerAutomatic: + // 空值按自动处理,保证已有 cron 调用不需要同步改所有入参。 + return ledger.HostSalarySettlementTriggerAutomatic + case ledger.HostSalarySettlementTriggerManual: + return ledger.HostSalarySettlementTriggerManual + default: + return "" + } +} + +func hostSalarySettlementCommandID(command ledger.HostSalarySettlementBatchCommand, account hostSalaryCandidate, progress hostSalaryProgress) string { + // commandID 包含“已推进后的等级 + 当前钻石”,同一状态重放命中同一个交易,不同增量生成新交易。 + return fmt.Sprintf("host_salary:%s:%s:%d:%d:%d", command.SettlementType, account.CycleKey, account.UserID, progress.SettledLevelNo, account.TotalDiamonds) +} + +func hostSalarySettlementID(appCode string, commandID string) string { + // settlement_id 对外稳定且不暴露原始 command_id,方便后台查账和防重复记录。 + return fmt.Sprintf("hsset_%s_%s", appcode.Normalize(appCode), stableHash(commandID)) +} + +func hostSalarySettlementRequestHash(metadata hostSalarySettlementMetadata) string { + // 请求哈希覆盖金额、等级和周期口径;同 commandID 但 payload 改变会被交易层识别为冲突。 + return stableHash(fmt.Sprintf("host_salary|%s|%s|%d|%d|%s|%d|%d|%d|%d|%d|%d|%d", + appcode.Normalize(metadata.AppCode), + metadata.SettlementType, + metadata.HostUserID, + metadata.AgencyOwnerUserID, + metadata.CycleKey, + metadata.PolicyID, + metadata.LevelNo, + metadata.TotalDiamonds, + metadata.HostSalaryUSDMinorDelta, + metadata.HostCoinRewardDelta, + metadata.AgencySalaryUSDMinorDelta, + metadata.ResidualUSDMinorDelta, + )) +} + +func positiveDelta(target int64, current int64) int64 { + if target <= current { + return 0 + } + return target - current +} + +func maxInt(left int, right int) int { + if right > left { + return right + } + return left +} + +func maxInt64(left int64, right int64) int64 { + if right > left { + return right + } + return left +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_repository.go index 7856f080..8cd0d2f2 100644 --- a/services/wallet-service/internal/storage/mysql/red_packet_repository.go +++ b/services/wallet-service/internal/storage/mysql/red_packet_repository.go @@ -58,6 +58,12 @@ type redPacketClaimMetadata struct { PacketID string `json:"packet_id"` UserID int64 `json:"user_id"` Amount int64 `json:"amount"` + RoomID string `json:"room_id"` + RegionID int64 `json:"region_id"` + PacketType string `json:"packet_type"` + RemainingAmount int64 `json:"remaining_amount"` + RemainingCount int32 `json:"remaining_count"` + PacketStatus string `json:"packet_status"` BalanceAfter int64 `json:"balance_after"` WalletTransaction string `json:"wallet_transaction_id"` ClaimedAtMS int64 `json:"claimed_at_ms"` @@ -69,7 +75,11 @@ type redPacketRefundMetadata struct { CommandID string `json:"command_id"` PacketID string `json:"packet_id"` SenderUserID int64 `json:"sender_user_id"` + RoomID string `json:"room_id"` + RegionID int64 `json:"region_id"` + PacketType string `json:"packet_type"` RefundAmount int64 `json:"refund_amount"` + PacketStatus string `json:"packet_status"` BalanceAfter int64 `json:"balance_after"` WalletTransaction string `json:"wallet_transaction_id"` RefundedAtMS int64 `json:"refunded_at_ms"` @@ -110,21 +120,23 @@ func (r *Repository) UpdateRedPacketConfig(ctx context.Context, config ledger.Re if _, err := tx.ExecContext(ctx, ` INSERT INTO red_packet_configs ( - app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, + app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, rule_url, updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), delayed_open_seconds = VALUES(delayed_open_seconds), expire_seconds = VALUES(expire_seconds), daily_send_limit = VALUES(daily_send_limit), + rule_url = VALUES(rule_url), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, config.AppCode, config.Enabled, config.DelayedOpenSeconds, - config.ExpireSeconds, + ledger.RedPacketExpireSeconds, config.DailySendLimit, + strings.TrimSpace(config.RuleURL), config.UpdatedByAdminID, nowMS, nowMS, @@ -202,7 +214,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack if command.TotalAmount < int64(command.PacketCount) { return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small") } - if config.ExpireSeconds <= 0 || (command.PacketType == ledger.RedPacketTypeDelayed && config.ExpireSeconds <= config.DelayedOpenSeconds) { + if ledger.RedPacketExpireSeconds <= 0 || (command.PacketType == ledger.RedPacketTypeDelayed && ledger.RedPacketExpireSeconds <= config.DelayedOpenSeconds) { return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InvalidArgument, "red packet config time range is invalid") } if err := r.incrementRedPacketDailyCounter(ctx, tx, command.SenderUserID, config.DailySendLimit, nowMS); err != nil { @@ -229,7 +241,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack openAtMS = nowMS + int64(config.DelayedOpenSeconds)*1000 status = ledger.RedPacketStatusWaitingOpen } - expiresAtMS := nowMS + int64(config.ExpireSeconds)*1000 + expiresAtMS := nowMS + int64(ledger.RedPacketExpireSeconds)*1000 balanceAfter := sender.AvailableAmount - command.TotalAmount metadata := redPacketMetadata{ AppCode: command.AppCode, @@ -322,11 +334,11 @@ func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacke if nowMS < packet.OpenAtMS { return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketNotOpen, "red packet is not open") } - if claimed, err := r.userClaimedRedPacket(ctx, tx, command.PacketID, command.UserID); err != nil || claimed { + if existingClaim, claimed, err := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID); err != nil || claimed { if err != nil { return ledger.RedPacketClaimReceipt{}, err } - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketAlreadyClaimed, "red packet already claimed") + return ledger.RedPacketClaimReceipt{Claim: existingClaim, Packet: packet}, nil } allocationNo, amount, err := r.lockOneRedPacketAllocation(ctx, tx, command.PacketID) if err != nil { @@ -350,6 +362,11 @@ func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacke PacketID: command.PacketID, UserID: command.UserID, Amount: amount, + RoomID: packet.RoomID, + RegionID: packet.RegionID, + PacketType: packet.PacketType, + RemainingAmount: packet.RemainingAmount - amount, + RemainingCount: packet.RemainingCount - 1, BalanceAfter: balanceAfter, WalletTransaction: transactionID, ClaimedAtMS: nowMS, @@ -376,7 +393,14 @@ func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacke } if err := r.insertRedPacketClaim(ctx, tx, metadata); err != nil { if isMySQLDuplicateError(err) { - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketAlreadyClaimed, "red packet already claimed") + existingClaim, claimed, lookupErr := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID) + if lookupErr != nil { + return ledger.RedPacketClaimReceipt{}, lookupErr + } + if claimed { + return r.redPacketClaimReceiptForTransaction(ctx, tx, existingClaim.WalletTransactionID) + } + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet claim already exists") } return ledger.RedPacketClaimReceipt{}, err } @@ -395,6 +419,7 @@ func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacke if newRemainingCount == 0 { newStatus = ledger.RedPacketStatusFinished } + metadata.PacketStatus = newStatus if _, err := tx.ExecContext(ctx, ` UPDATE red_packets SET remaining_amount = ?, remaining_count = ?, status = ?, updated_at_ms = ? @@ -412,7 +437,11 @@ func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacke if err := tx.Commit(); err != nil { return ledger.RedPacketClaimReceipt{}, err } - return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), BalanceAfter: balanceAfter}, nil + packet.RemainingAmount = newRemainingAmount + packet.RemainingCount = newRemainingCount + packet.Status = newStatus + packet.UpdatedAtMS = nowMS + return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: balanceAfter}, nil } func (r *Repository) ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) { @@ -458,6 +487,9 @@ func (r *Repository) ListRedPackets(ctx context.Context, query ledger.RedPacketQ if err := rows.Err(); err != nil { return nil, 0, err } + if err := r.attachRefunds(ctx, packets); err != nil { + return nil, 0, err + } if query.ViewerUserID > 0 { if err := r.attachViewerClaims(ctx, packets, query.ViewerUserID); err != nil { return nil, 0, err @@ -480,6 +512,15 @@ func (r *Repository) GetRedPacket(ctx context.Context, app string, packetID stri if err := r.attachViewerClaims(ctx, packets, viewerUserID); err != nil { return ledger.RedPacket{}, err } + if err := r.attachRefunds(ctx, packets); err != nil { + return ledger.RedPacket{}, err + } + packet = packets[0] + } else { + packets := []ledger.RedPacket{packet} + if err := r.attachRefunds(ctx, packets); err != nil { + return ledger.RedPacket{}, err + } packet = packets[0] } if includeClaims { @@ -541,6 +582,30 @@ func (r *Repository) ExpireRedPackets(ctx context.Context, app string, batchSize return result, nil } +func (r *Repository) RetryRedPacketRefund(ctx context.Context, app string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) { + if r == nil || r.db == nil { + return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + _ = operatorUserID + ctx = contextWithCommandApp(ctx, app) + nowMS := time.Now().UTC().UnixMilli() + refunded, amount, err := r.expireOneRedPacket(ctx, strings.TrimSpace(packetID), nowMS) + if err != nil { + return ledger.RedPacketRefundReceipt{}, err + } + packet, err := r.GetRedPacket(ctx, appcode.FromContext(ctx), packetID, 0, false) + if err != nil { + return ledger.RedPacketRefundReceipt{}, err + } + if !refunded { + if packet.Status == ledger.RedPacketStatusRefunded { + return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: packet.RefundAmount}, nil + } + return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is not expired") + } + return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: amount}, nil +} + func (r *Repository) expireOneRedPacket(ctx context.Context, packetID string, nowMS int64) (bool, int64, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -588,7 +653,11 @@ func (r *Repository) expireOneRedPacket(ctx context.Context, packetID string, no CommandID: commandID, PacketID: packetID, SenderUserID: packet.SenderUserID, + RoomID: packet.RoomID, + RegionID: packet.RegionID, + PacketType: packet.PacketType, RefundAmount: refundAmount, + PacketStatus: ledger.RedPacketStatusRefunded, BalanceAfter: balanceAfter, WalletTransaction: transactionID, RefundedAtMS: nowMS, @@ -649,12 +718,12 @@ func (r *Repository) expireOneRedPacket(ctx context.Context, packetID string, no func (r *Repository) getRedPacketConfig(ctx context.Context, querier redPacketQuerier) (ledger.RedPacketConfig, error) { row := querier.QueryRowContext(ctx, ` SELECT app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, - updated_by_admin_id, created_at_ms, updated_at_ms + updated_by_admin_id, COALESCE(rule_url, ''), created_at_ms, updated_at_ms FROM red_packet_configs WHERE app_code = ?`, appcode.FromContext(ctx), ) - config := ledger.RedPacketConfig{AppCode: appcode.FromContext(ctx), ExpireSeconds: 300} + config := ledger.RedPacketConfig{AppCode: appcode.FromContext(ctx), ExpireSeconds: ledger.RedPacketExpireSeconds} if err := row.Scan( &config.AppCode, &config.Enabled, @@ -662,6 +731,7 @@ func (r *Repository) getRedPacketConfig(ctx context.Context, querier redPacketQu &config.ExpireSeconds, &config.DailySendLimit, &config.UpdatedByAdminID, + &config.RuleURL, &config.CreatedAtMS, &config.UpdatedAtMS, ); err != nil { @@ -670,6 +740,7 @@ func (r *Repository) getRedPacketConfig(ctx context.Context, querier redPacketQu } return config, nil } + config.ExpireSeconds = ledger.RedPacketExpireSeconds counts, err := r.listRedPacketCountTiers(ctx, querier) if err != nil { return ledger.RedPacketConfig{}, err @@ -869,19 +940,32 @@ func scanRedPacket(row redPacketScanner) (ledger.RedPacket, error) { return packet, err } -func (r *Repository) userClaimedRedPacket(ctx context.Context, tx *sql.Tx, packetID string, userID int64) (bool, error) { - var claimID string +func (r *Repository) getUserRedPacketClaim(ctx context.Context, tx *sql.Tx, packetID string, userID int64) (ledger.RedPacketClaim, bool, error) { + var claim ledger.RedPacketClaim err := tx.QueryRowContext(ctx, ` - SELECT claim_id + SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, + status, failure_reason, created_at_ms, updated_at_ms FROM red_packet_claims WHERE app_code = ? AND packet_id = ? AND user_id = ? LIMIT 1`, appcode.FromContext(ctx), packetID, userID, - ).Scan(&claimID) + ).Scan( + &claim.AppCode, + &claim.ClaimID, + &claim.CommandID, + &claim.PacketID, + &claim.UserID, + &claim.Amount, + &claim.WalletTransactionID, + &claim.Status, + &claim.FailureReason, + &claim.CreatedAtMS, + &claim.UpdatedAtMS, + ) if errors.Is(err, sql.ErrNoRows) { - return false, nil + return ledger.RedPacketClaim{}, false, nil } - return err == nil, err + return claim, err == nil, err } func (r *Repository) lockOneRedPacketAllocation(ctx context.Context, tx *sql.Tx, packetID string) (int32, int64, error) { @@ -944,6 +1028,50 @@ func (r *Repository) attachViewerClaims(ctx context.Context, packets []ledger.Re return rows.Err() } +func (r *Repository) attachRefunds(ctx context.Context, packets []ledger.RedPacket) error { + if len(packets) == 0 { + return nil + } + packetIDs := make([]string, 0, len(packets)) + indexByID := make(map[string]int, len(packets)) + for index := range packets { + packetIDs = append(packetIDs, packets[index].PacketID) + indexByID[packets[index].PacketID] = index + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",") + args := []any{appcode.FromContext(ctx)} + for _, packetID := range packetIDs { + args = append(args, packetID) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT packet_id, refund_amount, status, updated_at_ms + FROM red_packet_refunds + WHERE app_code = ? AND packet_id IN (`+placeholders+`)`, + args..., + ) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var packetID string + var amount int64 + var status string + var updatedAtMS int64 + if err := rows.Scan(&packetID, &amount, &status, &updatedAtMS); err != nil { + return err + } + if index, ok := indexByID[packetID]; ok { + packets[index].RefundAmount = amount + packets[index].RefundStatus = status + if status == ledger.RedPacketStatusRefunded { + packets[index].RefundedAtMS = updatedAtMS + } + } + } + return rows.Err() +} + func (r *Repository) listRedPacketClaims(ctx context.Context, packetID string) ([]ledger.RedPacketClaim, error) { rows, err := r.db.QueryContext(ctx, ` SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, @@ -1015,7 +1143,11 @@ func (r *Repository) redPacketClaimReceiptForTransaction(ctx context.Context, tx if err := json.Unmarshal([]byte(raw), &metadata); err != nil { return ledger.RedPacketClaimReceipt{}, err } - return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), BalanceAfter: metadata.BalanceAfter}, nil + packet, err := r.getRedPacket(ctx, tx, metadata.PacketID) + if err != nil { + return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), BalanceAfter: metadata.BalanceAfter}, nil + } + return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil } func redPacketFromMetadata(metadata redPacketMetadata) ledger.RedPacket { diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 1edd591a..fb283e6c 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -34,6 +34,7 @@ const ( bizTypeGameCredit = "game_credit" bizTypeGameRefund = "game_refund" bizTypeGameReverse = "game_reverse" + bizTypeHostSalarySettlement = "host_salary_settlement" outboxStatusPending = "pending" outboxStatusDelivering = "delivering" outboxStatusDelivered = "delivered" @@ -104,7 +105,7 @@ func (r *Repository) Ping(ctx context.Context) error { return r.db.PingContext(ctx) } -// DebitGift 在一个 MySQL 事务内完成 sender COIN 扣减、target GIFT_POINT 入账、分录和 outbox。 +// DebitGift 在一个 MySQL 事务内完成 sender 扣费、target GIFT_POINT 入账、主播周期钻石入账、分录和 outbox。 func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { if r == nil || r.db == nil { return ledger.Receipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -154,6 +155,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if err != nil { return ledger.Receipt{}, err } + hostPeriodDiamondAdded := int64(0) + hostPeriodCycleKey := "" + if command.TargetIsHost { + // Phase 2 固化“用户礼物金币价值 -> 主播周期钻石”的默认 1:1 口径;后续政策配置只替换这里的换算快照。 + // 非主播不会进入该分支,因此普通用户收礼只增加 GIFT_POINT,不产生工资周期积分。 + hostPeriodDiamondAdded = chargeAmount + hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + } sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, chargeAmount == 0, nowMs) if err != nil { @@ -169,29 +178,34 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm transactionID := transactionID(command.AppCode, command.CommandID) metadata := giftMetadata{ - AppCode: command.AppCode, - GiftID: command.GiftID, - GiftName: giftConfig.Name, - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: price.ChargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - GiftPointAdded: giftPointAdded, - HeatValue: heatValue, - BalanceAfter: sender.AvailableAmount - chargeAmount, - BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), - SenderUserID: command.SenderUserID, - TargetUserID: command.TargetUserID, - RoomID: command.RoomID, - CoinPrice: price.CoinPrice, - GiftPointAmount: price.GiftPointAmount, - HeatUnitValue: price.HeatValue, + AppCode: command.AppCode, + GiftID: command.GiftID, + GiftName: giftConfig.Name, + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: price.ChargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + GiftPointAdded: giftPointAdded, + HeatValue: heatValue, + BalanceAfter: sender.AvailableAmount - chargeAmount, + BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), + SenderUserID: command.SenderUserID, + TargetUserID: command.TargetUserID, + TargetIsHost: command.TargetIsHost, + TargetHostRegionID: command.TargetHostRegionID, + TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + CoinPrice: price.CoinPrice, + GiftPointAmount: price.GiftPointAmount, + HeatUnitValue: price.HeatValue, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { return ledger.Receipt{}, err @@ -232,11 +246,25 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm }); err != nil { return ledger.Receipt{}, err } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + if hostPeriodDiamondAdded > 0 { + // 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币、主播礼物积分和工资积分会一起回滚。 + hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter + metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + } + + events := []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs), balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs), - }); err != nil { + } + if hostPeriodDiamondAdded > 0 { + events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) + } + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { return ledger.Receipt{}, err } if err := tx.Commit(); err != nil { @@ -1325,6 +1353,141 @@ func (r *Repository) insertEntry(ctx context.Context, tx *sql.Tx, entry walletEn return err } +func (r *Repository) creditHostPeriodDiamonds(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, nowMs int64) (int64, int64, error) { + // host_period_diamond_accounts 是工资引擎读取的投影表,不是可提现钱包资产;只有主播身份收礼会写入。 + if metadata.HostPeriodDiamondAdded <= 0 { + return 0, 0, nil + } + after, version, exists, err := r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) + if err != nil { + return 0, 0, err + } + if !exists { + // 周期账户只在主播首次收礼时创建;工资结算任务按 cycle_key 扫描,不依赖通用 wallet_accounts。 + // 首笔送礼写入当时区域和代理;同周期后续收礼会刷新快照,结算以周期账户最终快照为准。 + after = metadata.HostPeriodDiamondAdded + version = 1 + _, err = tx.ExecContext(ctx, + `INSERT INTO host_period_diamond_accounts ( + app_code, user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total, + version, first_gift_at_ms, last_gift_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + after, + metadata.HostPeriodDiamondAdded, + version, + nowMs, + nowMs, + nowMs, + nowMs, + ) + if err != nil && isMySQLDuplicateError(err) { + // 并发首笔送礼可能同时发现账户不存在;退回锁读更新路径,保证不丢钻石。 + after, version, exists, err = r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) + } + if err != nil { + return 0, 0, err + } + if !exists { + if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { + return 0, 0, err + } + return after, version, nil + } + } + + after, err = checkedAdd(after, metadata.HostPeriodDiamondAdded) + if err != nil { + return 0, 0, err + } + version++ + // 已存在账户时更新累计钻石,同时刷新区域/代理快照为最近一次收礼状态;这符合“当月当前归属”口径。 + result, err := tx.ExecContext(ctx, + `UPDATE host_period_diamond_accounts + SET region_id = ?, agency_owner_user_id = ?, total_diamonds = ?, gift_diamond_total = gift_diamond_total + ?, + version = ?, last_gift_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND cycle_key = ?`, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + after, + metadata.HostPeriodDiamondAdded, + version, + nowMs, + nowMs, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + ) + if err != nil { + return 0, 0, err + } + rows, err := result.RowsAffected() + if err != nil { + return 0, 0, err + } + if rows != 1 { + return 0, 0, xerr.New(xerr.LedgerConflict, "host period diamond account version conflict") + } + if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { + return 0, 0, err + } + return after, version, nil +} + +func (r *Repository) lockHostPeriodDiamondAccount(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (int64, int64, bool, error) { + // FOR UPDATE 串行化同主播同周期的并发送礼,保证 total_diamonds 不会被覆盖丢失。 + row := tx.QueryRowContext(ctx, + `SELECT total_diamonds, version + FROM host_period_diamond_accounts + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, + appcode.FromContext(ctx), + userID, + cycleKey, + ) + var totalDiamonds int64 + var version int64 + if err := row.Scan(&totalDiamonds, &version); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, false, nil + } + return 0, 0, false, err + } + return totalDiamonds, version, true, nil +} + +func (r *Repository) insertHostPeriodDiamondEntry(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, diamondAfter int64, nowMs int64) error { + // 明细表记录每一笔送礼带来的工资钻石,后续核对周期账户累计值时不需要解析钱包 metadata。 + _, err := tx.ExecContext(ctx, + `INSERT INTO host_period_diamond_entries ( + app_code, transaction_id, command_id, user_id, cycle_key, region_id, agency_owner_user_id, + diamond_delta, diamond_after, gift_charge_asset_type, gift_charge_amount, + gift_id, gift_count, sender_user_id, room_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + transactionID, + commandID, + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + metadata.HostPeriodDiamondAdded, + diamondAfter, + metadata.ChargeAssetType, + metadata.ChargeAmount, + metadata.GiftID, + metadata.GiftCount, + metadata.SenderUserID, + metadata.RoomID, + nowMs, + ) + return err +} + type walletOutboxEvent struct { AppCode string EventID string @@ -1575,29 +1738,36 @@ func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, tr } type giftMetadata struct { - AppCode string `json:"app_code"` - GiftID string `json:"gift_id"` - GiftName string `json:"gift_name"` - ResourceID int64 `json:"resource_id"` - ResourceSnapshot string `json:"resource_snapshot_json"` - GiftTypeCode string `json:"gift_type_code"` - PresentationJSON string `json:"presentation_json"` - SortOrder int32 `json:"sort_order"` - GiftCount int32 `json:"gift_count"` - PriceVersion string `json:"price_version"` - CoinPrice int64 `json:"coin_price"` - GiftPointAmount int64 `json:"gift_point_amount"` - HeatUnitValue int64 `json:"heat_unit_value"` - ChargeAssetType string `json:"charge_asset_type"` - ChargeAmount int64 `json:"charge_amount"` - CoinSpent int64 `json:"coin_spent"` - GiftPointAdded int64 `json:"gift_point_added"` - HeatValue int64 `json:"heat_value"` - BalanceAfter int64 `json:"balance_after"` - BillingReceipt string `json:"billing_receipt_id"` - SenderUserID int64 `json:"sender_user_id"` - TargetUserID int64 `json:"target_user_id"` - RoomID string `json:"room_id"` + AppCode string `json:"app_code"` + GiftID string `json:"gift_id"` + GiftName string `json:"gift_name"` + ResourceID int64 `json:"resource_id"` + ResourceSnapshot string `json:"resource_snapshot_json"` + GiftTypeCode string `json:"gift_type_code"` + PresentationJSON string `json:"presentation_json"` + SortOrder int32 `json:"sort_order"` + GiftCount int32 `json:"gift_count"` + PriceVersion string `json:"price_version"` + CoinPrice int64 `json:"coin_price"` + GiftPointAmount int64 `json:"gift_point_amount"` + HeatUnitValue int64 `json:"heat_unit_value"` + ChargeAssetType string `json:"charge_asset_type"` + ChargeAmount int64 `json:"charge_amount"` + CoinSpent int64 `json:"coin_spent"` + GiftPointAdded int64 `json:"gift_point_added"` + HeatValue int64 `json:"heat_value"` + BalanceAfter int64 `json:"balance_after"` + BillingReceipt string `json:"billing_receipt_id"` + SenderUserID int64 `json:"sender_user_id"` + TargetUserID int64 `json:"target_user_id"` + TargetIsHost bool `json:"target_is_host"` + TargetHostRegionID int64 `json:"target_host_region_id"` + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` + HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` + HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` + HostPeriodCycleKey string `json:"host_period_cycle_key"` + RoomID string `json:"room_id"` } type adminCreditMetadata struct { @@ -1698,16 +1868,18 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger chargeAmount = metadata.CoinSpent } return ledger.Receipt{ - BillingReceiptID: metadata.BillingReceipt, - TransactionID: transactionID, - CoinSpent: metadata.CoinSpent, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - GiftPointAdded: metadata.GiftPointAdded, - HeatValue: metadata.HeatValue, - GiftTypeCode: metadata.GiftTypeCode, - PriceVersion: metadata.PriceVersion, - BalanceAfter: metadata.BalanceAfter, + BillingReceiptID: metadata.BillingReceipt, + TransactionID: transactionID, + CoinSpent: metadata.CoinSpent, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + GiftPointAdded: metadata.GiftPointAdded, + HeatValue: metadata.HeatValue, + GiftTypeCode: metadata.GiftTypeCode, + PriceVersion: metadata.PriceVersion, + BalanceAfter: metadata.BalanceAfter, + HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded, + HostPeriodCycleKey: metadata.HostPeriodCycleKey, } } @@ -2028,6 +2200,38 @@ func giftDebitedEvent(transactionID string, commandID string, userID int64, asse } } +func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond), + EventType: "HostPeriodDiamondCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetHostPeriodDiamond, + AvailableDelta: metadata.HostPeriodDiamondAdded, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "cycle_key": metadata.HostPeriodCycleKey, + "region_id": metadata.TargetHostRegionID, + "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, + "diamond_delta": metadata.HostPeriodDiamondAdded, + "diamond_after": metadata.HostPeriodDiamondAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "sender_user_id": metadata.SenderUserID, + "room_id": metadata.RoomID, + "conversion_ratio_snapshot": "1", + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + func normalizeAssetTypes(assetTypes []string) []string { seen := make(map[string]bool, len(assetTypes)) result := make([]string, 0, len(assetTypes)) @@ -2075,9 +2279,14 @@ func checkedAdd(left int64, right int64) (int64, error) { return left + right, nil } +func hostPeriodCycleKeyFromMS(ms int64) string { + // 工资周期用 UTC 月份锚定,避免服务节点本地时区差异导致月底礼物落到不同周期。 + return time.UnixMilli(ms).UTC().Format("2006-01") +} + func debitRequestHash(command ledger.DebitGiftCommand) string { // 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。 - return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s", + return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%t|%d|%d", appcode.Normalize(command.AppCode), command.RoomID, command.SenderUserID, @@ -2085,6 +2294,10 @@ func debitRequestHash(command ledger.DebitGiftCommand) string { command.GiftID, command.GiftCount, strings.TrimSpace(command.PriceVersion), + command.RegionID, + command.TargetIsHost, + command.TargetHostRegionID, + command.TargetAgencyOwnerUserID, )) } diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index ca15047b..8f4cecb8 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -3,11 +3,13 @@ package mysqltest import ( "context" + "database/sql" "fmt" "testing" "time" "hyapp/internal/testutil/mysqlschema" + "hyapp/services/wallet-service/internal/domain/ledger" mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" ) @@ -34,6 +36,112 @@ func (r *Repository) CountRows(table string, where string, args ...any) int { return count } +// HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order. +func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string { + r.t.Helper() + + rows, err := r.schema.DB.QueryContext(context.Background(), ` + SELECT settlement_id + FROM host_salary_settlement_records + WHERE user_id = ? AND cycle_key = ? + ORDER BY created_at_ms ASC, settlement_id ASC + `, userID, cycleKey) + if err != nil { + r.t.Fatalf("query host salary settlement ids failed: %v", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + r.t.Fatalf("scan host salary settlement id failed: %v", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + r.t.Fatalf("iterate host salary settlement ids failed: %v", err) + } + return ids +} + +// MoveHostPeriodCycle merges a real gift-created current-cycle account into another cycle for month-boundary tests. +func (r *Repository) MoveHostPeriodCycle(userID int64, fromCycle string, toCycle string) { + r.t.Helper() + if fromCycle == "" || toCycle == "" || fromCycle == toCycle { + return + } + + tx, err := r.schema.DB.BeginTx(context.Background(), nil) + if err != nil { + r.t.Fatalf("begin move host period cycle failed: %v", err) + } + defer func() { _ = tx.Rollback() }() + + var sourceTotal int64 + var sourceGiftTotal int64 + var sourceLastGiftAt int64 + var sourceUpdatedAt int64 + err = tx.QueryRowContext(context.Background(), ` + SELECT total_diamonds, gift_diamond_total, last_gift_at_ms, updated_at_ms + FROM host_period_diamond_accounts + WHERE user_id = ? AND cycle_key = ? + FOR UPDATE + `, userID, fromCycle).Scan(&sourceTotal, &sourceGiftTotal, &sourceLastGiftAt, &sourceUpdatedAt) + if err != nil { + r.t.Fatalf("read source host period cycle failed: %v", err) + } + + var targetTotal int64 + err = tx.QueryRowContext(context.Background(), ` + SELECT total_diamonds + FROM host_period_diamond_accounts + WHERE user_id = ? AND cycle_key = ? + FOR UPDATE + `, userID, toCycle).Scan(&targetTotal) + if err != nil { + if err == sql.ErrNoRows { + if _, err := tx.ExecContext(context.Background(), ` + UPDATE host_period_diamond_accounts + SET cycle_key = ? + WHERE user_id = ? AND cycle_key = ? + `, toCycle, userID, fromCycle); err != nil { + r.t.Fatalf("move first host period account failed: %v", err) + } + } else { + r.t.Fatalf("read target host period cycle failed: %v", err) + } + } else { + if _, err := tx.ExecContext(context.Background(), ` + UPDATE host_period_diamond_accounts + SET total_diamonds = total_diamonds + ?, + gift_diamond_total = gift_diamond_total + ?, + version = version + 1, + last_gift_at_ms = ?, + updated_at_ms = ? + WHERE user_id = ? AND cycle_key = ? + `, sourceTotal, sourceGiftTotal, sourceLastGiftAt, sourceUpdatedAt, userID, toCycle); err != nil { + r.t.Fatalf("merge target host period account failed: %v", err) + } + if _, err := tx.ExecContext(context.Background(), ` + DELETE FROM host_period_diamond_accounts + WHERE user_id = ? AND cycle_key = ? + `, userID, fromCycle); err != nil { + r.t.Fatalf("delete merged source host period account failed: %v", err) + } + } + if _, err := tx.ExecContext(context.Background(), ` + UPDATE host_period_diamond_entries + SET cycle_key = ? + WHERE user_id = ? AND cycle_key = ? + `, toCycle, userID, fromCycle); err != nil { + r.t.Fatalf("move host period entries failed: %v", err) + } + if err := tx.Commit(); err != nil { + r.t.Fatalf("commit move host period cycle failed: %v", err) + } +} + // InsertRawGiftPrice allows tests to seed inactive or future prices without service helpers. func (r *Repository) InsertRawGiftPrice(giftID string, priceVersion string, status string, effectiveAtMs int64) { r.t.Helper() @@ -107,7 +215,52 @@ func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice r.t.Helper() nowMs := time.Now().UnixMilli() - _, err := r.schema.DB.ExecContext(context.Background(), ` + // DebitGift 真实链路先校验 active gift_config,再读取 wallet_gift_prices;测试 seed 必须同时补齐资源、礼物配置和区域关系。 + result, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO resources ( + resource_code, resource_type, name, status, grantable, manager_grant_enabled, + grant_strategy, price_type, coin_price, gift_point_amount, usage_scope_json, + created_at_ms, updated_at_ms + ) VALUES (?, 'gift', ?, 'active', TRUE, TRUE, 'new_entitlement', 'coin', ?, ?, '[]', ?, ?) + ON DUPLICATE KEY UPDATE + resource_id = LAST_INSERT_ID(resource_id), + status = VALUES(status), + coin_price = VALUES(coin_price), + gift_point_amount = VALUES(gift_point_amount), + updated_at_ms = VALUES(updated_at_ms) + `, "test-gift-"+giftID, giftID, coinPrice, giftPointAmount, nowMs, nowMs) + if err != nil { + r.t.Fatalf("seed gift resource failed: %v", err) + } + resourceID, err := result.LastInsertId() + if err != nil { + r.t.Fatalf("read gift resource id failed: %v", err) + } + if _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO gift_configs ( + gift_id, resource_id, status, name, sort_order, presentation_json, + gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'active', ?, 0, JSON_OBJECT(), 'normal', 0, 0, JSON_ARRAY(), 0, 0, ?, ?) + ON DUPLICATE KEY UPDATE + resource_id = VALUES(resource_id), + status = VALUES(status), + name = VALUES(name), + gift_type_code = VALUES(gift_type_code), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms) + `, giftID, resourceID, giftID, nowMs, nowMs); err != nil { + r.t.Fatalf("seed gift config failed: %v", err) + } + if _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO gift_config_regions (gift_id, region_id, created_at_ms, updated_at_ms) + VALUES (?, 0, ?, ?) + ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) + `, giftID, nowMs, nowMs); err != nil { + r.t.Fatalf("seed gift config region failed: %v", err) + } + _, err = r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_gift_prices ( gift_id, price_version, status, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms @@ -165,6 +318,70 @@ func (r *Repository) SetRechargeStats(userID int64, totalCoinAmount int64) { } } +// SetHostSalaryPolicy seeds the wallet runtime policy snapshot used by salary settlement tests. +func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) { + r.t.Helper() + + nowMs := time.Now().UnixMilli() + if policy.PolicyID == 0 { + policy.PolicyID = 1 + } + if policy.Status == "" { + policy.Status = "active" + } + if policy.SettlementMode == "" { + policy.SettlementMode = ledger.HostSalarySettlementModeDaily + } + if policy.SettlementTriggerMode == "" { + // 测试默认覆盖生产 cron 路径;需要验证人工政策时再显式传 manual。 + policy.SettlementTriggerMode = ledger.HostSalarySettlementTriggerAutomatic + } + if policy.GiftCoinToDiamondRatio == "" { + policy.GiftCoinToDiamondRatio = "1" + } + if policy.ResidualDiamondToUSDRate == "" { + policy.ResidualDiamondToUSDRate = "0" + } + _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO host_agency_salary_policies ( + policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, gift_coin_to_diamond_ratio, + residual_diamond_to_usd_rate, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + region_id = VALUES(region_id), + status = VALUES(status), + settlement_mode = VALUES(settlement_mode), + settlement_trigger_mode = VALUES(settlement_trigger_mode), + gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio), + residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms) + `, policy.PolicyID, policy.Name, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, policy.ResidualDiamondToUSDRate, policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs) + if err != nil { + r.t.Fatalf("seed host salary policy failed: %v", err) + } + if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE policy_id = ?`, policy.PolicyID); err != nil { + r.t.Fatalf("clear host salary policy levels failed: %v", err) + } + for _, level := range policy.Levels { + if level.Status == "" { + level.Status = "active" + } + if _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO host_agency_salary_policy_levels ( + policy_id, level_no, required_diamonds, host_salary_usd_minor, + host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor, + level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs, + ); err != nil { + r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err) + } + } +} + func initDBPath(t testing.TB) string { t.Helper() @@ -177,6 +394,8 @@ func initDBPath(t testing.TB) string { func validateTableName(table string) string { switch table { case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", + "host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies", + "host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records", "coin_seller_stock_records", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests", "vip_levels", "user_vip_memberships", "vip_purchase_orders", "user_vip_history", "resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events", diff --git a/services/wallet-service/internal/transport/grpc/cron_server.go b/services/wallet-service/internal/transport/grpc/cron_server.go new file mode 100644 index 00000000..6137fda8 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/cron_server.go @@ -0,0 +1,61 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + walletservice "hyapp/services/wallet-service/internal/service/wallet" +) + +// CronServer exposes wallet-service owned settlement batches to cron-service. +type CronServer struct { + walletv1.UnimplementedWalletCronServiceServer + + svc *walletservice.Service +} + +// NewCronServer 创建只给 cron-service 调用的批处理适配器;真实账务事务仍在 wallet repository 内完成。 +func NewCronServer(svc *walletservice.Service) *CronServer { + return &CronServer{svc: svc} +} + +func (s *CronServer) ProcessHostSalaryDailySettlementBatch(ctx context.Context, req *walletv1.CronBatchRequest) (*walletv1.CronBatchResponse, error) { + return s.processHostSalarySettlementBatch(ctx, req, ledger.HostSalarySettlementModeDaily) +} + +func (s *CronServer) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req *walletv1.CronBatchRequest) (*walletv1.CronBatchResponse, error) { + return s.processHostSalarySettlementBatch(ctx, req, ledger.HostSalarySettlementModeHalfMonth) +} + +func (s *CronServer) ProcessHostSalaryMonthEndBatch(ctx context.Context, req *walletv1.CronBatchRequest) (*walletv1.CronBatchResponse, error) { + return s.processHostSalarySettlementBatch(ctx, req, ledger.HostSalarySettlementTypeMonthEnd) +} + +func (s *CronServer) processHostSalarySettlementBatch(ctx context.Context, req *walletv1.CronBatchRequest, settlementType string) (*walletv1.CronBatchResponse, error) { + if s.svc == nil { + return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "wallet service is not configured")) + } + appCode := appcode.Normalize(req.GetAppCode()) + ctx = appcode.WithContext(ctx, appCode) + // cron-service 只决定触发哪类批次;周期、当前时间、幂等交易和余额入账全部由 wallet-service 统一裁决。 + result, err := s.svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{ + AppCode: appCode, + RunID: req.GetRunId(), + WorkerID: req.GetWorkerId(), + BatchSize: int(req.GetBatchSize()), + SettlementType: settlementType, + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.CronBatchResponse{ + ClaimedCount: int32(result.ClaimedCount), + ProcessedCount: int32(result.ProcessedCount), + SuccessCount: int32(result.SuccessCount), + FailureCount: int32(result.FailureCount), + HasMore: result.HasMore, + }, nil +} diff --git a/services/wallet-service/internal/transport/grpc/red_packet.go b/services/wallet-service/internal/transport/grpc/red_packet.go index 00486b86..a94e382a 100644 --- a/services/wallet-service/internal/transport/grpc/red_packet.go +++ b/services/wallet-service/internal/transport/grpc/red_packet.go @@ -33,6 +33,7 @@ func (s *Server) UpdateRedPacketConfig(ctx context.Context, req *walletv1.Update ExpireSeconds: req.GetExpireSeconds(), DailySendLimit: req.GetDailySendLimit(), UpdatedByAdminID: req.GetOperatorUserId(), + RuleURL: req.GetRuleUrl(), }) if err != nil { return nil, xerr.ToGRPCError(err) @@ -80,6 +81,7 @@ func (s *Server) ClaimRedPacket(ctx context.Context, req *walletv1.ClaimRedPacke Claim: redPacketClaimToProto(receipt.Claim), BalanceAfter: receipt.BalanceAfter, ServerTimeMs: time.Now().UTC().UnixMilli(), + Packet: redPacketToProto(receipt.Packet), }, nil } @@ -130,6 +132,19 @@ func (s *Server) ExpireRedPackets(ctx context.Context, req *walletv1.ExpireRedPa }, nil } +func (s *Server) RetryRedPacketRefund(ctx context.Context, req *walletv1.RetryRedPacketRefundRequest) (*walletv1.RetryRedPacketRefundResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.RetryRedPacketRefund(ctx, req.GetAppCode(), req.GetPacketId(), req.GetOperatorUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.RetryRedPacketRefundResponse{ + Packet: redPacketToProto(receipt.Packet), + RefundedAmount: receipt.RefundedAmount, + ServerTimeMs: time.Now().UTC().UnixMilli(), + }, nil +} + func redPacketConfigToProto(config ledger.RedPacketConfig) *walletv1.RedPacketConfig { return &walletv1.RedPacketConfig{ AppCode: config.AppCode, @@ -142,6 +157,7 @@ func redPacketConfigToProto(config ledger.RedPacketConfig) *walletv1.RedPacketCo UpdatedByAdminId: config.UpdatedByAdminID, CreatedAtMs: config.CreatedAtMS, UpdatedAtMs: config.UpdatedAtMS, + RuleUrl: config.RuleURL, } } @@ -170,6 +186,9 @@ func redPacketToProto(packet ledger.RedPacket) *walletv1.RedPacket { ClaimedByViewer: packet.ClaimedByViewer, ViewerClaimAmount: packet.ViewerClaimAmount, Claims: claims, + RefundAmount: packet.RefundAmount, + RefundStatus: packet.RefundStatus, + RefundedAtMs: packet.RefundedAtMS, } } diff --git a/services/wallet-service/internal/transport/grpc/server.go b/services/wallet-service/internal/transport/grpc/server.go index 5b382c89..7cd2dfeb 100644 --- a/services/wallet-service/internal/transport/grpc/server.go +++ b/services/wallet-service/internal/transport/grpc/server.go @@ -36,22 +36,28 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) GiftCount: req.GetGiftCount(), PriceVersion: req.GetPriceVersion(), RegionID: req.GetRegionId(), + TargetIsHost: req.GetTargetIsHost(), + // 工资政策区域来自 user-service host profile,不能用房间 visible_region_id 兜底。 + TargetHostRegionID: req.GetTargetHostRegionId(), + TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), }) if err != nil { return nil, xerr.ToGRPCError(err) } return &walletv1.DebitGiftResponse{ - BillingReceiptId: receipt.BillingReceiptID, - BalanceAfter: receipt.BalanceAfter, - TransactionId: receipt.TransactionID, - CoinSpent: receipt.CoinSpent, - ChargeAssetType: receipt.ChargeAssetType, - ChargeAmount: receipt.ChargeAmount, - GiftPointAdded: receipt.GiftPointAdded, - HeatValue: receipt.HeatValue, - GiftTypeCode: receipt.GiftTypeCode, - PriceVersion: receipt.PriceVersion, + BillingReceiptId: receipt.BillingReceiptID, + BalanceAfter: receipt.BalanceAfter, + TransactionId: receipt.TransactionID, + CoinSpent: receipt.CoinSpent, + ChargeAssetType: receipt.ChargeAssetType, + ChargeAmount: receipt.ChargeAmount, + GiftPointAdded: receipt.GiftPointAdded, + HeatValue: receipt.HeatValue, + GiftTypeCode: receipt.GiftTypeCode, + PriceVersion: receipt.PriceVersion, + HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded, + HostPeriodCycleKey: receipt.HostPeriodCycleKey, }, nil }