黑名单,死锁问题解决

This commit is contained in:
zhx 2026-07-23 20:35:25 +08:00
parent 94a9824f68
commit d3f29ae57e
58 changed files with 3501 additions and 1135 deletions

File diff suppressed because it is too large Load Diff

View File

@ -98,6 +98,8 @@ message User {
string profile_bg_img = 31;
string contact_info = 32;
string withdraw_usdt_trc20_address = 33;
// content_blacklisted Banner
bool content_blacklisted = 34;
}
// InviteOverview read model
@ -777,6 +779,56 @@ message AdminIssueUserAccessTokenResponse {
int64 session_last_heartbeat_at_ms = 6;
}
// UserContentBlacklistEntry
// operator_admin_id/reason/time user-service
message UserContentBlacklistEntry {
int64 user_id = 1;
string display_user_id = 2;
string default_display_user_id = 3;
string pretty_id = 4;
string pretty_display_user_id = 5;
string username = 6;
string avatar = 7;
UserStatus user_status = 8;
int64 operator_admin_id = 9;
string reason = 10;
int64 created_at_ms = 11;
int64 updated_at_ms = 12;
}
message ListUserContentBlacklistRequest {
RequestMeta meta = 1;
int32 page = 2;
int32 page_size = 3;
}
message ListUserContentBlacklistResponse {
repeated UserContentBlacklistEntry items = 1;
int64 total = 2;
}
message AdminAddUserContentBlacklistRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int64 operator_admin_id = 3;
string reason = 4;
}
message AdminAddUserContentBlacklistResponse {
UserContentBlacklistEntry item = 1;
}
message AdminRemoveUserContentBlacklistRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int64 operator_admin_id = 3;
string reason = 4;
}
message AdminRemoveUserContentBlacklistResponse {
bool removed = 1;
}
// RoomBasicUser
message RoomBasicUser {
int64 user_id = 1;
@ -1548,6 +1600,9 @@ service UserService {
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
rpc BatchGetUserAdminProfiles(BatchGetUserAdminProfilesRequest) returns (BatchGetUserAdminProfilesResponse);
rpc AdminIssueUserAccessToken(AdminIssueUserAccessTokenRequest) returns (AdminIssueUserAccessTokenResponse);
rpc ListUserContentBlacklist(ListUserContentBlacklistRequest) returns (ListUserContentBlacklistResponse);
rpc AdminAddUserContentBlacklist(AdminAddUserContentBlacklistRequest) returns (AdminAddUserContentBlacklistResponse);
rpc AdminRemoveUserContentBlacklist(AdminRemoveUserContentBlacklistRequest) returns (AdminRemoveUserContentBlacklistResponse);
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);

View File

@ -19,33 +19,36 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
UserService_AdminIssueUserAccessToken_FullMethodName = "/hyapp.user.v1.UserService/AdminIssueUserAccessToken"
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_AuthorizeUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/AuthorizeUserAvatarUpload"
UserService_CompleteUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/CompleteUserAvatarUpload"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
UserService_UpdateUserWithdrawAddress_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserWithdrawAddress"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
UserService_AdminBanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminBanUser"
UserService_AdminUnbanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminUnbanUser"
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
UserService_SearchInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/SearchInviteReferrer"
UserService_BindInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/BindInviteReferrer"
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
UserService_AdminIssueUserAccessToken_FullMethodName = "/hyapp.user.v1.UserService/AdminIssueUserAccessToken"
UserService_ListUserContentBlacklist_FullMethodName = "/hyapp.user.v1.UserService/ListUserContentBlacklist"
UserService_AdminAddUserContentBlacklist_FullMethodName = "/hyapp.user.v1.UserService/AdminAddUserContentBlacklist"
UserService_AdminRemoveUserContentBlacklist_FullMethodName = "/hyapp.user.v1.UserService/AdminRemoveUserContentBlacklist"
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_AuthorizeUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/AuthorizeUserAvatarUpload"
UserService_CompleteUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/CompleteUserAvatarUpload"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
UserService_UpdateUserWithdrawAddress_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserWithdrawAddress"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
UserService_AdminBanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminBanUser"
UserService_AdminUnbanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminUnbanUser"
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
UserService_SearchInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/SearchInviteReferrer"
UserService_BindInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/BindInviteReferrer"
)
// UserServiceClient is the client API for UserService service.
@ -61,6 +64,9 @@ type UserServiceClient interface {
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error)
AdminIssueUserAccessToken(ctx context.Context, in *AdminIssueUserAccessTokenRequest, opts ...grpc.CallOption) (*AdminIssueUserAccessTokenResponse, error)
ListUserContentBlacklist(ctx context.Context, in *ListUserContentBlacklistRequest, opts ...grpc.CallOption) (*ListUserContentBlacklistResponse, error)
AdminAddUserContentBlacklist(ctx context.Context, in *AdminAddUserContentBlacklistRequest, opts ...grpc.CallOption) (*AdminAddUserContentBlacklistResponse, error)
AdminRemoveUserContentBlacklist(ctx context.Context, in *AdminRemoveUserContentBlacklistRequest, opts ...grpc.CallOption) (*AdminRemoveUserContentBlacklistResponse, error)
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
@ -161,6 +167,36 @@ func (c *userServiceClient) AdminIssueUserAccessToken(ctx context.Context, in *A
return out, nil
}
func (c *userServiceClient) ListUserContentBlacklist(ctx context.Context, in *ListUserContentBlacklistRequest, opts ...grpc.CallOption) (*ListUserContentBlacklistResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserContentBlacklistResponse)
err := c.cc.Invoke(ctx, UserService_ListUserContentBlacklist_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) AdminAddUserContentBlacklist(ctx context.Context, in *AdminAddUserContentBlacklistRequest, opts ...grpc.CallOption) (*AdminAddUserContentBlacklistResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminAddUserContentBlacklistResponse)
err := c.cc.Invoke(ctx, UserService_AdminAddUserContentBlacklist_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) AdminRemoveUserContentBlacklist(ctx context.Context, in *AdminRemoveUserContentBlacklistRequest, opts ...grpc.CallOption) (*AdminRemoveUserContentBlacklistResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminRemoveUserContentBlacklistResponse)
err := c.cc.Invoke(ctx, UserService_AdminRemoveUserContentBlacklist_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetRoomBasicUsersResponse)
@ -374,6 +410,9 @@ type UserServiceServer interface {
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error)
AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error)
ListUserContentBlacklist(context.Context, *ListUserContentBlacklistRequest) (*ListUserContentBlacklistResponse, error)
AdminAddUserContentBlacklist(context.Context, *AdminAddUserContentBlacklistRequest) (*AdminAddUserContentBlacklistResponse, error)
AdminRemoveUserContentBlacklist(context.Context, *AdminRemoveUserContentBlacklistRequest) (*AdminRemoveUserContentBlacklistResponse, error)
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
@ -425,6 +464,15 @@ func (UnimplementedUserServiceServer) BatchGetUserAdminProfiles(context.Context,
func (UnimplementedUserServiceServer) AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminIssueUserAccessToken not implemented")
}
func (UnimplementedUserServiceServer) ListUserContentBlacklist(context.Context, *ListUserContentBlacklistRequest) (*ListUserContentBlacklistResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListUserContentBlacklist not implemented")
}
func (UnimplementedUserServiceServer) AdminAddUserContentBlacklist(context.Context, *AdminAddUserContentBlacklistRequest) (*AdminAddUserContentBlacklistResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminAddUserContentBlacklist not implemented")
}
func (UnimplementedUserServiceServer) AdminRemoveUserContentBlacklist(context.Context, *AdminRemoveUserContentBlacklistRequest) (*AdminRemoveUserContentBlacklistResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminRemoveUserContentBlacklist not implemented")
}
func (UnimplementedUserServiceServer) BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BatchGetRoomBasicUsers not implemented")
}
@ -632,6 +680,60 @@ func _UserService_AdminIssueUserAccessToken_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _UserService_ListUserContentBlacklist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUserContentBlacklistRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).ListUserContentBlacklist(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_ListUserContentBlacklist_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).ListUserContentBlacklist(ctx, req.(*ListUserContentBlacklistRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_AdminAddUserContentBlacklist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminAddUserContentBlacklistRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).AdminAddUserContentBlacklist(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_AdminAddUserContentBlacklist_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).AdminAddUserContentBlacklist(ctx, req.(*AdminAddUserContentBlacklistRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_AdminRemoveUserContentBlacklist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminRemoveUserContentBlacklistRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).AdminRemoveUserContentBlacklist(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_AdminRemoveUserContentBlacklist_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).AdminRemoveUserContentBlacklist(ctx, req.(*AdminRemoveUserContentBlacklistRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_BatchGetRoomBasicUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetRoomBasicUsersRequest)
if err := dec(in); err != nil {
@ -1027,6 +1129,18 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdminIssueUserAccessToken",
Handler: _UserService_AdminIssueUserAccessToken_Handler,
},
{
MethodName: "ListUserContentBlacklist",
Handler: _UserService_ListUserContentBlacklist_Handler,
},
{
MethodName: "AdminAddUserContentBlacklist",
Handler: _UserService_AdminAddUserContentBlacklist_Handler,
},
{
MethodName: "AdminRemoveUserContentBlacklist",
Handler: _UserService_AdminRemoveUserContentBlacklist_Handler,
},
{
MethodName: "BatchGetRoomBasicUsers",
Handler: _UserService_BatchGetRoomBasicUsers_Handler,

View File

@ -1502,8 +1502,13 @@ type HostSalaryPolicy struct {
MinimumWithdrawUsdMinor int64 `protobuf:"varint,17,opt,name=minimum_withdraw_usd_minor,json=minimumWithdrawUsdMinor,proto3" json:"minimum_withdraw_usd_minor,omitempty"`
WithdrawFeeBps int32 `protobuf:"varint,18,opt,name=withdraw_fee_bps,json=withdrawFeeBps,proto3" json:"withdraw_fee_bps,omitempty"`
AgencyPointShareBps int32 `protobuf:"varint,19,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// 两个渠道的最低金额分别进入各自资金事务;旧 minimum_withdraw_usd_minor 仅保留为平台金额兼容别名。
CoinSellerMinimumWithdrawUsdMinor int64 `protobuf:"varint,20,opt,name=coin_seller_minimum_withdraw_usd_minor,json=coinSellerMinimumWithdrawUsdMinor,proto3" json:"coin_seller_minimum_withdraw_usd_minor,omitempty"`
PlatformMinimumWithdrawUsdMinor int64 `protobuf:"varint,21,opt,name=platform_minimum_withdraw_usd_minor,json=platformMinimumWithdrawUsdMinor,proto3" json:"platform_minimum_withdraw_usd_minor,omitempty"`
// 只控制 POINT_DIAMOND -> COIN关闭后永久积分余额和两种提现能力仍可独立使用。
DiamondExchangeEnabled bool `protobuf:"varint,22,opt,name=diamond_exchange_enabled,json=diamondExchangeEnabled,proto3" json:"diamond_exchange_enabled,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HostSalaryPolicy) Reset() {
@ -1669,6 +1674,27 @@ func (x *HostSalaryPolicy) GetAgencyPointShareBps() int32 {
return 0
}
func (x *HostSalaryPolicy) GetCoinSellerMinimumWithdrawUsdMinor() int64 {
if x != nil {
return x.CoinSellerMinimumWithdrawUsdMinor
}
return 0
}
func (x *HostSalaryPolicy) GetPlatformMinimumWithdrawUsdMinor() int64 {
if x != nil {
return x.PlatformMinimumWithdrawUsdMinor
}
return 0
}
func (x *HostSalaryPolicy) GetDiamondExchangeEnabled() bool {
if x != nil {
return x.DiamondExchangeEnabled
}
return false
}
type GetActiveHostSalaryPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
@ -26927,17 +26953,21 @@ type GetPointWithdrawalConfigResponse struct {
MinimumPoints int64 `protobuf:"varint,4,opt,name=minimum_points,json=minimumPoints,proto3" json:"minimum_points,omitempty"`
PolicyInstanceCode string `protobuf:"bytes,5,opt,name=policy_instance_code,json=policyInstanceCode,proto3" json:"policy_instance_code,omitempty"`
// coins_per_usd 复用工资兑换普通金币的统一比例,与 points_per_usd 组合得到 POINT->COIN 展示比例。
CoinsPerUsd int64 `protobuf:"varint,6,opt,name=coins_per_usd,json=coinsPerUsd,proto3" json:"coins_per_usd,omitempty"`
PolicyType string `protobuf:"bytes,7,opt,name=policy_type,json=policyType,proto3" json:"policy_type,omitempty"`
MinimumWithdrawUsdMinor int64 `protobuf:"varint,8,opt,name=minimum_withdraw_usd_minor,json=minimumWithdrawUsdMinor,proto3" json:"minimum_withdraw_usd_minor,omitempty"`
AgencyPointShareBps int32 `protobuf:"varint,9,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"`
PolicyId uint64 `protobuf:"varint,10,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"`
PolicyVersion uint64 `protobuf:"varint,11,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"`
AvailabilityEvaluated bool `protobuf:"varint,12,opt,name=availability_evaluated,json=availabilityEvaluated,proto3" json:"availability_evaluated,omitempty"`
CoinSellerAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,13,opt,name=coin_seller_availability,json=coinSellerAvailability,proto3" json:"coin_seller_availability,omitempty"`
PlatformAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,14,opt,name=platform_availability,json=platformAvailability,proto3" json:"platform_availability,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
CoinsPerUsd int64 `protobuf:"varint,6,opt,name=coins_per_usd,json=coinsPerUsd,proto3" json:"coins_per_usd,omitempty"`
PolicyType string `protobuf:"bytes,7,opt,name=policy_type,json=policyType,proto3" json:"policy_type,omitempty"`
MinimumWithdrawUsdMinor int64 `protobuf:"varint,8,opt,name=minimum_withdraw_usd_minor,json=minimumWithdrawUsdMinor,proto3" json:"minimum_withdraw_usd_minor,omitempty"`
AgencyPointShareBps int32 `protobuf:"varint,9,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"`
PolicyId uint64 `protobuf:"varint,10,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"`
PolicyVersion uint64 `protobuf:"varint,11,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"`
AvailabilityEvaluated bool `protobuf:"varint,12,opt,name=availability_evaluated,json=availabilityEvaluated,proto3" json:"availability_evaluated,omitempty"`
CoinSellerAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,13,opt,name=coin_seller_availability,json=coinSellerAvailability,proto3" json:"coin_seller_availability,omitempty"`
PlatformAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,14,opt,name=platform_availability,json=platformAvailability,proto3" json:"platform_availability,omitempty"`
CoinSellerMinimumWithdrawUsdMinor int64 `protobuf:"varint,15,opt,name=coin_seller_minimum_withdraw_usd_minor,json=coinSellerMinimumWithdrawUsdMinor,proto3" json:"coin_seller_minimum_withdraw_usd_minor,omitempty"`
PlatformMinimumWithdrawUsdMinor int64 `protobuf:"varint,16,opt,name=platform_minimum_withdraw_usd_minor,json=platformMinimumWithdrawUsdMinor,proto3" json:"platform_minimum_withdraw_usd_minor,omitempty"`
// optional 区分旧 wallet-service 未返回字段与运营明确关闭,支持滚动升级时保持原开启行为。
DiamondExchangeEnabled *bool `protobuf:"varint,17,opt,name=diamond_exchange_enabled,json=diamondExchangeEnabled,proto3,oneof" json:"diamond_exchange_enabled,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetPointWithdrawalConfigResponse) Reset() {
@ -27068,6 +27098,27 @@ func (x *GetPointWithdrawalConfigResponse) GetPlatformAvailability() *PointWithd
return nil
}
func (x *GetPointWithdrawalConfigResponse) GetCoinSellerMinimumWithdrawUsdMinor() int64 {
if x != nil {
return x.CoinSellerMinimumWithdrawUsdMinor
}
return 0
}
func (x *GetPointWithdrawalConfigResponse) GetPlatformMinimumWithdrawUsdMinor() int64 {
if x != nil {
return x.PlatformMinimumWithdrawUsdMinor
}
return 0
}
func (x *GetPointWithdrawalConfigResponse) GetDiamondExchangeEnabled() bool {
if x != nil && x.DiamondExchangeEnabled != nil {
return *x.DiamondExchangeEnabled
}
return false
}
// VipBenefitPreviewItem 是同一权益下的一项预览内容;独立稳定键允许 Flutter 在多图、动态资源间切换,
// 且不会把数组下标误当成资源身份。
type VipBenefitPreviewItem struct {
@ -27652,7 +27703,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x17agency_salary_usd_minor\x18\x05 \x01(\x03R\x14agencySalaryUsdMinor\x12\x16\n" +
"\x06status\x18\x06 \x01(\tR\x06status\x12\x1d\n" +
"\n" +
"sort_order\x18\a \x01(\x05R\tsortOrder\"\xc3\x06\n" +
"sort_order\x18\a \x01(\x05R\tsortOrder\"\x9e\b\n" +
"\x10HostSalaryPolicy\x12\x1b\n" +
"\tpolicy_id\x18\x01 \x01(\x04R\bpolicyId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@ -27674,7 +27725,10 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\rcoins_per_usd\x18\x10 \x01(\x03R\vcoinsPerUsd\x12;\n" +
"\x1aminimum_withdraw_usd_minor\x18\x11 \x01(\x03R\x17minimumWithdrawUsdMinor\x12(\n" +
"\x10withdraw_fee_bps\x18\x12 \x01(\x05R\x0ewithdrawFeeBps\x123\n" +
"\x16agency_point_share_bps\x18\x13 \x01(\x05R\x13agencyPointShareBps\"\x8e\x02\n" +
"\x16agency_point_share_bps\x18\x13 \x01(\x05R\x13agencyPointShareBps\x12Q\n" +
"&coin_seller_minimum_withdraw_usd_minor\x18\x14 \x01(\x03R!coinSellerMinimumWithdrawUsdMinor\x12L\n" +
"#platform_minimum_withdraw_usd_minor\x18\x15 \x01(\x03R\x1fplatformMinimumWithdrawUsdMinor\x128\n" +
"\x18diamond_exchange_enabled\x18\x16 \x01(\bR\x16diamondExchangeEnabled\"\x8e\x02\n" +
" GetActiveHostSalaryPolicyRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -30226,7 +30280,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"used_count\x18\x04 \x01(\x03R\tusedCount\x12'\n" +
"\x0fremaining_count\x18\x05 \x01(\x03R\x0eremainingCount\x12!\n" +
"\fallowed_days\x18\x06 \x01(\tR\vallowedDays\x12!\n" +
"\fblock_reason\x18\a \x01(\tR\vblockReason\"\xd9\x05\n" +
"\fblock_reason\x18\a \x01(\tR\vblockReason\"\xd6\a\n" +
" GetPointWithdrawalConfigResponse\x12\x14\n" +
"\x05found\x18\x01 \x01(\bR\x05found\x12$\n" +
"\x0epoints_per_usd\x18\x02 \x01(\x03R\fpointsPerUsd\x12\x17\n" +
@ -30243,7 +30297,11 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0epolicy_version\x18\v \x01(\x04R\rpolicyVersion\x125\n" +
"\x16availability_evaluated\x18\f \x01(\bR\x15availabilityEvaluated\x12l\n" +
"\x18coin_seller_availability\x18\r \x01(\v22.hyapp.wallet.v1.PointWithdrawalActionAvailabilityR\x16coinSellerAvailability\x12g\n" +
"\x15platform_availability\x18\x0e \x01(\v22.hyapp.wallet.v1.PointWithdrawalActionAvailabilityR\x14platformAvailability\"\x98\x02\n" +
"\x15platform_availability\x18\x0e \x01(\v22.hyapp.wallet.v1.PointWithdrawalActionAvailabilityR\x14platformAvailability\x12Q\n" +
"&coin_seller_minimum_withdraw_usd_minor\x18\x0f \x01(\x03R!coinSellerMinimumWithdrawUsdMinor\x12L\n" +
"#platform_minimum_withdraw_usd_minor\x18\x10 \x01(\x03R\x1fplatformMinimumWithdrawUsdMinor\x12=\n" +
"\x18diamond_exchange_enabled\x18\x11 \x01(\bH\x00R\x16diamondExchangeEnabled\x88\x01\x01B\x1b\n" +
"\x19_diamond_exchange_enabled\"\x98\x02\n" +
"\x15VipBenefitPreviewItem\x12\x1d\n" +
"\n" +
"preview_id\x18\x01 \x01(\tR\tpreviewId\x12\x14\n" +
@ -31187,6 +31245,7 @@ func file_proto_wallet_v1_wallet_proto_init() {
file_proto_wallet_v1_wallet_proto_msgTypes[153].OneofWrappers = []any{}
file_proto_wallet_v1_wallet_proto_msgTypes[219].OneofWrappers = []any{}
file_proto_wallet_v1_wallet_proto_msgTypes[231].OneofWrappers = []any{}
file_proto_wallet_v1_wallet_proto_msgTypes[298].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

View File

@ -223,6 +223,11 @@ message HostSalaryPolicy {
int64 minimum_withdraw_usd_minor = 17;
int32 withdraw_fee_bps = 18;
int32 agency_point_share_bps = 19;
// minimum_withdraw_usd_minor
int64 coin_seller_minimum_withdraw_usd_minor = 20;
int64 platform_minimum_withdraw_usd_minor = 21;
// POINT_DIAMOND -> COIN使
bool diamond_exchange_enabled = 22;
}
message GetActiveHostSalaryPolicyRequest {
@ -3143,6 +3148,10 @@ message GetPointWithdrawalConfigResponse {
bool availability_evaluated = 12;
PointWithdrawalActionAvailability coin_seller_availability = 13;
PointWithdrawalActionAvailability platform_availability = 14;
int64 coin_seller_minimum_withdraw_usd_minor = 15;
int64 platform_minimum_withdraw_usd_minor = 16;
// optional wallet-service
optional bool diamond_exchange_enabled = 17;
}
// VipBenefitPreviewItem Flutter

View File

@ -92,9 +92,16 @@ var catalog = map[Code]Spec{
CPAlreadyExistsSelf: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsSelf, "You already have a CP"),
CPAlreadyExistsOther: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsOther, "The other party already has a CP"),
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "wallet state changed, please retry"),
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "wallet state changed, please retry"),
WalletResourceReadTransient: {
GRPCCode: codes.Unavailable,
HTTPStatus: httpStatusBadGateway,
PublicCode: string(WalletResourceReadTransient),
PublicMessage: "wallet resource data is temporarily unavailable",
Retryable: true,
},
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "coin seller is not active"),
CoinSellerStockTypeInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockTypeInvalid, "invalid argument"),
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),

View File

@ -95,6 +95,8 @@ const (
DuplicateBillingCommand Code = "DUPLICATE_BILLING_COMMAND"
// LedgerConflict 表示账本并发写入或余额版本冲突。
LedgerConflict Code = "LEDGER_CONFLICT"
// WalletResourceReadTransient 表示钱包资源读模型遇到可安全重试的瞬时数据库冲突。
WalletResourceReadTransient Code = "WALLET_RESOURCE_READ_TRANSIENT"
// CoinSellerNotActive 表示目标用户不存在 active 币商身份。
CoinSellerNotActive Code = "COIN_SELLER_NOT_ACTIVE"
// CoinSellerStockTypeInvalid 表示币商库存入账类型不属于支持范围。

View File

@ -217,6 +217,7 @@ type UserIdentity struct {
type User struct {
UserID int64 `json:"userId,string"`
Status string `json:"status"`
ContentBlacklisted bool `json:"contentBlacklisted"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
DisplayUserID string `json:"displayUserId"`
@ -438,6 +439,20 @@ func (c *GRPCClient) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanU
return c.client.AdminUnbanUser(ctx, req)
}
// 内容黑名单仍由 user-service 持有事实admin-server 只透传治理命令和读模型,
// 避免后台直接读取用户库后形成第二套状态判断。
func (c *GRPCClient) ListUserContentBlacklist(ctx context.Context, req *userv1.ListUserContentBlacklistRequest) (*userv1.ListUserContentBlacklistResponse, error) {
return c.client.ListUserContentBlacklist(ctx, req)
}
func (c *GRPCClient) AdminAddUserContentBlacklist(ctx context.Context, req *userv1.AdminAddUserContentBlacklistRequest) (*userv1.AdminAddUserContentBlacklistResponse, error) {
return c.client.AdminAddUserContentBlacklist(ctx, req)
}
func (c *GRPCClient) AdminRemoveUserContentBlacklist(ctx context.Context, req *userv1.AdminRemoveUserContentBlacklistRequest) (*userv1.AdminRemoveUserContentBlacklistResponse, error) {
return c.client.AdminRemoveUserContentBlacklist(ctx, req)
}
func userStatusToProto(status string) userv1.UserStatus {
switch status {
case "active":
@ -494,6 +509,7 @@ func fromProtoUser(user *userv1.User) *User {
return &User{
UserID: user.GetUserId(),
Status: fromProtoStatus(user.GetStatus()),
ContentBlacklisted: user.GetContentBlacklisted(),
CreatedAtMs: user.GetCreatedAtMs(),
UpdatedAtMs: user.GetUpdatedAtMs(),
DisplayUserID: user.GetDisplayUserId(),

View File

@ -454,13 +454,17 @@ type HostAgencySalaryPolicy struct {
// 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"`
PointDiamondsPerUSD int64 `gorm:"column:point_diamonds_per_usd;not null;default:0" json:"pointDiamondsPerUsd"`
CoinsPerUSD int64 `gorm:"column:coins_per_usd;not null;default:0" json:"coinsPerUsd"`
MinimumWithdrawUSDMinor int64 `gorm:"column:minimum_withdraw_usd_minor;not null;default:0" json:"minimumWithdrawUsdMinor"`
WithdrawFeeBPS int32 `gorm:"column:withdraw_fee_bps;not null;default:0" json:"withdrawFeeBps"`
AgencyPointShareBPS int32 `gorm:"column:agency_point_share_bps;not null;default:0" json:"agencyPointShareBps"`
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
PointDiamondsPerUSD int64 `gorm:"column:point_diamonds_per_usd;not null;default:0" json:"pointDiamondsPerUsd"`
CoinsPerUSD int64 `gorm:"column:coins_per_usd;not null;default:0" json:"coinsPerUsd"`
// MinimumWithdrawUSDMinor 保留为旧客户端平台门槛别名;新配置和运行校验必须使用下面两个渠道字段。
MinimumWithdrawUSDMinor int64 `gorm:"column:minimum_withdraw_usd_minor;not null;default:0" json:"minimumWithdrawUsdMinor"`
CoinSellerMinimumWithdrawUSDMinor int64 `gorm:"column:coin_seller_minimum_withdraw_usd_minor;not null;default:0" json:"coinSellerMinimumWithdrawUsdMinor"`
PlatformMinimumWithdrawUSDMinor int64 `gorm:"column:platform_minimum_withdraw_usd_minor;not null;default:0" json:"platformMinimumWithdrawUsdMinor"`
DiamondExchangeEnabled bool `gorm:"column:diamond_exchange_enabled;not null;default:true" json:"diamondExchangeEnabled"`
WithdrawFeeBPS int32 `gorm:"column:withdraw_fee_bps;not null;default:0" json:"withdrawFeeBps"`
AgencyPointShareBPS int32 `gorm:"column:agency_point_share_bps;not null;default:0" json:"agencyPointShareBps"`
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
// 提现次数限制随月度政策形成不可变快照count=0 表示不限period 只允许 day/week/month。
CoinSellerWithdrawalLimitPeriod string `gorm:"column:coin_seller_withdrawal_limit_period;size:16;not null;default:month" json:"coinSellerWithdrawalLimitPeriod"`
CoinSellerWithdrawalLimitCount int64 `gorm:"column:coin_seller_withdrawal_limit_count;not null;default:0" json:"coinSellerWithdrawalLimitCount"`

View File

@ -0,0 +1,209 @@
package appuser
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
userv1 "hyapp.local/api/proto/user/v1"
)
const maxContentBlacklistIdentifierRunes = 64
// AppUserContentBlacklistEntry 是管理端当前态读模型。
// userId 始终按字符串输出,避免 19 位 Snowflake ID 穿过 JavaScript Number 后失真。
type AppUserContentBlacklistEntry struct {
Target AppUserBrief `json:"target"`
UserStatus string `json:"userStatus"`
OperatorAdminID string `json:"operatorAdminId"`
Reason string `json:"reason"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppUserContentBlacklistCandidate struct {
Target AppUserBrief `json:"target"`
UserStatus string `json:"userStatus"`
Blacklisted bool `json:"blacklisted"`
}
type contentBlacklistMutationRequest struct {
UserIdentifier string `json:"user_id"`
Reason string `json:"reason"`
}
// ListUserContentBlacklist 通过 owner service 分页读取,不允许 admin 用直连 userDB 的方式复制治理事实。
func (s *Service) ListUserContentBlacklist(ctx context.Context, page int, pageSize int, requestID string) ([]AppUserContentBlacklistEntry, int64, error) {
if s.contentBlacklistClient == nil {
return nil, 0, fmt.Errorf("user content blacklist client is not configured")
}
if page < 1 {
page = 1
}
if pageSize <= 0 {
pageSize = 50
}
if pageSize > 200 {
pageSize = 200
}
response, err := s.contentBlacklistClient.ListUserContentBlacklist(ctx, &userv1.ListUserContentBlacklistRequest{
Meta: contentBlacklistRequestMeta(ctx, requestID),
Page: int32(page),
PageSize: int32(pageSize),
})
if err != nil {
return nil, 0, err
}
items := make([]AppUserContentBlacklistEntry, 0, len(response.GetItems()))
for _, item := range response.GetItems() {
if item == nil {
continue
}
items = append(items, appUserContentBlacklistEntryFromProto(item))
}
return items, response.GetTotal(), nil
}
// ResolveUserContentBlacklistCandidate 复用 user-service 的后台通用 ID 解析:
// 永久默认短号、当前有效展示号、靓号和内部长 user_id 都收敛到同一个真实 user_id。
func (s *Service) ResolveUserContentBlacklistCandidate(ctx context.Context, identifier string, requestID string) (AppUserContentBlacklistCandidate, error) {
identifier = strings.TrimSpace(identifier)
if identifier == "" || len([]rune(identifier)) > maxContentBlacklistIdentifierRunes {
return AppUserContentBlacklistCandidate{}, fmt.Errorf("%w: 用户 ID 参数不正确", ErrInvalidArgument)
}
if s.contentBlacklistClient == nil {
return AppUserContentBlacklistCandidate{}, fmt.Errorf("user content blacklist client is not configured")
}
identity, err := s.contentBlacklistClient.ResolveAdminUserIdentifier(ctx, userclient.ResolveAdminUserIdentifierRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
UserIdentifier: identifier,
})
if err != nil {
return AppUserContentBlacklistCandidate{}, err
}
if identity == nil || identity.UserID <= 0 {
return AppUserContentBlacklistCandidate{}, ErrNotFound
}
user, err := s.userClient.GetUser(ctx, userclient.GetUserRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
UserID: identity.UserID,
})
if err != nil {
return AppUserContentBlacklistCandidate{}, err
}
if user == nil {
return AppUserContentBlacklistCandidate{}, ErrNotFound
}
return AppUserContentBlacklistCandidate{
Target: AppUserBrief{
UserID: strconv.FormatInt(user.UserID, 10),
DisplayUserID: user.DisplayUserID,
Username: user.Username,
Avatar: user.Avatar,
},
UserStatus: user.Status,
Blacklisted: user.ContentBlacklisted,
}, nil
}
// AddUserContentBlacklist 先用通用 ID 契约解析真实用户,再仅把真实 user_id 发送给治理 RPC。
// 这样前端输入的短号即使以后过期,也不会成为黑名单持久键。
func (s *Service) AddUserContentBlacklist(ctx context.Context, identifier string, operatorAdminID int64, reason string, requestID string) (AppUserContentBlacklistEntry, error) {
if operatorAdminID <= 0 {
return AppUserContentBlacklistEntry{}, fmt.Errorf("%w: 操作管理员参数不正确", ErrInvalidArgument)
}
candidate, err := s.ResolveUserContentBlacklistCandidate(ctx, identifier, requestID)
if err != nil {
return AppUserContentBlacklistEntry{}, err
}
userID, err := strconv.ParseInt(candidate.Target.UserID, 10, 64)
if err != nil || userID <= 0 {
return AppUserContentBlacklistEntry{}, fmt.Errorf("%w: 用户 ID 参数不正确", ErrInvalidArgument)
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "admin_content_blacklist"
}
response, err := s.contentBlacklistClient.AdminAddUserContentBlacklist(ctx, &userv1.AdminAddUserContentBlacklistRequest{
Meta: contentBlacklistRequestMeta(ctx, requestID),
UserId: userID,
OperatorAdminId: operatorAdminID,
Reason: reason,
})
if err != nil {
return AppUserContentBlacklistEntry{}, err
}
if response.GetItem() == nil {
return AppUserContentBlacklistEntry{}, fmt.Errorf("user-service returned empty content blacklist entry")
}
return appUserContentBlacklistEntryFromProto(response.GetItem()), nil
}
// RemoveUserContentBlacklist 以内部真实 user_id 移除当前态;重复移除由 owner service 返回 removed=false
// handler 仍按幂等成功处理,避免管理员因网络重试得到伪失败。
func (s *Service) RemoveUserContentBlacklist(ctx context.Context, userID int64, operatorAdminID int64, reason string, requestID string) (bool, error) {
if s.contentBlacklistClient == nil {
return false, fmt.Errorf("user content blacklist client is not configured")
}
if userID <= 0 || operatorAdminID <= 0 {
return false, fmt.Errorf("%w: 黑名单移除参数不正确", ErrInvalidArgument)
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "admin_remove_content_blacklist"
}
response, err := s.contentBlacklistClient.AdminRemoveUserContentBlacklist(ctx, &userv1.AdminRemoveUserContentBlacklistRequest{
Meta: contentBlacklistRequestMeta(ctx, requestID),
UserId: userID,
OperatorAdminId: operatorAdminID,
Reason: reason,
})
if err != nil {
return false, err
}
return response.GetRemoved(), nil
}
func contentBlacklistRequestMeta(ctx context.Context, requestID string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: strings.TrimSpace(requestID),
Caller: "hyapp-admin-server",
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: appctx.FromContext(ctx),
}
}
func appUserContentBlacklistEntryFromProto(item *userv1.UserContentBlacklistEntry) AppUserContentBlacklistEntry {
return AppUserContentBlacklistEntry{
Target: AppUserBrief{
UserID: strconv.FormatInt(item.GetUserId(), 10),
DisplayUserID: item.GetDisplayUserId(),
Username: item.GetUsername(),
Avatar: item.GetAvatar(),
},
UserStatus: contentBlacklistUserStatus(item.GetUserStatus()),
OperatorAdminID: strconv.FormatInt(item.GetOperatorAdminId(), 10),
Reason: item.GetReason(),
CreatedAtMs: item.GetCreatedAtMs(),
UpdatedAtMs: item.GetUpdatedAtMs(),
}
}
func contentBlacklistUserStatus(status userv1.UserStatus) string {
switch status {
case userv1.UserStatus_USER_STATUS_ACTIVE:
return "active"
case userv1.UserStatus_USER_STATUS_DISABLED:
return "disabled"
case userv1.UserStatus_USER_STATUS_BANNED:
return "banned"
default:
return ""
}
}

View File

@ -76,6 +76,83 @@ func (h *Handler) ListBannedUsers(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListUserContentBlacklist(c *gin.Context) {
options := shared.ListOptions(c)
items, total, err := h.service.ListUserContentBlacklist(
c.Request.Context(),
options.Page,
options.PageSize,
middleware.CurrentRequestID(c),
)
if err != nil {
writeReadError(c, err, "获取黑名单屏蔽用户失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) ResolveUserContentBlacklistCandidate(c *gin.Context) {
identifier := firstQuery(c, "user_id", "userId", "identifier")
candidate, err := h.service.ResolveUserContentBlacklistCandidate(c.Request.Context(), identifier, middleware.CurrentRequestID(c))
if err != nil {
// 搜索输入和未找到都需要给管理员可操作的反馈gRPC 业务错误按原状态映射。
writeMutationError(c, err, "搜索 App 用户失败")
return
}
response.OK(c, candidate)
}
func (h *Handler) AddUserContentBlacklist(c *gin.Context) {
var req contentBlacklistMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "黑名单用户参数不正确")
return
}
item, err := h.service.AddUserContentBlacklist(
c.Request.Context(),
req.UserIdentifier,
int64(middleware.CurrentUserID(c)),
req.Reason,
middleware.CurrentRequestID(c),
)
if err != nil {
writeMutationError(c, err, "添加黑名单屏蔽用户失败")
return
}
userID, _ := strconv.ParseInt(item.Target.UserID, 10, 64)
writeAppUserAuditLog(c, h.audit, "add-user-content-blacklist", "user_content_blacklist", userID, "success", strings.TrimSpace(item.Reason))
response.OK(c, item)
}
func (h *Handler) RemoveUserContentBlacklist(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req contentBlacklistMutationRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "移除黑名单参数不正确")
return
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "admin_remove_content_blacklist"
}
removed, err := h.service.RemoveUserContentBlacklist(
c.Request.Context(),
userID,
int64(middleware.CurrentUserID(c)),
reason,
middleware.CurrentRequestID(c),
)
if err != nil {
writeMutationError(c, err, "移除黑名单屏蔽用户失败")
return
}
writeAppUserAuditLog(c, h.audit, "remove-user-content-blacklist", "user_content_blacklist", userID, "success", reason)
response.OK(c, gin.H{"removed": removed})
}
func (h *Handler) ListUserLoginLogs(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {

View File

@ -15,6 +15,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/exports/app-users", middleware.RequirePermission("app-user:export"), h.CreateExport)
protected.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), h.ListBannedUsers)
protected.GET("/app/users/login-logs", middleware.RequirePermission("app-user:view"), h.ListLoginLogs)
protected.GET("/app/users/content-blacklist", middleware.RequirePermission("app-user:view"), h.ListUserContentBlacklist)
protected.GET("/app/users/content-blacklist/candidate", middleware.RequirePermission("app-user:view"), h.ResolveUserContentBlacklistCandidate)
protected.POST("/app/users/content-blacklist", middleware.RequirePermission("app-user:status"), h.AddUserContentBlacklist)
protected.DELETE("/app/users/content-blacklist/:id", middleware.RequirePermission("app-user:status"), h.RemoveUserContentBlacklist)
protected.GET("/app/users/:id/login-logs", middleware.RequirePermission("app-user:view"), h.ListUserLoginLogs)
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)
protected.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), h.UpdateUser)

View File

@ -31,6 +31,8 @@ var (
type Service struct {
userClient userclient.Client
userAdminClient userAdminProjectionClient
// contentBlacklistClient 只开放 user-service 的内容屏蔽治理契约admin 不直连用户库维护第二份状态。
contentBlacklistClient userContentBlacklistAdminClient
// tokenIssuerClient 只在 gRPC client 具备后台重签 token 能力时非 nil。
tokenIssuerClient accessTokenIssuerClient
activityClient activityclient.Client
@ -55,6 +57,14 @@ type userAdminProjectionClient interface {
AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error)
}
// userContentBlacklistAdminClient 与通用 Client 分离,避免既有模块和测试替身被迫获得后台治理写能力。
type userContentBlacklistAdminClient interface {
ResolveAdminUserIdentifier(context.Context, userclient.ResolveAdminUserIdentifierRequest) (*userclient.UserIdentity, error)
ListUserContentBlacklist(context.Context, *userv1.ListUserContentBlacklistRequest) (*userv1.ListUserContentBlacklistResponse, error)
AdminAddUserContentBlacklist(context.Context, *userv1.AdminAddUserContentBlacklistRequest) (*userv1.AdminAddUserContentBlacklistResponse, error)
AdminRemoveUserContentBlacklist(context.Context, *userv1.AdminRemoveUserContentBlacklistRequest) (*userv1.AdminRemoveUserContentBlacklistResponse, error)
}
// levelAdminProjectionClient 只允许后台读 overlay 和一次原子调整,游戏等级没有写入口。
type levelAdminProjectionClient interface {
BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error)
@ -223,6 +233,9 @@ func NewService(userClient userclient.Client, activityClient activityclient.Clie
if adminClient, ok := any(userClient).(userAdminProjectionClient); ok {
service.userAdminClient = adminClient
}
if blacklistClient, ok := any(userClient).(userContentBlacklistAdminClient); ok {
service.contentBlacklistClient = blacklistClient
}
if tokenClient, ok := any(userClient).(accessTokenIssuerClient); ok {
service.tokenIssuerClient = tokenClient
}

View File

@ -1,28 +1,32 @@
package hostagencypolicy
type policyRequest struct {
Name string `json:"name"`
PolicyType string `json:"policy_type"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids"`
CycleKey string `json:"cycle_key"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
CoinsPerUSD int64 `json:"coins_per_usd"`
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
Description string `json:"description"`
Levels []levelRequest `json:"levels"`
Name string `json:"name"`
PolicyType string `json:"policy_type"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids"`
CycleKey string `json:"cycle_key"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
CoinsPerUSD int64 `json:"coins_per_usd"`
// MinimumWithdrawUSDMinor 只兼容旧后台;新后台必须分别提交两个渠道金额。
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
CoinSellerMinimumWithdrawUSDMinor int64 `json:"coin_seller_minimum_withdraw_usd_minor"`
PlatformMinimumWithdrawUSDMinor int64 `json:"platform_minimum_withdraw_usd_minor"`
DiamondExchangeEnabled *bool `json:"diamond_exchange_enabled"`
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
Description string `json:"description"`
Levels []levelRequest `json:"levels"`
}
type levelRequest struct {

View File

@ -7,42 +7,45 @@ import (
)
type policyDTO struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
PolicyType string `json:"policy_type"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids"`
CycleKey string `json:"cycle_key"`
PolicyVersion uint64 `json:"policy_version"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
CoinsPerUSD int64 `json:"coins_per_usd"`
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
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"`
CopiedFromPolicyID uint `json:"copied_from_policy_id"`
CopiedFromCycleKey string `json:"copied_from_cycle_key"`
Inherited bool `json:"inherited"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Levels []levelDTO `json:"levels"`
ID uint `json:"id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
PolicyType string `json:"policy_type"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids"`
CycleKey string `json:"cycle_key"`
PolicyVersion uint64 `json:"policy_version"`
Status string `json:"status"`
SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"`
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
CoinsPerUSD int64 `json:"coins_per_usd"`
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
CoinSellerMinimumWithdrawUSDMinor int64 `json:"coin_seller_minimum_withdraw_usd_minor"`
PlatformMinimumWithdrawUSDMinor int64 `json:"platform_minimum_withdraw_usd_minor"`
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
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"`
CopiedFromPolicyID uint `json:"copied_from_policy_id"`
CopiedFromCycleKey string `json:"copied_from_cycle_key"`
Inherited bool `json:"inherited"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Levels []levelDTO `json:"levels"`
}
type levelDTO struct {
@ -61,48 +64,53 @@ type levelDTO struct {
}
func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
coinSellerMinimum, platformMinimum := resolvedPolicyWithdrawalMinimums(item)
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,
PolicyType: normalizePolicyType(item.PolicyType),
RegionID: item.RegionID,
RegionIDs: responseRegionIDs(item),
CycleKey: item.CycleKey,
PolicyVersion: item.PolicyVersion,
Status: item.Status,
SettlementMode: item.SettlementMode,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
PointDiamondsPerUSD: item.PointDiamondsPerUSD,
CoinsPerUSD: item.CoinsPerUSD,
MinimumWithdrawUSDMinor: item.MinimumWithdrawUSDMinor,
WithdrawFeeBPS: item.WithdrawFeeBPS,
AgencyPointShareBPS: item.AgencyPointShareBPS,
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount,
PlatformWithdrawalAllowedDays: parseWithdrawalDays(item.PlatformWithdrawalAllowedDays),
Description: item.Description,
CreatedByAdminID: item.CreatedByAdminID,
UpdatedByAdminID: item.UpdatedByAdminID,
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
PublishError: item.PublishError,
PublishedAtMS: item.PublishedAtMS,
PublishedByAdminID: item.PublishedByAdminID,
CopiedFromPolicyID: item.CopiedFromPolicyID,
CopiedFromCycleKey: item.CopiedFromCycleKey,
Inherited: item.CopiedFromPolicyID > 0 && item.CopiedFromCycleKey != "",
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
Levels: levels,
ID: item.ID,
AppCode: item.AppCode,
Name: item.Name,
PolicyType: normalizePolicyType(item.PolicyType),
RegionID: item.RegionID,
RegionIDs: responseRegionIDs(item),
CycleKey: item.CycleKey,
PolicyVersion: item.PolicyVersion,
Status: item.Status,
SettlementMode: item.SettlementMode,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
PointDiamondsPerUSD: item.PointDiamondsPerUSD,
CoinsPerUSD: item.CoinsPerUSD,
// 旧字段继续返回平台提现门槛,旧客户端不会因拆分字段而改变原展示和提交口径。
MinimumWithdrawUSDMinor: platformMinimum,
CoinSellerMinimumWithdrawUSDMinor: coinSellerMinimum,
PlatformMinimumWithdrawUSDMinor: platformMinimum,
DiamondExchangeEnabled: item.DiamondExchangeEnabled,
WithdrawFeeBPS: item.WithdrawFeeBPS,
AgencyPointShareBPS: item.AgencyPointShareBPS,
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount,
PlatformWithdrawalAllowedDays: parseWithdrawalDays(item.PlatformWithdrawalAllowedDays),
Description: item.Description,
CreatedByAdminID: item.CreatedByAdminID,
UpdatedByAdminID: item.UpdatedByAdminID,
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
PublishError: item.PublishError,
PublishedAtMS: item.PublishedAtMS,
PublishedByAdminID: item.PublishedByAdminID,
CopiedFromPolicyID: item.CopiedFromPolicyID,
CopiedFromCycleKey: item.CopiedFromCycleKey,
Inherited: item.CopiedFromPolicyID > 0 && item.CopiedFromCycleKey != "",
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
Levels: levels,
}
}

View File

@ -211,6 +211,10 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
// Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。
req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType)
}
if req.DiamondExchangeEnabled == nil {
// PUT 仍兼容尚未提交新开关的旧后台:更新已关闭的政策时保留原值,不能因字段缺失自动重新开启。
req.DiamondExchangeEnabled = boolPointer(item.DiamondExchangeEnabled)
}
// 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
if err != nil {
@ -235,6 +239,9 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
item.PointDiamondsPerUSD = updated.PointDiamondsPerUSD
item.CoinsPerUSD = updated.CoinsPerUSD
item.MinimumWithdrawUSDMinor = updated.MinimumWithdrawUSDMinor
item.CoinSellerMinimumWithdrawUSDMinor = updated.CoinSellerMinimumWithdrawUSDMinor
item.PlatformMinimumWithdrawUSDMinor = updated.PlatformMinimumWithdrawUSDMinor
item.DiamondExchangeEnabled = updated.DiamondExchangeEnabled
item.WithdrawFeeBPS = updated.WithdrawFeeBPS
item.AgencyPointShareBPS = updated.AgencyPointShareBPS
item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
@ -443,26 +450,29 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
}
type runtimeHostSalaryPolicySnapshot struct {
CycleKey string
PolicyVersion uint64
Name string
PolicyType string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
PointDiamondsPerUSD int64
CoinsPerUSD int64
MinimumWithdrawUSDMinor int64
WithdrawFeeBPS int32
AgencyPointShareBPS int32
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
PlatformWithdrawalAllowedDays string
CycleKey string
PolicyVersion uint64
Name string
PolicyType string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
PointDiamondsPerUSD int64
CoinsPerUSD int64
MinimumWithdrawUSDMinor int64
CoinSellerMinimumWithdrawUSDMinor int64
PlatformMinimumWithdrawUSDMinor int64
DiamondExchangeEnabled bool
WithdrawFeeBPS int32
AgencyPointShareBPS int32
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
PlatformWithdrawalAllowedDays string
}
type runtimeHostSalaryLevelSnapshot struct {
@ -476,21 +486,25 @@ type runtimeHostSalaryLevelSnapshot struct {
}
func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error {
coinSellerMinimum, platformMinimum := resolvedPolicyWithdrawalMinimums(item)
// 先以完整快照尝试原子占位;复合主键包含 policy_version已存在时只做自赋值绝不改写业务字段。
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policies (
app_code, policy_id, cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
gift_coin_to_diamond_ratio, point_diamonds_per_usd, coins_per_usd, minimum_withdraw_usd_minor, withdraw_fee_bps, agency_point_share_bps,
gift_coin_to_diamond_ratio, point_diamonds_per_usd, coins_per_usd, minimum_withdraw_usd_minor,
coin_seller_minimum_withdraw_usd_minor, platform_minimum_withdraw_usd_minor, diamond_exchange_enabled,
withdraw_fee_bps, agency_point_share_bps,
residual_diamond_to_usd_rate,
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id)`,
item.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, normalizePolicyType(item.PolicyType), item.RegionID, item.Status,
item.SettlementMode, firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
item.GiftCoinToDiamondRatio, item.PointDiamondsPerUSD, item.CoinsPerUSD, item.MinimumWithdrawUSDMinor, item.WithdrawFeeBPS, item.AgencyPointShareBPS,
item.GiftCoinToDiamondRatio, item.PointDiamondsPerUSD, item.CoinsPerUSD, platformMinimum,
coinSellerMinimum, platformMinimum, item.DiamondExchangeEnabled, item.WithdrawFeeBPS, item.AgencyPointShareBPS,
item.ResidualDiamondToUSDRate,
firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
@ -504,7 +518,8 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
err := tx.QueryRowContext(ctx, `
SELECT cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
CAST(gift_coin_to_diamond_ratio AS CHAR), point_diamonds_per_usd, coins_per_usd,
minimum_withdraw_usd_minor, withdraw_fee_bps, agency_point_share_bps, CAST(residual_diamond_to_usd_rate AS CHAR),
minimum_withdraw_usd_minor, coin_seller_minimum_withdraw_usd_minor, platform_minimum_withdraw_usd_minor,
diamond_exchange_enabled, withdraw_fee_bps, agency_point_share_bps, CAST(residual_diamond_to_usd_rate AS CHAR),
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
FROM host_agency_salary_policies
@ -513,7 +528,8 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status,
&existing.SettlementMode, &existing.SettlementTriggerMode,
&existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD,
&existing.MinimumWithdrawUSDMinor, &existing.WithdrawFeeBPS, &existing.AgencyPointShareBPS, &existing.ResidualDiamondToUSDRate,
&existing.MinimumWithdrawUSDMinor, &existing.CoinSellerMinimumWithdrawUSDMinor, &existing.PlatformMinimumWithdrawUSDMinor,
&existing.DiamondExchangeEnabled, &existing.WithdrawFeeBPS, &existing.AgencyPointShareBPS, &existing.ResidualDiamondToUSDRate,
&existing.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount,
&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
&existing.PlatformWithdrawalAllowedDays,
@ -562,13 +578,23 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
}
func runtimeHostSalaryPolicyMatches(existing runtimeHostSalaryPolicySnapshot, item model.HostAgencySalaryPolicy, policyVersion uint64) bool {
itemCoinSellerMinimum, itemPlatformMinimum := resolvedPolicyWithdrawalMinimums(item)
existingCoinSellerMinimum := existing.CoinSellerMinimumWithdrawUSDMinor
if existingCoinSellerMinimum <= 0 {
existingCoinSellerMinimum = existing.MinimumWithdrawUSDMinor
}
existingPlatformMinimum := existing.PlatformMinimumWithdrawUSDMinor
if existingPlatformMinimum <= 0 {
existingPlatformMinimum = existing.MinimumWithdrawUSDMinor
}
return existing.CycleKey == item.CycleKey && existing.PolicyVersion == policyVersion && existing.Name == item.Name &&
existing.PolicyType == normalizePolicyType(item.PolicyType) &&
existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
existing.PointDiamondsPerUSD == item.PointDiamondsPerUSD && existing.CoinsPerUSD == item.CoinsPerUSD &&
existing.MinimumWithdrawUSDMinor == item.MinimumWithdrawUSDMinor && existing.WithdrawFeeBPS == item.WithdrawFeeBPS &&
existingPlatformMinimum == itemPlatformMinimum && existingCoinSellerMinimum == itemCoinSellerMinimum &&
existing.DiamondExchangeEnabled == item.DiamondExchangeEnabled && existing.WithdrawFeeBPS == item.WithdrawFeeBPS &&
existing.AgencyPointShareBPS == item.AgencyPointShareBPS &&
trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
@ -755,7 +781,20 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
}
pointDiamondsPerUSD := req.PointDiamondsPerUSD
coinsPerUSD := req.CoinsPerUSD
minimumWithdrawUSDMinor := req.MinimumWithdrawUSDMinor
// 旧请求只有一个 minimum 字段时同时用于两个渠道;新请求分别配置,旧别名固定投影平台金额。
coinSellerMinimumWithdrawUSDMinor := req.CoinSellerMinimumWithdrawUSDMinor
if coinSellerMinimumWithdrawUSDMinor == 0 {
coinSellerMinimumWithdrawUSDMinor = req.MinimumWithdrawUSDMinor
}
platformMinimumWithdrawUSDMinor := req.PlatformMinimumWithdrawUSDMinor
if platformMinimumWithdrawUSDMinor == 0 {
platformMinimumWithdrawUSDMinor = req.MinimumWithdrawUSDMinor
}
minimumWithdrawUSDMinor := platformMinimumWithdrawUSDMinor
diamondExchangeEnabled := true
if req.DiamondExchangeEnabled != nil {
diamondExchangeEnabled = *req.DiamondExchangeEnabled
}
withdrawFeeBPS := req.WithdrawFeeBPS
agencyPointShareBPS := req.AgencyPointShareBPS
if policyType == policyTypeSalaryDiamond {
@ -763,15 +802,18 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
pointDiamondsPerUSD = 0
coinsPerUSD = 0
minimumWithdrawUSDMinor = 0
coinSellerMinimumWithdrawUSDMinor = 0
platformMinimumWithdrawUSDMinor = 0
diamondExchangeEnabled = false
withdrawFeeBPS = 0
agencyPointShareBPS = 0
}
if pointDiamondsPerUSD < 0 || coinsPerUSD < 0 || minimumWithdrawUSDMinor < 0 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 || agencyPointShareBPS < 0 || agencyPointShareBPS > 10_000 {
if pointDiamondsPerUSD < 0 || coinsPerUSD < 0 || coinSellerMinimumWithdrawUSDMinor < 0 || platformMinimumWithdrawUSDMinor < 0 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 || agencyPointShareBPS < 0 || agencyPointShareBPS > 10_000 {
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分兑换或提现配置不正确")
}
if policyType == policyTypePointDiamond && (pointDiamondsPerUSD <= 0 || coinsPerUSD <= 0 || minimumWithdrawUSDMinor <= 0) {
if policyType == policyTypePointDiamond && (pointDiamondsPerUSD <= 0 || coinsPerUSD <= 0 || coinSellerMinimumWithdrawUSDMinor <= 0 || platformMinimumWithdrawUSDMinor <= 0) {
// POINT_DIAMOND 的金额入口全部读取这份月度不可变快照;缺少任一比例时必须拒绝发布前保存,不能回退代码常量。
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策必须配置每美元钻石、每美元金币和最低提现金额")
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策必须配置每美元钻石、每美元金币、币商最低提现金额平台最低提现金额")
}
if policyType == policyTypePointDiamond && pointDiamondsPerUSD%100 != 0 {
// HTTP 只接受美元分整数;比例必须能精确映射每一美分,禁止依赖向下取整吞掉用户积分。
@ -821,31 +863,52 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
Name: name,
PolicyType: policyType,
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
RegionID: regionIDs[0],
RegionIDs: regionIDs,
CycleKey: cycleKey,
Status: status,
SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode,
GiftCoinToDiamondRatio: giftRatio,
PointDiamondsPerUSD: pointDiamondsPerUSD,
CoinsPerUSD: coinsPerUSD,
MinimumWithdrawUSDMinor: minimumWithdrawUSDMinor,
WithdrawFeeBPS: withdrawFeeBPS,
AgencyPointShareBPS: agencyPointShareBPS,
ResidualDiamondToUSDRate: residualRate,
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
PlatformWithdrawalLimitCount: platformLimitCount,
PlatformWithdrawalAllowedDays: platformAllowedDays,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
Levels: levels,
RegionID: regionIDs[0],
RegionIDs: regionIDs,
CycleKey: cycleKey,
Status: status,
SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode,
GiftCoinToDiamondRatio: giftRatio,
PointDiamondsPerUSD: pointDiamondsPerUSD,
CoinsPerUSD: coinsPerUSD,
MinimumWithdrawUSDMinor: minimumWithdrawUSDMinor,
CoinSellerMinimumWithdrawUSDMinor: coinSellerMinimumWithdrawUSDMinor,
PlatformMinimumWithdrawUSDMinor: platformMinimumWithdrawUSDMinor,
DiamondExchangeEnabled: diamondExchangeEnabled,
WithdrawFeeBPS: withdrawFeeBPS,
AgencyPointShareBPS: agencyPointShareBPS,
ResidualDiamondToUSDRate: residualRate,
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
PlatformWithdrawalLimitCount: platformLimitCount,
PlatformWithdrawalAllowedDays: platformAllowedDays,
Description: description,
CreatedByAdminID: actorID,
UpdatedByAdminID: actorID,
Levels: levels,
}, nil
}
func boolPointer(value bool) *bool {
return &value
}
// resolvedPolicyWithdrawalMinimums 兼容迁移前快照:渠道字段为 0 时回退原单一门槛。
// POINT_DIAMOND 新保存会强制两个渠道都大于 0因此 0 不会与合法的新配置产生歧义。
func resolvedPolicyWithdrawalMinimums(item model.HostAgencySalaryPolicy) (int64, int64) {
coinSellerMinimum := item.CoinSellerMinimumWithdrawUSDMinor
if coinSellerMinimum <= 0 {
coinSellerMinimum = item.MinimumWithdrawUSDMinor
}
platformMinimum := item.PlatformMinimumWithdrawUSDMinor
if platformMinimum <= 0 {
platformMinimum = item.MinimumWithdrawUSDMinor
}
return coinSellerMinimum, platformMinimum
}
func normalizePolicyType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {

View File

@ -245,9 +245,11 @@ func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t
mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81), uint64(81)).WillReturnRows(sqlmock.NewRows([]string{
"cycle_key", "policy_version", "name", "policy_type", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
"gift_ratio", "points_per_usd", "coins_per_usd", "minimum_usd_minor", "fee_bps", "agency_bps", "residual_rate",
"gift_ratio", "points_per_usd", "coins_per_usd", "minimum_usd_minor",
"coin_seller_minimum_usd_minor", "platform_minimum_usd_minor", "diamond_exchange_enabled",
"fee_bps", "agency_bps", "residual_rate",
"coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days",
}).AddRow("2026-08", 81, "August policy", "SALARY_DIAMOND", 25, "active", "daily", "automatic", "1.000000", 0, 0, 0, 0, 0, "0.000000000000", "week", 2, "day", 1, "15,30"))
}).AddRow("2026-08", 81, "August policy", "SALARY_DIAMOND", 25, "active", "daily", "automatic", "1.000000", 0, 0, 0, 0, 0, false, 0, 0, "0.000000000000", "week", 2, "day", 1, "15,30"))
mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81), uint64(81)).WillReturnRows(sqlmock.NewRows([]string{
"level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order",
}).AddRow(1, 100, 150, 20, 50, "active", 1))

View File

@ -345,11 +345,12 @@ func (s *Store) seedMenus() error {
{ParentID: &systemID, Title: "团队配置", Code: "system-teams", Path: "/system/teams", Icon: "team", PermissionCode: "team:view", Sort: 36, Visible: true},
{ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true},
{ParentID: &appUsersID, Title: "封禁列表", Code: "app-user-bans", Path: "/app/users/bans", Icon: "block", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 62, Visible: true},
{ParentID: &appUsersID, Title: "等级配置", Code: "app-user-level-config", Path: "/app/users/level-config", Icon: "military_tech", PermissionCode: "level-config:view", Sort: 63, Visible: true},
{ParentID: &appUsersID, Title: "靓号管理", Code: "app-user-pretty-ids", Path: "/app/users/pretty-ids", Icon: "tag", PermissionCode: "pretty-id:view", Sort: 64, Visible: true},
{ParentID: &appUsersID, Title: "风控管理", Code: "app-user-risk-config", Path: "/app/users/risk-config", Icon: "shield", PermissionCode: "risk-config:view", Sort: 65, Visible: true},
{ParentID: &appUsersID, Title: "地区屏蔽", Code: "app-user-region-blocks", Path: "/app/users/region-blocks", Icon: "public", PermissionCode: "region-block:view", Sort: 66, Visible: true},
{ParentID: &appUsersID, Title: "黑名单屏蔽用户", Code: "app-user-content-blacklist", Path: "/app/users/content-blacklist", Icon: "visibility_off", PermissionCode: "app-user:view", Sort: 62, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 63, Visible: true},
{ParentID: &appUsersID, Title: "等级配置", Code: "app-user-level-config", Path: "/app/users/level-config", Icon: "military_tech", PermissionCode: "level-config:view", Sort: 64, Visible: true},
{ParentID: &appUsersID, Title: "靓号管理", Code: "app-user-pretty-ids", Path: "/app/users/pretty-ids", Icon: "tag", PermissionCode: "pretty-id:view", Sort: 65, Visible: true},
{ParentID: &appUsersID, Title: "风控管理", Code: "app-user-risk-config", Path: "/app/users/risk-config", Icon: "shield", PermissionCode: "risk-config:view", Sort: 66, Visible: true},
{ParentID: &appUsersID, Title: "地区屏蔽", Code: "app-user-region-blocks", Path: "/app/users/region-blocks", Icon: "public", PermissionCode: "region-block:view", Sort: 67, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间置顶", Code: "room-pins", Path: "/rooms/pins", Icon: "push_pin", PermissionCode: "room-pin:view", Sort: 66, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},

View File

@ -0,0 +1,46 @@
-- 内容黑名单复用 app-user:view/status 权限,仅新增用户管理子菜单。
-- admin_menus.code 有唯一索引,以下调整都是点查/点更;不扫描或锁定 App 用户业务表。
SET @now_ms := UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000;
UPDATE admin_menus
SET sort = CASE code
WHEN 'app-user-login-logs' THEN 63
WHEN 'app-user-level-config' THEN 64
WHEN 'app-user-pretty-ids' THEN 65
WHEN 'app-user-risk-config' THEN 66
WHEN 'app-user-region-blocks' THEN 67
ELSE sort
END,
updated_at_ms = @now_ms
WHERE code IN (
'app-user-login-logs',
'app-user-level-config',
'app-user-pretty-ids',
'app-user-risk-config',
'app-user-region-blocks'
);
INSERT INTO admin_menus (
parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms
)
SELECT parent.id,
'黑名单屏蔽用户',
'app-user-content-blacklist',
'/app/users/content-blacklist',
'visibility_off',
'app-user:view',
62,
TRUE,
@now_ms,
@now_ms
FROM admin_menus parent
WHERE parent.code = 'app-users'
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 = VALUES(updated_at_ms);

View File

@ -0,0 +1,15 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Host 政策是小型配置表;迁移仅追加定长列并使用在线 DDL不扫描钱包、提现或用户流水。
-- 新渠道字段以 0 表示尚未拆分,运行时回退旧 minimum_withdraw_usd_minor因此无需全表 UPDATE 回填。
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'coin_seller_minimum_withdraw_usd_minor') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN coin_seller_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 用户找币商最低提现美元美分0 回退旧字段'' AFTER minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', '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 = 'platform_minimum_withdraw_usd_minor') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN platform_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 用户找平台最低提现美元美分0 回退旧字段'' AFTER coin_seller_minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', '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 = 'diamond_exchange_enabled') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN diamond_exchange_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开放 POINT_DIAMOND 兑换金币'' AFTER platform_minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -179,6 +179,11 @@ func (h *Handler) applyAuthenticatedBannerAudience(writer http.ResponseWriter, r
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return false
}
if user.GetContentBlacklisted() {
// Banner 仍保留公开匿名访问兼容一旦请求携带有效登录态user-service 的黑名单事实优先并直接返回空列表。
httpkit.WriteOK(writer, request, map[string]any{"items": []appconfig.Banner{}, "total": 0})
return false
}
// 已登录用户的区域由后端用户资料覆盖;客户端保留 region_id/country 只做老版本参数兼容,不参与裁决。
query.RegionID = user.GetRegionId()

View File

@ -44,12 +44,22 @@ func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteRPCError(writer, request, err)
return
}
user := userResp.GetUser()
if user == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if user.GetContentBlacklisted() {
// 内容黑名单只隐藏游戏目录,不调用 game-service也不改变账号、房间或钱包状态。
httpkit.WriteOK(writer, request, gameListData{Games: []gameItemData{}})
return
}
resp, err := h.gameClient.ListGames(request.Context(), &gamev1.ListGamesRequest{
Meta: gameMeta(request),
UserId: userID,
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
RegionId: userResp.GetUser().GetRegionId(),
RegionId: user.GetRegionId(),
Language: requestLanguage(request),
ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
})
@ -75,6 +85,16 @@ func (h *Handler) listRecentGames(writer http.ResponseWriter, request *http.Requ
httpkit.WriteRPCError(writer, request, err)
return
}
user := userResp.GetUser()
if user == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if user.GetContentBlacklisted() {
// 最近常玩与完整目录使用同一 owner 判定,避免从第二个 tab 绕过内容屏蔽。
httpkit.WriteOK(writer, request, gameListData{Games: []gameItemData{}})
return
}
// 最近常玩仍传 scene、room、region、language 和 platform让 game-service 在真实启动记录之外,
// 继续按当前房间场景和用户区域过滤,避免 App 展示用户已不能进入的游戏。
resp, err := h.gameClient.ListRecentGames(request.Context(), &gamev1.ListRecentGamesRequest{
@ -82,7 +102,7 @@ func (h *Handler) listRecentGames(writer http.ResponseWriter, request *http.Requ
UserId: userID,
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
RegionId: userResp.GetUser().GetRegionId(),
RegionId: user.GetRegionId(),
Language: requestLanguage(request),
ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
PageSize: pageSize,

View File

@ -15,6 +15,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -150,7 +151,9 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R
})
bagCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "bag", func() (*walletv1.ListUserResourcesResponse, bool, error) {
resp, err := h.roomGiftBagResources(request, viewerUserID)
return resp, false, err
// 只有 Wallet 明确标记的资源读瞬时冲突可以降级为空背包;鉴权、参数、
// 网络和未知内部错误仍让面板失败,避免把真实依赖故障静默伪装成“用户没有背包礼物”。
return resp, xerr.IsCode(err, xerr.WalletResourceReadTransient), err
})
comboConfigCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "combo_config", func() (appconfig.GiftComboConfig, bool, error) {
config, err := h.roomGiftComboConfig(totalCtx, app, viewerUserID)
@ -192,10 +195,15 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
return
}
bagResources, _, err := waitRoomGiftPanelStage(totalCtx, bagCh)
bagResources, bagDegraded, err := waitRoomGiftPanelStage(totalCtx, bagCh)
if err != nil {
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
return
if !bagDegraded || totalCtx.Err() != nil {
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
return
}
// 背包只影响用户持有礼物的附加列表,目录、余额和送礼主链路仍可安全展示;
// 当前请求返回空背包,下一次打开会重新读取,不缓存这次瞬时降级。
bagResources = &walletv1.ListUserResourcesResponse{}
}
recipientProfiles, _, err := waitRoomGiftPanelStage(totalCtx, profileCh)
if err != nil {

View File

@ -90,14 +90,17 @@ type pointDiamondBalanceData struct {
}
type pointDiamondPolicyData struct {
PointDiamondsPerUSD string `json:"point_diamonds_per_usd"`
CoinsPerUSD string `json:"coins_per_usd"`
AgencyPointShareBPS string `json:"agency_point_share_bps"`
MinimumWithdrawUSDMinor string `json:"minimum_withdraw_usd_minor"`
WithdrawFeeBPS string `json:"withdraw_fee_bps"`
PolicyID string `json:"policy_id"`
PolicyVersion string `json:"policy_version"`
PolicyInstanceCode string `json:"policy_instance_code"`
PointDiamondsPerUSD string `json:"point_diamonds_per_usd"`
CoinsPerUSD string `json:"coins_per_usd"`
AgencyPointShareBPS string `json:"agency_point_share_bps"`
MinimumWithdrawUSDMinor string `json:"minimum_withdraw_usd_minor"`
CoinSellerMinimumWithdrawUSDMinor string `json:"coin_seller_minimum_withdraw_usd_minor"`
PlatformMinimumWithdrawUSDMinor string `json:"platform_minimum_withdraw_usd_minor"`
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
WithdrawFeeBPS string `json:"withdraw_fee_bps"`
PolicyID string `json:"policy_id"`
PolicyVersion string `json:"policy_version"`
PolicyInstanceCode string `json:"policy_instance_code"`
}
type pointDiamondActionsData struct {
@ -170,14 +173,17 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ
}
}
availabilityEvaluated := config.GetAvailabilityEvaluated()
diamondExchangeEnabled := pointDiamondExchangeEnabled(config)
actions := pointDiamondActionsData{
ExchangeToCoins: enabled,
ExchangeToCoins: enabled && diamondExchangeEnabled,
Withdraw: enabled && availabilityEvaluated && config.GetPlatformAvailability().GetAllowed(),
TransferToCoinSeller: enabled && availabilityEvaluated && config.GetCoinSellerAvailability().GetAllowed() && len(sellers) > 0,
}
channels := []string{}
if enabled {
channels = append(channels, "COIN")
if actions.ExchangeToCoins {
channels = append(channels, "COIN")
}
if actions.Withdraw {
channels = append(channels, "PLATFORM_USDT")
}
@ -197,9 +203,13 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ
},
"available_points": balance.AvailablePoints, "available_amount": balance.AvailableAmount,
"point_diamonds_per_usd": policy.PointDiamondsPerUSD, "coins_per_usd": policy.CoinsPerUSD,
"agency_point_share_bps": policy.AgencyPointShareBPS,
"minimum_withdraw_usd_minor": policy.MinimumWithdrawUSDMinor, "withdraw_fee_bps": policy.WithdrawFeeBPS,
"withdraw_address": salaryWithdrawAddressFromProto(profile),
"agency_point_share_bps": policy.AgencyPointShareBPS,
"minimum_withdraw_usd_minor": policy.MinimumWithdrawUSDMinor,
"coin_seller_minimum_withdraw_usd_minor": policy.CoinSellerMinimumWithdrawUSDMinor,
"platform_minimum_withdraw_usd_minor": policy.PlatformMinimumWithdrawUSDMinor,
"diamond_exchange_enabled": policy.DiamondExchangeEnabled,
"withdraw_fee_bps": policy.WithdrawFeeBPS,
"withdraw_address": salaryWithdrawAddressFromProto(profile),
})
}
@ -503,10 +513,25 @@ func pointDiamondPolicyFromProto(config *walletv1.GetPointWithdrawalConfigRespon
}
return pointDiamondPolicyData{
PointDiamondsPerUSD: strconv.FormatInt(config.GetPointsPerUsd(), 10), CoinsPerUSD: strconv.FormatInt(config.GetCoinsPerUsd(), 10),
AgencyPointShareBPS: strconv.FormatInt(int64(config.GetAgencyPointShareBps()), 10),
MinimumWithdrawUSDMinor: strconv.FormatInt(config.GetMinimumWithdrawUsdMinor(), 10),
WithdrawFeeBPS: strconv.FormatInt(int64(config.GetFeeBps()), 10),
PolicyID: strconv.FormatUint(config.GetPolicyId(), 10), PolicyVersion: strconv.FormatUint(config.GetPolicyVersion(), 10),
AgencyPointShareBPS: strconv.FormatInt(int64(config.GetAgencyPointShareBps()), 10),
MinimumWithdrawUSDMinor: strconv.FormatInt(config.GetMinimumWithdrawUsdMinor(), 10),
CoinSellerMinimumWithdrawUSDMinor: strconv.FormatInt(config.GetCoinSellerMinimumWithdrawUsdMinor(), 10),
PlatformMinimumWithdrawUSDMinor: strconv.FormatInt(config.GetPlatformMinimumWithdrawUsdMinor(), 10),
DiamondExchangeEnabled: pointDiamondExchangeEnabled(config),
WithdrawFeeBPS: strconv.FormatInt(int64(config.GetFeeBps()), 10),
PolicyID: strconv.FormatUint(config.GetPolicyId(), 10), PolicyVersion: strconv.FormatUint(config.GetPolicyVersion(), 10),
PolicyInstanceCode: config.GetPolicyInstanceCode(),
}
}
func pointDiamondExchangeEnabled(config *walletv1.GetPointWithdrawalConfigResponse) bool {
if config == nil {
return false
}
// optional 字段缺失只会来自旧 wallet-service兼容窗口保持原有开启行为。
// 新服务无论 true/false 都会显式设置指针,因此运营关闭不会被该回退覆盖。
if config.DiamondExchangeEnabled == nil {
return true
}
return config.GetDiamondExchangeEnabled()
}

View File

@ -315,6 +315,18 @@ CREATE TABLE IF NOT EXISTS admin_user_bans (
KEY idx_admin_user_bans_expire (app_code, status, expires_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员用户封禁事实表';
CREATE TABLE IF NOT EXISTS user_content_blacklist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '被屏蔽用户 ID',
status VARCHAR(16) NOT NULL COMMENT 'active/removed',
operator_admin_id BIGINT NOT NULL COMMENT '最近操作后台管理员 ID',
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近操作原因',
created_at_ms BIGINT NOT NULL COMMENT '本次进入黑名单时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '最近操作时间UTC epoch ms',
PRIMARY KEY (app_code, user_id),
KEY idx_user_content_blacklist_status_time (app_code, status, created_at_ms, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户运营内容屏蔽当前态';
-- 空 ban_id 解封会按用户选择最新 active 管理员事实;独立命令表把一次选择固定到 resolved_ban_id
-- 避免网络重试继续释放下一条重叠封禁。
CREATE TABLE IF NOT EXISTS admin_user_ban_release_commands (

View File

@ -0,0 +1,14 @@
-- 运营内容黑名单是独立小表CREATE TABLE 不扫描或重写 users发布时只获取数据字典锁。
-- 运行时单用户判断命中 PRIMARY KEY (app_code,user_id),后台列表命中 status/time 覆盖索引,
-- 不允许把列表裁决退化为 users 全表扫描。
CREATE TABLE IF NOT EXISTS user_content_blacklist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '被屏蔽用户 ID',
status VARCHAR(16) NOT NULL COMMENT 'active/removed',
operator_admin_id BIGINT NOT NULL COMMENT '最近操作后台管理员 ID',
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近操作原因',
created_at_ms BIGINT NOT NULL COMMENT '本次进入黑名单时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '最近操作时间UTC epoch ms',
PRIMARY KEY (app_code, user_id),
KEY idx_user_content_blacklist_status_time (app_code, status, created_at_ms, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户运营内容屏蔽当前态';

View File

@ -289,12 +289,31 @@ type User struct {
OnboardingStatus OnboardingStatus
// Status 控制登录和业务可用性。
Status Status
// ContentBlacklisted 只屏蔽游戏列表和 Banner 等运营内容;账号仍可登录,房间和账务状态不受影响。
ContentBlacklisted bool
// CreatedAtMs 是用户创建时间。
CreatedAtMs int64
// UpdatedAtMs 是用户主记录更新时间。
UpdatedAtMs int64
}
// ContentBlacklistEntry 是 user-service 持有的运营内容屏蔽当前态。
// 用户资料在列表读取时从 users 当前行投影,避免昵称、头像和展示号被固化成过期快照。
type ContentBlacklistEntry struct {
UserID int64
CurrentDisplayUserID string
DefaultDisplayUserID string
PrettyID string
PrettyDisplayUserID string
Username string
Avatar string
UserStatus Status
OperatorAdminID int64
Reason string
CreatedAtMs int64
UpdatedAtMs int64
}
// RoomBasicUser 是房间首屏热路径使用的最小用户展示资料,不能混入国家、靓号详情或装扮字段。
type RoomBasicUser struct {
// UserID 是系统内部不可变用户 ID调用方用它和麦位/房主/当前用户状态关联。

View File

@ -0,0 +1,83 @@
package user
import (
"context"
"strings"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
defaultContentBlacklistPageSize int32 = 50
maxContentBlacklistPageSize int32 = 200
maxContentBlacklistReasonRunes = 255
)
// UserContentBlacklistCommand 是后台增加/移除运营内容屏蔽的治理命令。
// 时间由 user-service owner 生成,不能信任后台进程或客户端传入的时钟。
type UserContentBlacklistCommand struct {
UserID int64
OperatorAdminID int64
Reason string
NowMs int64
}
// ListUserContentBlacklist 分页读取当前 active 屏蔽用户。
func (s *Service) ListUserContentBlacklist(ctx context.Context, page int32, pageSize int32) ([]userdomain.ContentBlacklistEntry, int64, error) {
if s.contentBlacklistRepository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "content blacklist repository is not configured")
}
if page < 1 {
page = 1
}
if pageSize <= 0 {
pageSize = defaultContentBlacklistPageSize
}
if pageSize > maxContentBlacklistPageSize {
pageSize = maxContentBlacklistPageSize
}
return s.contentBlacklistRepository.ListUserContentBlacklist(ctx, page, pageSize)
}
// AdminAddUserContentBlacklist 启用指定用户的运营内容屏蔽。
// 重复添加保持单行当前态并更新操作者和原因,不会把账号状态改成 banned/disabled。
func (s *Service) AdminAddUserContentBlacklist(ctx context.Context, command UserContentBlacklistCommand) (userdomain.ContentBlacklistEntry, error) {
if s.contentBlacklistRepository == nil {
return userdomain.ContentBlacklistEntry{}, xerr.New(xerr.Unavailable, "content blacklist repository is not configured")
}
normalized, err := s.normalizeUserContentBlacklistCommand(command)
if err != nil {
return userdomain.ContentBlacklistEntry{}, err
}
return s.contentBlacklistRepository.AddUserContentBlacklist(ctx, normalized)
}
// AdminRemoveUserContentBlacklist 关闭指定用户的运营内容屏蔽。
// 已经移除时返回 removed=false允许后台重试同一个操作而不产生错误。
func (s *Service) AdminRemoveUserContentBlacklist(ctx context.Context, command UserContentBlacklistCommand) (bool, error) {
if s.contentBlacklistRepository == nil {
return false, xerr.New(xerr.Unavailable, "content blacklist repository is not configured")
}
normalized, err := s.normalizeUserContentBlacklistCommand(command)
if err != nil {
return false, err
}
return s.contentBlacklistRepository.RemoveUserContentBlacklist(ctx, normalized)
}
func (s *Service) normalizeUserContentBlacklistCommand(command UserContentBlacklistCommand) (UserContentBlacklistCommand, error) {
if command.UserID <= 0 {
return UserContentBlacklistCommand{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if command.OperatorAdminID <= 0 {
return UserContentBlacklistCommand{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
command.Reason = strings.TrimSpace(command.Reason)
if len([]rune(command.Reason)) > maxContentBlacklistReasonRunes {
return UserContentBlacklistCommand{}, xerr.New(xerr.InvalidArgument, "reason is too long")
}
// user-service 的 UTC 时钟是持久事实时间源,后台 RequestMeta.sent_at_ms 只用于链路追踪。
command.NowMs = s.now().UTC().UnixMilli()
return command, nil
}

View File

@ -202,6 +202,15 @@ type DeviceRepository interface {
DeletePushToken(ctx context.Context, command userdomain.DeletePushTokenCommand) (bool, error)
}
// ContentBlacklistRepository 持有运营内容屏蔽当前态。
// 它与账号封禁分离:黑名单只改变游戏列表和 Banner 可见性,不能吊销登录态或修改 users.status。
type ContentBlacklistRepository interface {
IsUserContentBlacklisted(ctx context.Context, userID int64) (bool, error)
ListUserContentBlacklist(ctx context.Context, page int32, pageSize int32) ([]userdomain.ContentBlacklistEntry, int64, error)
AddUserContentBlacklist(ctx context.Context, command UserContentBlacklistCommand) (userdomain.ContentBlacklistEntry, error)
RemoveUserContentBlacklist(ctx context.Context, command UserContentBlacklistCommand) (bool, error)
}
// ModerationRepository 持有用户状态和认证 session 的同库事务。
type ModerationRepository interface {
SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error)
@ -276,6 +285,8 @@ type Service struct {
roleSummaryRepository RoleSummaryRepository
// deviceRepository 持有设备 push token 绑定能力。
deviceRepository DeviceRepository
// contentBlacklistRepository 持有游戏列表和 Banner 屏蔽当前态,不参与账号封禁或登录状态判断。
contentBlacklistRepository ContentBlacklistRepository
// moderationRepository 持有用户状态和 session 吊销事务能力。
moderationRepository ModerationRepository
// roleScopePolicyResolver 与 Host 组织链路共享同一 owner 配置,经理治理不能复制 App 分支。
@ -323,6 +334,10 @@ func New(userRepository UserRepository, options ...Option) *Service {
// 生产 MySQL 用户 repository 同时拥有 users 与头像授权表,凭证消费才能和资料更新保持原子。
svc.avatarUploadRepository = repository
}
if repository, ok := userRepository.(ContentBlacklistRepository); ok {
// 生产 MySQL 用户 repository 同库维护屏蔽名单;测试替身未覆盖该能力时保持可选。
svc.contentBlacklistRepository = repository
}
for _, option := range options {
// option 只改依赖或策略,不做 IO。
option(svc)
@ -461,6 +476,15 @@ func WithDeviceRepository(repository DeviceRepository) Option {
}
}
// WithContentBlacklistRepository 注入运营内容屏蔽 repository。
func WithContentBlacklistRepository(repository ContentBlacklistRepository) Option {
return func(s *Service) {
if repository != nil {
s.contentBlacklistRepository = repository
}
}
}
// WithCountryChangeCooldown 配置用户国家修改冷却窗口。
func WithCountryChangeCooldown(cooldown time.Duration) Option {
return func(s *Service) {

View File

@ -33,7 +33,20 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (userdomain.User, e
}
// 查询用户时顺带执行靓号懒过期,返回给上游的是当前有效展示号。
return s.refreshExpiredUser(ctx, user, "")
user, err = s.refreshExpiredUser(ctx, user, "")
if err != nil {
return userdomain.User{}, err
}
if s.contentBlacklistRepository == nil {
// 纯 service 单测允许只注入最小 UserRepository生产装配会自动注入同库 blacklist repository。
return user, nil
}
user.ContentBlacklisted, err = s.contentBlacklistRepository.IsUserContentBlacklisted(ctx, userID)
if err != nil {
// 屏蔽状态读取失败不能按“未屏蔽”降级,否则数据库抖动会把运营限制静默放开。
return userdomain.User{}, err
}
return user, nil
}
// GetInviteAttribution 按被邀请用户读取邀请关系;没有关系时返回 Found=false调用方按普通非邀请充值处理。

View File

@ -0,0 +1,214 @@
package user
import (
"context"
"database/sql"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
userservice "hyapp/services/user-service/internal/service/user"
)
const contentBlacklistEntryColumns = `
b.user_id,
CASE
WHEN u.current_display_user_id_kind <> 'default'
AND COALESCE(u.current_display_user_id_expires_at_ms, 0) > 0
AND u.current_display_user_id_expires_at_ms <= ?
THEN u.default_display_user_id
ELSE u.current_display_user_id
END,
u.default_display_user_id,
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases pdl
LEFT JOIN pretty_display_ids pdi
ON pdi.app_code = pdl.app_code
AND pdi.assigned_lease_id = pdl.lease_id
AND pdi.assigned_user_id = pdl.user_id
WHERE pdl.app_code = u.app_code
AND pdl.user_id = u.user_id
AND pdl.status = 'active'
AND (pdl.expires_at_ms = 0 OR pdl.expires_at_ms > ?)
ORDER BY pdl.starts_at_ms DESC
LIMIT 1
), ''),
COALESCE((
SELECT pdl.display_user_id
FROM pretty_display_user_id_leases pdl
WHERE pdl.app_code = u.app_code
AND pdl.user_id = u.user_id
AND pdl.status = 'active'
AND (pdl.expires_at_ms = 0 OR pdl.expires_at_ms > ?)
ORDER BY pdl.starts_at_ms DESC
LIMIT 1
), ''),
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
u.status,
b.operator_admin_id,
b.reason,
b.created_at_ms,
b.updated_at_ms`
// IsUserContentBlacklisted 使用租户和用户主键做单点判断。
// 该查询位于 GetUser 链路,必须保持 O(1),不能为运行时裁决扫描黑名单列表。
func (r *Repository) IsUserContentBlacklisted(ctx context.Context, userID int64) (bool, error) {
var blacklisted bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1
FROM user_content_blacklist
WHERE app_code = ? AND user_id = ? AND status = 'active'
)`,
appcode.FromContext(ctx), userID,
).Scan(&blacklisted)
return blacklisted, err
}
// ListUserContentBlacklist 只列出 active 当前态;用户展示资料实时关联 users避免后台看到过期昵称或头像快照。
func (r *Repository) ListUserContentBlacklist(ctx context.Context, page int32, pageSize int32) ([]userdomain.ContentBlacklistEntry, int64, error) {
appCode := appcode.FromContext(ctx)
var total int64
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM user_content_blacklist FORCE INDEX (idx_user_content_blacklist_status_time)
WHERE app_code = ? AND status = 'active'`,
appCode,
).Scan(&total); err != nil {
return nil, 0, err
}
nowMS := userserviceNowMillis()
rows, err := r.db.QueryContext(ctx, `
SELECT `+contentBlacklistEntryColumns+`
FROM user_content_blacklist b FORCE INDEX (idx_user_content_blacklist_status_time)
INNER JOIN users u ON u.user_id = b.user_id AND u.app_code = b.app_code
WHERE b.app_code = ? AND b.status = 'active'
ORDER BY b.created_at_ms DESC, b.user_id DESC
LIMIT ? OFFSET ?`,
nowMS, nowMS, nowMS, appCode, pageSize, int64(page-1)*int64(pageSize),
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]userdomain.ContentBlacklistEntry, 0, pageSize)
for rows.Next() {
item, err := scanContentBlacklistEntry(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
// AddUserContentBlacklist 以 (app_code,user_id) 保存一行当前态。
// 从 removed 恢复时重置加入时间;重复 active 添加只更新操作者、原因和更新时间。
func (r *Repository) AddUserContentBlacklist(ctx context.Context, command userservice.UserContentBlacklistCommand) (userdomain.ContentBlacklistEntry, error) {
appCode := appcode.FromContext(ctx)
if err := r.requireContentBlacklistUser(ctx, appCode, command.UserID); err != nil {
return userdomain.ContentBlacklistEntry{}, err
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO user_content_blacklist (
app_code, user_id, status, operator_admin_id, reason, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
created_at_ms = IF(status = 'active', created_at_ms, VALUES(created_at_ms)),
status = 'active',
operator_admin_id = VALUES(operator_admin_id),
reason = VALUES(reason),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, command.UserID, command.OperatorAdminID, command.Reason, command.NowMs, command.NowMs,
)
if err != nil {
return userdomain.ContentBlacklistEntry{}, err
}
return r.getUserContentBlacklistEntry(ctx, appCode, command.UserID, command.NowMs)
}
// RemoveUserContentBlacklist 软移除当前态,保留最后一次操作元数据供数据库排障。
func (r *Repository) RemoveUserContentBlacklist(ctx context.Context, command userservice.UserContentBlacklistCommand) (bool, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE user_content_blacklist
SET status = 'removed',
operator_admin_id = ?,
reason = ?,
updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND status = 'active'`,
command.OperatorAdminID, command.Reason, command.NowMs, appcode.FromContext(ctx), command.UserID,
)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
func (r *Repository) requireContentBlacklistUser(ctx context.Context, appCode string, userID int64) error {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT 1
FROM users FORCE INDEX (PRIMARY)
WHERE user_id = ? AND app_code = ?`,
userID, appCode,
).Scan(&exists)
if err == sql.ErrNoRows {
return xerr.New(xerr.NotFound, "user not found")
}
return err
}
func (r *Repository) getUserContentBlacklistEntry(ctx context.Context, appCode string, userID int64, nowMS int64) (userdomain.ContentBlacklistEntry, error) {
row := r.db.QueryRowContext(ctx, `
SELECT `+contentBlacklistEntryColumns+`
FROM user_content_blacklist b
INNER JOIN users u ON u.user_id = b.user_id AND u.app_code = b.app_code
WHERE b.app_code = ? AND b.user_id = ? AND b.status = 'active'`,
nowMS, nowMS, nowMS, appCode, userID,
)
item, err := scanContentBlacklistEntry(row)
if err == sql.ErrNoRows {
return userdomain.ContentBlacklistEntry{}, xerr.New(xerr.NotFound, "content blacklist entry not found")
}
return item, err
}
func scanContentBlacklistEntry(scanner interface {
Scan(dest ...any) error
}) (userdomain.ContentBlacklistEntry, error) {
var item userdomain.ContentBlacklistEntry
var status string
err := scanner.Scan(
&item.UserID,
&item.CurrentDisplayUserID,
&item.DefaultDisplayUserID,
&item.PrettyID,
&item.PrettyDisplayUserID,
&item.Username,
&item.Avatar,
&status,
&item.OperatorAdminID,
&item.Reason,
&item.CreatedAtMs,
&item.UpdatedAtMs,
)
item.UserStatus = userdomain.Status(status)
return item, err
}
func userserviceNowMillis() int64 {
// 列表只用该时间判断展示号租约不产生持久事实UTC epoch 不受进程本地时区影响。
return time.Now().UTC().UnixMilli()
}

View File

@ -92,6 +92,24 @@ func toProtoUser(user userdomain.User) *userv1.User {
Invite: toProtoInviteOverview(user.InviteOverview),
PrettyId: user.PrettyID,
PrettyDisplayUserId: user.PrettyDisplayUserID,
ContentBlacklisted: user.ContentBlacklisted,
}
}
func toProtoUserContentBlacklistEntry(item userdomain.ContentBlacklistEntry) *userv1.UserContentBlacklistEntry {
return &userv1.UserContentBlacklistEntry{
UserId: item.UserID,
DisplayUserId: item.CurrentDisplayUserID,
DefaultDisplayUserId: item.DefaultDisplayUserID,
PrettyId: item.PrettyID,
PrettyDisplayUserId: item.PrettyDisplayUserID,
Username: item.Username,
Avatar: item.Avatar,
UserStatus: toProtoStatus(item.UserStatus),
OperatorAdminId: item.OperatorAdminID,
Reason: item.Reason,
CreatedAtMs: item.CreatedAtMs,
UpdatedAtMs: item.UpdatedAtMs,
}
}

View File

@ -784,6 +784,48 @@ func (s *Server) AdminIssueUserAccessToken(ctx context.Context, req *userv1.Admi
}, nil
}
// ListUserContentBlacklist 返回当前 App 的运营内容屏蔽用户。
func (s *Server) ListUserContentBlacklist(ctx context.Context, req *userv1.ListUserContentBlacklistRequest) (*userv1.ListUserContentBlacklistResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
items, total, err := s.userSvc.ListUserContentBlacklist(ctx, req.GetPage(), req.GetPageSize())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
result := make([]*userv1.UserContentBlacklistEntry, 0, len(items))
for _, item := range items {
result = append(result, toProtoUserContentBlacklistEntry(item))
}
return &userv1.ListUserContentBlacklistResponse{Items: result, Total: total}, nil
}
// AdminAddUserContentBlacklist 只启用游戏列表和 Banner 屏蔽,不修改用户主状态或会话。
func (s *Server) AdminAddUserContentBlacklist(ctx context.Context, req *userv1.AdminAddUserContentBlacklistRequest) (*userv1.AdminAddUserContentBlacklistResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
item, err := s.userSvc.AdminAddUserContentBlacklist(ctx, userservice.UserContentBlacklistCommand{
UserID: req.GetUserId(),
OperatorAdminID: req.GetOperatorAdminId(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.AdminAddUserContentBlacklistResponse{Item: toProtoUserContentBlacklistEntry(item)}, nil
}
// AdminRemoveUserContentBlacklist 幂等关闭运营内容屏蔽removed=false 表示目标已经不在名单中。
func (s *Server) AdminRemoveUserContentBlacklist(ctx context.Context, req *userv1.AdminRemoveUserContentBlacklistRequest) (*userv1.AdminRemoveUserContentBlacklistResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
removed, err := s.userSvc.AdminRemoveUserContentBlacklist(ctx, userservice.UserContentBlacklistCommand{
UserID: req.GetUserId(),
OperatorAdminID: req.GetOperatorAdminId(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.AdminRemoveUserContentBlacklistResponse{Removed: removed}, nil
}
// BatchGetRoomBasicUsers 批量返回房间首屏最小用户展示资料。
func (s *Server) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -16,6 +16,15 @@ red_packet_expiry_worker:
enabled: true
poll_interval: "5s"
batch_size: 50
resource_equipment_cleanup_worker:
# 读取链路只过滤有效装备;物理删除由 Wallet 多副本共享的单写者游标任务完成。
enabled: true
poll_interval: "30s"
query_timeout: "3s"
batch_pause: "25ms"
scan_batch_size: 200
delete_batch_size: 50
round_max_scanned_rows: 2000
projection_worker:
enabled: true
poll_interval: "60s"

View File

@ -16,6 +16,15 @@ red_packet_expiry_worker:
enabled: true
poll_interval: "5s"
batch_size: 50
resource_equipment_cleanup_worker:
# 单轮最多扫描 2000 个稳定主键;线上按页短事务,避免和 Equip/Revoke 长时间争锁。
enabled: true
poll_interval: "30s"
query_timeout: "3s"
batch_pause: "25ms"
scan_batch_size: 200
delete_batch_size: 50
round_max_scanned_rows: 2000
projection_worker:
enabled: true
poll_interval: "60s"

View File

@ -16,6 +16,15 @@ red_packet_expiry_worker:
enabled: true
poll_interval: "5s"
batch_size: 50
resource_equipment_cleanup_worker:
# 读取链路只过滤有效装备;物理删除由 Wallet 多副本共享的单写者游标任务完成。
enabled: true
poll_interval: "30s"
query_timeout: "3s"
batch_pause: "25ms"
scan_batch_size: 200
delete_batch_size: 50
round_max_scanned_rows: 2000
projection_worker:
enabled: true
poll_interval: "60s"

View File

@ -409,7 +409,10 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '周期钻石政策系数快照1 表示不调整礼物类型倍率',
point_diamonds_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 每美元对应钻石积分',
coins_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 每美元对应金币',
minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 最低提现美元美分',
minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧客户端的平台最低提现美元美分',
coin_seller_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 用户找币商最低提现美元美分0 回退旧字段',
platform_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 用户找平台最低提现美元美分0 回退旧字段',
diamond_exchange_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开放 POINT_DIAMOND 兑换金币',
withdraw_fee_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 提现费率基点',
agency_point_share_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 代理分成占主播基础积分的基点',
residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例',
@ -1712,6 +1715,16 @@ CREATE TABLE IF NOT EXISTS user_resource_equipment (
KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表';
CREATE TABLE IF NOT EXISTS wallet_resource_equipment_cleanup_state (
job_name VARCHAR(64) NOT NULL COMMENT 'Wallet owner 清理任务稳定名称',
cursor_app_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '装备主键游标 App',
cursor_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '装备主键游标用户',
cursor_resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '装备主键游标资源类型',
cursor_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '装备主键游标权益',
updated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '游标提交时间UTC epoch ms',
PRIMARY KEY (job_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Wallet 资源装备物理清理游标';
-- 装备命令以 (app_code,command_id) 点查并先于 entitlement/equipment 行加锁;不对装备表做历史扫描。
-- 记录首个成功回包快照,确保网络重试不会因资源随后到期或后台下架而改变业务结果。
CREATE TABLE IF NOT EXISTS user_resource_equipment_commands (

View File

@ -0,0 +1,18 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET time_zone = '+00:00';
USE hyapp_wallet;
-- 运行政策表远小于钱包流水;只追加定长列并使用在线 DDL不读取或回填 wallet_transactions/wallet_entries。
-- 既有快照的新渠道字段保持 0由运行时回退旧 minimum_withdraw_usd_minor避免迁移期大范围 UPDATE。
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies' AND COLUMN_NAME = 'coin_seller_minimum_withdraw_usd_minor') = 0,
'ALTER TABLE host_agency_salary_policies ADD COLUMN coin_seller_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 用户找币商最低提现美元美分0 回退旧字段'' AFTER minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', '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 = 'platform_minimum_withdraw_usd_minor') = 0,
'ALTER TABLE host_agency_salary_policies ADD COLUMN platform_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 用户找平台最低提现美元美分0 回退旧字段'' AFTER coin_seller_minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', '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 = 'diamond_exchange_enabled') = 0,
'ALTER TABLE host_agency_salary_policies ADD COLUMN diamond_exchange_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开放 POINT_DIAMOND 兑换金币'' AFTER platform_minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,11 @@
-- 只创建单行游标小表,不扫描或重建 user_resource_equipment生产执行对现有装备表无锁表 DDL。
-- 后台清理通过装备复合主键做固定上限范围扫描,游标与删除在同一短事务提交。
CREATE TABLE IF NOT EXISTS wallet_resource_equipment_cleanup_state (
job_name VARCHAR(64) NOT NULL COMMENT 'Wallet owner 清理任务稳定名称',
cursor_app_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '装备主键游标 App',
cursor_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '装备主键游标用户',
cursor_resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '装备主键游标资源类型',
cursor_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '装备主键游标权益',
updated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '游标提交时间UTC epoch ms',
PRIMARY KEY (job_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Wallet 资源装备物理清理游标';

View File

@ -54,6 +54,7 @@ type App struct {
projectionWorkerCfg config.ProjectionWorkerConfig
googlePaidSyncWorkerCfg config.GooglePaidSyncWorkerConfig
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
resourceEquipmentCleanupWorkerCfg config.ResourceEquipmentCleanupWorkerConfig
walletOutboxTopic string
realtimeWalletOutboxTopic string
walletEventTagMode config.WalletEventTagMode
@ -232,6 +233,7 @@ func New(cfg config.Config) (*App, error) {
projectionWorkerCfg: cfg.ProjectionWorker,
googlePaidSyncWorkerCfg: googlePaidSyncWorkerCfg,
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
resourceEquipmentCleanupWorkerCfg: cfg.ResourceEquipmentCleanupWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,

View File

@ -35,4 +35,9 @@ func (a *App) runBackgroundWorkers() {
if a.redPacketExpiryWorkerCfg.Enabled {
a.startRedPacketExpiryWorker(ctx)
}
if a.resourceEquipmentCleanupWorkerCfg.Enabled {
// 装备残留是 Wallet owner 数据;多副本只由 advisory lock winner 执行物理删除,
// gateway/cron 都不能直连钱包库或在用户请求内代做清理。
a.startResourceEquipmentCleanupWorker(ctx)
}
}

View File

@ -0,0 +1,90 @@
package app
import (
"context"
"errors"
"log/slog"
"time"
"hyapp/pkg/logx"
)
func (a *App) startResourceEquipmentCleanupWorker(ctx context.Context) {
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.resourceEquipmentCleanupWorkerCfg.PollInterval)
defer ticker.Stop()
for {
if err := a.runResourceEquipmentCleanupRound(ctx); err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "wallet_resource_equipment_cleanup_round_failed", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func (a *App) runResourceEquipmentCleanupRound(ctx context.Context) error {
if a.mysqlRepo == nil {
return nil
}
cfg := a.resourceEquipmentCleanupWorkerCfg
lockCtx, lockCancel := context.WithTimeout(ctx, cfg.QueryTimeout)
release, acquired, err := a.mysqlRepo.AcquireResourceEquipmentCleanupLock(lockCtx)
lockCancel()
if err != nil || !acquired {
return err
}
defer release()
totalScanned := 0
totalDeleted := 0
totalDeadlockRetries := 0
reachedEnd := false
for totalScanned < cfg.RoundMaxScannedRows {
pageCtx, pageCancel := context.WithTimeout(ctx, cfg.QueryTimeout)
result, pageErr := a.mysqlRepo.CleanupExpiredResourceEquipmentPage(
pageCtx,
cfg.ScanBatchSize,
cfg.DeleteBatchSize,
time.Now().UTC().UnixMilli(),
)
pageCancel()
if pageErr != nil {
return pageErr
}
totalScanned += result.ScannedCount
totalDeleted += result.DeletedCount
totalDeadlockRetries += result.DeadlockRetries
if result.ReachedEnd {
reachedEnd = true
break
}
if cfg.BatchPause > 0 {
timer := time.NewTimer(cfg.BatchPause)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ctx.Err()
case <-timer.C:
}
}
}
if totalDeleted > 0 || totalDeadlockRetries > 0 {
// 日志只记录有实际回收或发生窄重试的轮次;空表巡检不制造周期性噪音。
logx.Info(ctx, "wallet_resource_equipment_cleanup_completed",
slog.Int("scanned_count", totalScanned),
slog.Int("deleted_count", totalDeleted),
slog.Int("deadlock_retries", totalDeadlockRetries),
slog.Bool("reached_end", reachedEnd),
)
}
return nil
}

View File

@ -47,6 +47,8 @@ type Config struct {
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
// ResourceEquipmentCleanupWorker 把失效装备的物理删除收敛到 Wallet owner 后台,读取接口只做有效性过滤。
ResourceEquipmentCleanupWorker ResourceEquipmentCleanupWorkerConfig `yaml:"resource_equipment_cleanup_worker"`
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// OutboxArchive 把已投递事实归档到外部存储purge 仅对恢复验证且命中正向安全策略的精确成员开放。
@ -336,6 +338,17 @@ type ExternalRechargeReconcileWorkerConfig struct {
BatchSize int `yaml:"batch_size"`
}
// ResourceEquipmentCleanupWorkerConfig 控制失效装备的单写者、游标式物理清理。
type ResourceEquipmentCleanupWorkerConfig struct {
Enabled bool `yaml:"enabled"`
PollInterval time.Duration `yaml:"poll_interval"`
QueryTimeout time.Duration `yaml:"query_timeout"`
BatchPause time.Duration `yaml:"batch_pause"`
ScanBatchSize int `yaml:"scan_batch_size"`
DeleteBatchSize int `yaml:"delete_batch_size"`
RoundMaxScannedRows int `yaml:"round_max_scanned_rows"`
}
// Default 返回本地开发默认配置。
func Default() Config {
return Config{
@ -402,6 +415,15 @@ func Default() Config {
PollInterval: 30 * time.Second,
BatchSize: 50,
},
ResourceEquipmentCleanupWorker: ResourceEquipmentCleanupWorkerConfig{
Enabled: true,
PollInterval: 30 * time.Second,
QueryTimeout: 3 * time.Second,
BatchPause: 25 * time.Millisecond,
ScanBatchSize: 200,
DeleteBatchSize: 50,
RoundMaxScannedRows: 2000,
},
OutboxWorker: OutboxWorkerConfig{
Enabled: false,
PollInterval: time.Second,
@ -600,6 +622,10 @@ func Load(path string) (Config, error) {
cfg.ExternalRecharge.V5PayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayReturnURL)
// USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。
cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker)
cfg.ResourceEquipmentCleanupWorker = normalizeResourceEquipmentCleanupWorkerConfig(
cfg.ResourceEquipmentCleanupWorker,
Default().ResourceEquipmentCleanupWorker,
)
cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
@ -953,6 +979,40 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig, defaults OutboxWorkerCo
return cfg
}
func normalizeResourceEquipmentCleanupWorkerConfig(cfg ResourceEquipmentCleanupWorkerConfig, defaults ResourceEquipmentCleanupWorkerConfig) ResourceEquipmentCleanupWorkerConfig {
if cfg.PollInterval <= 0 {
cfg.PollInterval = defaults.PollInterval
}
if cfg.QueryTimeout <= 0 {
cfg.QueryTimeout = defaults.QueryTimeout
}
if cfg.BatchPause < 0 {
cfg.BatchPause = defaults.BatchPause
}
if cfg.ScanBatchSize <= 0 {
cfg.ScanBatchSize = defaults.ScanBatchSize
}
if cfg.ScanBatchSize > 1000 {
cfg.ScanBatchSize = 1000
}
if cfg.DeleteBatchSize <= 0 {
cfg.DeleteBatchSize = defaults.DeleteBatchSize
}
if cfg.DeleteBatchSize > cfg.ScanBatchSize {
cfg.DeleteBatchSize = cfg.ScanBatchSize
}
if cfg.RoundMaxScannedRows <= 0 {
cfg.RoundMaxScannedRows = defaults.RoundMaxScannedRows
}
if cfg.RoundMaxScannedRows < cfg.ScanBatchSize {
cfg.RoundMaxScannedRows = cfg.ScanBatchSize
}
if cfg.RoundMaxScannedRows > 100_000 {
cfg.RoundMaxScannedRows = 100_000
}
return cfg
}
func normalizeOutboxArchiveConfig(cfg OutboxArchiveConfig, defaults OutboxArchiveConfig) (OutboxArchiveConfig, error) {
cfg.Mode = OutboxArchiveMode(strings.ToLower(strings.TrimSpace(string(cfg.Mode))))
if cfg.Mode == "" {

View File

@ -39,17 +39,21 @@ type HostSalaryPolicy struct {
SettlementMode string
SettlementTriggerMode string
// GiftCoinToDiamondRatio 是叠加在礼物类型倍率上的 Host 政策系数1 表示不调整。
GiftCoinToDiamondRatio string
PointDiamondsPerUSD int64
CoinsPerUSD int64
MinimumWithdrawUSDMinor int64
WithdrawFeeBPS int32
AgencyPointShareBPS int32
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
GiftCoinToDiamondRatio string
PointDiamondsPerUSD int64
CoinsPerUSD int64
// MinimumWithdrawUSDMinor 是历史客户端兼容值,始终投影平台提现门槛;资金校验必须读取下面的渠道字段。
MinimumWithdrawUSDMinor int64
CoinSellerMinimumWithdrawUSDMinor int64
PlatformMinimumWithdrawUSDMinor int64
DiamondExchangeEnabled bool
WithdrawFeeBPS int32
AgencyPointShareBPS int32
ResidualDiamondToUSDRate string
CoinSellerWithdrawalLimitPeriod string
CoinSellerWithdrawalLimitCount int64
PlatformWithdrawalLimitPeriod string
PlatformWithdrawalLimitCount int64
// PlatformWithdrawalAllowedDays 是 UTC 月内日的规范逗号串;空串表示不限制提现日期。
PlatformWithdrawalAllowedDays string
EffectiveFromMs int64

View File

@ -81,20 +81,24 @@ type PointToCoinReceipt struct {
// PointWithdrawalRuntimeConfig 是已发布 wallet 政策中钱包页需要展示并用于冻结的 POINT 参数。
type PointWithdrawalRuntimeConfig struct {
Found bool
PointsPerUSD int64
CoinsPerUSD int64
FeeBPS int64
MinimumPoints int64
PolicyInstanceCode string
PolicyType string
MinimumWithdrawUSDMinor int64
AgencyPointShareBPS int32
PolicyID uint64
PolicyVersion uint64
AvailabilityEvaluated bool
CoinSellerAvailability PointWithdrawalActionAvailability
PlatformAvailability PointWithdrawalActionAvailability
Found bool
PointsPerUSD int64
CoinsPerUSD int64
FeeBPS int64
MinimumPoints int64
PolicyInstanceCode string
PolicyType string
// MinimumWithdrawUSDMinor 兼容旧客户端,值等于平台提现门槛。
MinimumWithdrawUSDMinor int64
CoinSellerMinimumWithdrawUSDMinor int64
PlatformMinimumWithdrawUSDMinor int64
DiamondExchangeEnabled bool
AgencyPointShareBPS int32
PolicyID uint64
PolicyVersion uint64
AvailabilityEvaluated bool
CoinSellerAvailability PointWithdrawalActionAvailability
PlatformAvailability PointWithdrawalActionAvailability
}
// PointWithdrawalActionAvailability 是钱包按当前 UTC 日期和已消费次数计算的实时能力;

View File

@ -491,7 +491,9 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar
SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, p.policy_type, binding.region_id, p.status,
p.settlement_mode, p.settlement_trigger_mode,
CAST(p.gift_coin_to_diamond_ratio AS CHAR),
p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor, p.withdraw_fee_bps, p.agency_point_share_bps,
p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor,
p.coin_seller_minimum_withdraw_usd_minor, p.platform_minimum_withdraw_usd_minor, p.diamond_exchange_enabled,
p.withdraw_fee_bps, p.agency_point_share_bps,
CAST(p.residual_diamond_to_usd_rate AS CHAR),
p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count,
p.platform_withdrawal_limit_period, p.platform_withdrawal_limit_count, p.platform_withdrawal_allowed_days,
@ -512,7 +514,9 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar
err := q.QueryRowContext(ctx, query, args...).Scan(
&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.PolicyType, &policy.RegionID, &policy.Status,
&policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio,
&policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor, &policy.WithdrawFeeBPS,
&policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor,
&policy.CoinSellerMinimumWithdrawUSDMinor, &policy.PlatformMinimumWithdrawUSDMinor, &policy.DiamondExchangeEnabled,
&policy.WithdrawFeeBPS,
&policy.AgencyPointShareBPS,
&policy.ResidualDiamondToUSDRate,
&policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount,
@ -530,6 +534,7 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar
if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond && policy.PolicyType != ledger.HostPolicyTypePointDiamond {
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Internal, "host policy type is invalid")
}
normalizeHostPolicyWithdrawalMinimums(&policy)
levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID, policy.PolicyVersion)
if err != nil {
return ledger.HostSalaryPolicy{}, false, err

View File

@ -82,10 +82,15 @@ type WalletOutboxArchiveReceipt struct {
// AcquireWalletOutboxArchiveAppLock 用 MySQL connection-scoped advisory lock 保证多副本下每个 App 只有一个归档者。
// release 必须 defer 调用;专用 Conn 在释放前不会回到连接池,避免锁跟随错误会话。
func (r *Repository) AcquireWalletOutboxArchiveAppLock(ctx context.Context) (release func(), acquired bool, err error) {
return r.acquireNamedLock(ctx, "wallet_outbox_archive:"+appcode.FromContext(ctx))
}
// acquireNamedLock 为 Wallet owner 后台任务保留专用连接直到显式 release。
// 名称只能由服务端常量和规范化 app_code 组成,不能接受外部请求值。
func (r *Repository) acquireNamedLock(ctx context.Context, lockName string) (release func(), acquired bool, err error) {
if r == nil || r.db == nil {
return nil, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
lockName := "wallet_outbox_archive:" + appcode.FromContext(ctx)
conn, err := r.db.Conn(ctx)
if err != nil {
return nil, false, err

View File

@ -143,7 +143,7 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg
if err != nil {
return ledger.PointToCoinSellerReceipt{}, err
}
if command.GrossUSDMinor < policy.MinimumWithdrawUSDMinor {
if command.GrossUSDMinor < policy.CoinSellerMinimumWithdrawUSDMinor {
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum")
}
pointAmount, feeUSDMinor, netUSDMinor, sellerCoinAmount, err = calculatePointDiamondCoinSellerAmounts(
@ -326,7 +326,7 @@ func (r *Repository) getPointWithdrawalConfig(ctx context.Context, appCode strin
}
return ledger.PointWithdrawalRuntimeConfig{}, err
}
minimumPoints, calculateErr := checkedMul(policy.MinimumWithdrawUSDMinor, policy.PointDiamondsPerUSD)
minimumPoints, calculateErr := checkedMul(policy.PlatformMinimumWithdrawUSDMinor, policy.PointDiamondsPerUSD)
if calculateErr != nil {
return ledger.PointWithdrawalRuntimeConfig{}, calculateErr
}
@ -334,8 +334,12 @@ func (r *Repository) getPointWithdrawalConfig(ctx context.Context, appCode strin
Found: true, PointsPerUSD: policy.PointDiamondsPerUSD, CoinsPerUSD: policy.CoinsPerUSD,
FeeBPS: int64(policy.WithdrawFeeBPS), MinimumPoints: minimumPoints / 100,
PolicyInstanceCode: fmt.Sprintf("host:%d:%d", policy.PolicyID, policy.PolicyVersion), PolicyType: policy.PolicyType,
MinimumWithdrawUSDMinor: policy.MinimumWithdrawUSDMinor, AgencyPointShareBPS: policy.AgencyPointShareBPS,
PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion,
MinimumWithdrawUSDMinor: policy.PlatformMinimumWithdrawUSDMinor,
CoinSellerMinimumWithdrawUSDMinor: policy.CoinSellerMinimumWithdrawUSDMinor,
PlatformMinimumWithdrawUSDMinor: policy.PlatformMinimumWithdrawUSDMinor,
DiamondExchangeEnabled: policy.DiamondExchangeEnabled,
AgencyPointShareBPS: policy.AgencyPointShareBPS,
PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion,
}
if userID > 0 {
now := time.UnixMilli(nowMS).UTC()

View File

@ -69,6 +69,11 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi
if err != nil {
return ledger.PointToCoinReceipt{}, err
}
if !policy.DiamondExchangeEnabled {
// UI action 只是展示投影;资金事务必须再次读取同一政策快照,关闭后拒绝所有新 command。
// 幂等回执查询位于此前,因此开关关闭不会把已经成功但响应丢失的重试改成失败。
return ledger.PointToCoinReceipt{}, xerr.New(xerr.PermissionDenied, "point diamond exchange is disabled")
}
ratioPointAmount, ratioCoinAmount = policy.PointDiamondsPerUSD, policy.CoinsPerUSD
policyID, policyVersion = policy.PolicyID, policy.PolicyVersion
} else {
@ -177,6 +182,8 @@ func (r *Repository) resolvePointDiamondHostPolicy(ctx context.Context, tx *sql.
err := tx.QueryRowContext(ctx, `
SELECT p.policy_id, p.policy_version, binding.cycle_key, binding.region_id,
p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor,
p.coin_seller_minimum_withdraw_usd_minor, p.platform_minimum_withdraw_usd_minor,
p.diamond_exchange_enabled,
p.withdraw_fee_bps, p.agency_point_share_bps,
p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count,
p.platform_withdrawal_limit_period, p.platform_withdrawal_limit_count,
@ -195,6 +202,8 @@ func (r *Repository) resolvePointDiamondHostPolicy(ctx context.Context, tx *sql.
LIMIT 1`, appcode.FromContext(ctx), regionID, cycleKey, ledger.HostPolicyTypePointDiamond).Scan(
&policy.PolicyID, &policy.PolicyVersion, &policy.CycleKey, &policy.RegionID,
&policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor,
&policy.CoinSellerMinimumWithdrawUSDMinor, &policy.PlatformMinimumWithdrawUSDMinor,
&policy.DiamondExchangeEnabled,
&policy.WithdrawFeeBPS, &policy.AgencyPointShareBPS,
&policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount,
&policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount,
@ -207,8 +216,24 @@ func (r *Repository) resolvePointDiamondHostPolicy(ctx context.Context, tx *sql.
return ledger.HostSalaryPolicy{}, err
}
policy.PolicyType = ledger.HostPolicyTypePointDiamond
if policy.PointDiamondsPerUSD <= 0 || policy.CoinsPerUSD <= 0 || policy.MinimumWithdrawUSDMinor <= 0 || policy.WithdrawFeeBPS < 0 || policy.WithdrawFeeBPS > 10_000 {
normalizeHostPolicyWithdrawalMinimums(&policy)
if policy.PointDiamondsPerUSD <= 0 || policy.CoinsPerUSD <= 0 || policy.CoinSellerMinimumWithdrawUSDMinor <= 0 || policy.PlatformMinimumWithdrawUSDMinor <= 0 || policy.WithdrawFeeBPS < 0 || policy.WithdrawFeeBPS > 10_000 {
return ledger.HostSalaryPolicy{}, xerr.New(xerr.Internal, "point diamond policy is invalid")
}
return policy, nil
}
// normalizeHostPolicyWithdrawalMinimums 把迁移前单一门槛投影到两个渠道;新快照会直接保存精确字段。
// 兼容字段固定等于平台门槛,保证旧 Gateway/H5 在滚动升级期间仍执行原平台提现口径。
func normalizeHostPolicyWithdrawalMinimums(policy *ledger.HostSalaryPolicy) {
if policy == nil {
return
}
if policy.CoinSellerMinimumWithdrawUSDMinor <= 0 {
policy.CoinSellerMinimumWithdrawUSDMinor = policy.MinimumWithdrawUSDMinor
}
if policy.PlatformMinimumWithdrawUSDMinor <= 0 {
policy.PlatformMinimumWithdrawUSDMinor = policy.MinimumWithdrawUSDMinor
}
policy.MinimumWithdrawUSDMinor = policy.PlatformMinimumWithdrawUSDMinor
}

View File

@ -16,6 +16,11 @@ import (
)
func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
items, err := r.listUserResources(ctx, query)
return items, mapTransientResourceReadError(err)
}
func (r *Repository) listUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
@ -25,7 +30,6 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
}
nowMs := time.Now().UnixMilli()
pruneResourceTypes := []string{}
where := `WHERE e.app_code = ? AND e.user_id = ?`
args := []any{appcode.FromContext(ctx), query.UserID}
if strings.TrimSpace(query.ResourceType) != "" {
@ -33,14 +37,9 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
if !resourcedomain.ValidResourceType(resourceType) {
return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
}
if isEquipableResourceType(resourceType) {
pruneResourceTypes = []string{resourceType}
}
}
if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{query.UserID}, pruneResourceTypes, nowMs); err != nil {
return nil, err
}
// 背包和资料读取只能过滤事实,不能顺带修改 equipment。过期/失效条件由下方
// active 谓词和后台单写者清理共同收敛,避免同一用户并发读退化成 DELETE 写锁竞争。
if query.ActiveOnly {
where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0`
@ -497,6 +496,11 @@ func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedo
}
func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) {
items, err := r.batchGetUserEquippedResources(ctx, query)
return items, mapTransientResourceReadError(err)
}
func (r *Repository) batchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
@ -517,10 +521,8 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
}
nowMs := time.Now().UnixMilli()
if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil {
return nil, err
}
// appearance/在线用户批量读不再物理清理 equipment查询本身完整过滤失效权益
// 后台任务按稳定主键分页删除残留行,读请求不会因为清理死锁成为 1213 受害者。
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
args = append(args, nowMs, nowMs)
typeFilter := ""
@ -972,46 +974,6 @@ func isAutoEquippedOnGrantResourceType(resourceType string) bool {
}
}
func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error {
userIDs = compactPositiveInt64s(userIDs)
if r == nil || r.db == nil || len(userIDs) == 0 {
return nil
}
resourceTypes, err := normalizeEquipableResourceTypes(resourceTypes)
if err != nil {
return err
}
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
args = append(args, nowMs, nowMs)
typeFilter := ""
if len(resourceTypes) > 0 {
typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)`
args = append(args, stringAnyArgs(resourceTypes)...)
}
_, err = r.db.ExecContext(ctx, `
DELETE eq
FROM user_resource_equipment eq
LEFT JOIN user_resource_entitlements e ON e.app_code = eq.app_code
AND e.user_id = eq.user_id
AND e.resource_id = eq.resource_id
AND e.entitlement_id = eq.entitlement_id
LEFT JOIN resources r ON r.app_code = eq.app_code AND r.resource_id = eq.resource_id
WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`)
AND (
e.entitlement_id IS NULL
OR e.status <> 'active'
OR e.effective_at_ms > ?
OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?)
OR e.remaining_quantity <= 0
OR (e.source_snapshot_id = '' AND (r.resource_id IS NULL OR r.status <> 'active'))
)`+typeFilter,
args...,
)
return err
}
func isEquipableResourceType(resourceType string) bool {
switch resourcedomain.NormalizeResourceType(resourceType) {
// 麦位中心图标和声波动效必须使用两个独立的 resource_type 单选槽;用户切换 mic_seat_icon

View File

@ -0,0 +1,333 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"math/rand/v2"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"hyapp/pkg/xerr"
)
const (
resourceEquipmentCleanupJobName = "resource_equipment_cleanup_v1"
resourceEquipmentCleanupLockName = "wallet_resource_equipment_cleanup:v1"
)
// ResourceEquipmentCleanupPageResult 描述一个已经提交的有界清理页。
// ScannedCount 统计主键页读取量DeletedCount 只统计复核后实际删除的装备指针。
type ResourceEquipmentCleanupPageResult struct {
ScannedCount int
DeletedCount int
ReachedEnd bool
DeadlockRetries int
}
type resourceEquipmentCleanupCursor struct {
AppCode string
UserID int64
ResourceType string
EntitlementID string
}
type resourceEquipmentCleanupRow struct {
Cursor resourceEquipmentCleanupCursor
ResourceID int64
Invalid bool
}
// AcquireResourceEquipmentCleanupLock 保证 Wallet 多副本每轮只有一个物理清理者。
// 持久游标行仍会在删除事务内 FOR UPDATE作为连接异常导致 advisory lock 丢失时的第二道串行边界。
func (r *Repository) AcquireResourceEquipmentCleanupLock(ctx context.Context) (release func(), acquired bool, err error) {
return r.acquireNamedLock(ctx, resourceEquipmentCleanupLockName)
}
// CleanupExpiredResourceEquipmentPage 清理一个稳定主键页,并且只对 MySQL 1213 做一次短抖动重试。
// 1205 可能已经消耗完整 lock_wait_timeout不在本地重试下一轮 worker 会从未提交游标重新执行。
func (r *Repository) CleanupExpiredResourceEquipmentPage(ctx context.Context, scanLimit int, deleteLimit int, nowMS int64) (ResourceEquipmentCleanupPageResult, error) {
if r == nil || r.db == nil {
return ResourceEquipmentCleanupPageResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if scanLimit <= 0 || scanLimit > 1000 || deleteLimit <= 0 || deleteLimit > scanLimit {
return ResourceEquipmentCleanupPageResult{}, xerr.New(xerr.InvalidArgument, "resource equipment cleanup limits are invalid")
}
if nowMS <= 0 {
return ResourceEquipmentCleanupPageResult{}, xerr.New(xerr.InvalidArgument, "resource equipment cleanup time is invalid")
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
result, err := r.cleanupExpiredResourceEquipmentPageOnce(ctx, scanLimit, deleteLimit, nowMS)
if err == nil {
result.DeadlockRetries = attempt
return result, nil
}
if !isMySQLDeadlockError(err) || attempt == 1 {
return ResourceEquipmentCleanupPageResult{}, err
}
lastErr = err
timer := time.NewTimer(resourceEquipmentCleanupRetryDelay())
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ResourceEquipmentCleanupPageResult{}, ctx.Err()
case <-timer.C:
}
}
return ResourceEquipmentCleanupPageResult{}, lastErr
}
func (r *Repository) cleanupExpiredResourceEquipmentPageOnce(ctx context.Context, scanLimit int, deleteLimit int, nowMS int64) (ResourceEquipmentCleanupPageResult, error) {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
defer func() { _ = tx.Rollback() }()
// 单行状态先加锁、装备行后加锁;即使 advisory lock 因连接故障意外释放,
// 两个副本也会按同一顺序串行处理,不能同时推进或跳过游标。
if _, err = tx.ExecContext(ctx, `
INSERT IGNORE INTO wallet_resource_equipment_cleanup_state (
job_name, cursor_app_code, cursor_user_id, cursor_resource_type,
cursor_entitlement_id, updated_at_ms
) VALUES (?, '', 0, '', '', 0)`,
resourceEquipmentCleanupJobName,
); err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
var cursor resourceEquipmentCleanupCursor
if err = tx.QueryRowContext(ctx, `
SELECT cursor_app_code, cursor_user_id, cursor_resource_type, cursor_entitlement_id
FROM wallet_resource_equipment_cleanup_state
WHERE job_name = ?
FOR UPDATE`,
resourceEquipmentCleanupJobName,
).Scan(&cursor.AppCode, &cursor.UserID, &cursor.ResourceType, &cursor.EntitlementID); err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
rows, err := selectResourceEquipmentCleanupPage(ctx, tx, cursor, scanLimit, nowMS)
if err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
if len(rows) == 0 {
if cursor != (resourceEquipmentCleanupCursor{}) {
if err = updateResourceEquipmentCleanupCursor(ctx, tx, resourceEquipmentCleanupCursor{}, nowMS); err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
}
if err = tx.Commit(); err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
return ResourceEquipmentCleanupPageResult{ReachedEnd: true}, nil
}
// 删除候选始终保持 equipment PRIMARY KEY 顺序。达到删除上限时只推进到最后一个候选,
// 让本页后续行在下一事务重新复核,不能因为批次截断而永久跳过失效指针。
candidates := make([]resourceEquipmentCleanupRow, 0, deleteLimit)
nextCursor := rows[len(rows)-1].Cursor
for _, row := range rows {
if !row.Invalid {
continue
}
candidates = append(candidates, row)
if len(candidates) == deleteLimit {
nextCursor = row.Cursor
break
}
}
deletedCount := 0
for _, candidate := range candidates {
deleted, deleteErr := deleteInvalidResourceEquipmentCandidate(ctx, tx, candidate, nowMS)
if deleteErr != nil {
return ResourceEquipmentCleanupPageResult{}, deleteErr
}
deletedCount += deleted
}
lastScannedCursor := rows[len(rows)-1].Cursor
reachedEnd := len(rows) < scanLimit && nextCursor == lastScannedCursor
if reachedEnd {
nextCursor = resourceEquipmentCleanupCursor{}
}
if err = updateResourceEquipmentCleanupCursor(ctx, tx, nextCursor, nowMS); err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
if err = tx.Commit(); err != nil {
return ResourceEquipmentCleanupPageResult{}, err
}
return ResourceEquipmentCleanupPageResult{
ScannedCount: len(rows),
DeletedCount: deletedCount,
ReachedEnd: reachedEnd,
}, nil
}
func selectResourceEquipmentCleanupPage(ctx context.Context, tx *sql.Tx, cursor resourceEquipmentCleanupCursor, limit int, nowMS int64) ([]resourceEquipmentCleanupRow, error) {
const pageProjection = `
SELECT page.app_code, page.user_id, page.resource_type, page.entitlement_id, page.resource_id,
(
e.entitlement_id IS NULL
OR e.status <> 'active'
OR e.effective_at_ms > ?
OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?)
OR e.remaining_quantity <= 0
OR (e.source_snapshot_id = '' AND (r.resource_id IS NULL OR r.status <> 'active'))
) AS invalid
FROM (%s) AS page
LEFT JOIN user_resource_entitlements AS e
ON e.app_code = page.app_code
AND e.user_id = page.user_id
AND e.resource_id = page.resource_id
AND e.entitlement_id = page.entitlement_id
LEFT JOIN resources AS r
ON r.resource_id = page.resource_id
AND r.app_code = page.app_code
ORDER BY page.app_code, page.user_id, page.resource_type, page.entitlement_id`
// 先从 equipment PRIMARY KEY 截取固定数量,再做两个点查 JOIN。显式字典序 OR 谓词让
// MySQL 使用 Index range scanrow-constructor `>` 会退化成从主键表头过滤,游标靠后时不够有界。
var (
query string
args []any
)
if cursor == (resourceEquipmentCleanupCursor{}) {
query = `
SELECT app_code, user_id, resource_type, entitlement_id, resource_id
FROM user_resource_equipment FORCE INDEX (PRIMARY)
ORDER BY app_code, user_id, resource_type, entitlement_id
LIMIT ?`
args = []any{nowMS, nowMS, limit}
} else {
query = `
SELECT app_code, user_id, resource_type, entitlement_id, resource_id
FROM user_resource_equipment FORCE INDEX (PRIMARY)
WHERE app_code > ?
OR (app_code = ? AND user_id > ?)
OR (app_code = ? AND user_id = ? AND resource_type > ?)
OR (app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id > ?)
ORDER BY app_code, user_id, resource_type, entitlement_id
LIMIT ?`
args = []any{
nowMS, nowMS,
cursor.AppCode,
cursor.AppCode, cursor.UserID,
cursor.AppCode, cursor.UserID, cursor.ResourceType,
cursor.AppCode, cursor.UserID, cursor.ResourceType, cursor.EntitlementID,
limit,
}
}
rows, err := tx.QueryContext(ctx, fmt.Sprintf(pageProjection, query), args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]resourceEquipmentCleanupRow, 0, limit)
for rows.Next() {
var row resourceEquipmentCleanupRow
if err = rows.Scan(
&row.Cursor.AppCode,
&row.Cursor.UserID,
&row.Cursor.ResourceType,
&row.Cursor.EntitlementID,
&row.ResourceID,
&row.Invalid,
); err != nil {
return nil, err
}
result = append(result, row)
}
if err = rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func deleteInvalidResourceEquipmentCandidate(ctx context.Context, tx *sql.Tx, candidate resourceEquipmentCleanupRow, nowMS int64) (int, error) {
result, err := tx.ExecContext(ctx, `
DELETE eq
FROM user_resource_equipment AS eq FORCE INDEX (PRIMARY)
LEFT JOIN user_resource_entitlements AS e
ON e.app_code = eq.app_code
AND e.user_id = eq.user_id
AND e.resource_id = eq.resource_id
AND e.entitlement_id = eq.entitlement_id
LEFT JOIN resources AS r
ON r.resource_id = eq.resource_id
AND r.app_code = eq.app_code
WHERE eq.app_code = ?
AND eq.user_id = ?
AND eq.resource_type = ?
AND eq.entitlement_id = ?
AND eq.resource_id = ?
AND (
e.entitlement_id IS NULL
OR e.status <> 'active'
OR e.effective_at_ms > ?
OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?)
OR e.remaining_quantity <= 0
OR (e.source_snapshot_id = '' AND (r.resource_id IS NULL OR r.status <> 'active'))
)`,
candidate.Cursor.AppCode,
candidate.Cursor.UserID,
candidate.Cursor.ResourceType,
candidate.Cursor.EntitlementID,
candidate.ResourceID,
nowMS,
nowMS,
)
if err != nil {
return 0, err
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return int(affected), nil
}
func updateResourceEquipmentCleanupCursor(ctx context.Context, tx *sql.Tx, cursor resourceEquipmentCleanupCursor, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE wallet_resource_equipment_cleanup_state
SET cursor_app_code = ?, cursor_user_id = ?, cursor_resource_type = ?,
cursor_entitlement_id = ?, updated_at_ms = ?
WHERE job_name = ?`,
cursor.AppCode,
cursor.UserID,
cursor.ResourceType,
cursor.EntitlementID,
nowMS,
resourceEquipmentCleanupJobName,
)
return err
}
func isMySQLDeadlockError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1213
}
func mapTransientResourceReadError(err error) error {
if !isMySQLDeadlockError(err) {
return err
}
// 对外只暴露稳定、可判定的瞬时 reason原始 MySQL 错误继续留在 error chain
// 供 Wallet 服务端 access log 诊断,不能进入 gRPC message 或 HTTP envelope。
return fmt.Errorf("%w: %w",
xerr.New(xerr.WalletResourceReadTransient, "wallet resource data is temporarily unavailable"),
err,
)
}
func resourceEquipmentCleanupRetryDelay() time.Duration {
// 单次重试固定在 20-60ms 内;随机抖动只用于错开 Equip/Revoke 的瞬时持锁窗口,
// 不能形成无限退避或把持续故障隐藏在 worker 内。
return 20*time.Millisecond + time.Duration(rand.IntN(41))*time.Millisecond
}

View File

@ -108,7 +108,7 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S
if resolveErr != nil {
return ledger.SalaryWithdrawalReceipt{}, resolveErr
}
if command.GrossUSDMinor < policy.MinimumWithdrawUSDMinor {
if command.GrossUSDMinor < policy.PlatformMinimumWithdrawUSDMinor {
return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum")
}
pointProduct, calculateErr := checkedMul(command.GrossUSDMinor, policy.PointDiamondsPerUSD)

View File

@ -91,9 +91,26 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
ADD COLUMN minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 最低提现美元美分' AFTER coins_per_usd, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) {
return err
}
// 渠道门槛不做启动时 UPDATE 回填:旧快照以 0 标记未拆分,资金事务读取时回退历史单一门槛。
// 这样兼容升级只在线追加小型配置表字段,不扫描任何钱包或提现流水。
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN withdraw_fee_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 提现费率基点' AFTER minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) {
ADD COLUMN coin_seller_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 用户找币商最低提现美元美分0 回退旧字段' AFTER minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN platform_minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 用户找平台最低提现美元美分0 回退旧字段' AFTER coin_seller_minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN diamond_exchange_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开放 POINT_DIAMOND 兑换金币' AFTER platform_minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies
ADD COLUMN withdraw_fee_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 提现费率基点' AFTER diamond_exchange_enabled, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
@ -674,6 +691,21 @@ func ensureUserResourceEquipmentSchema(ctx context.Context, db *sql.DB) error {
}
}
// 清理游标是单行小表;后台每页与装备删除同事务推进,节点滚动或 leader 切换后
// 不会回到表头反复扫描。表创建不触碰 equipment 大表,也不需要在线索引变更。
if _, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS wallet_resource_equipment_cleanup_state (
job_name VARCHAR(64) NOT NULL COMMENT 'Wallet owner 清理任务稳定名称',
cursor_app_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '装备主键游标 App',
cursor_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '装备主键游标用户',
cursor_resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '装备主键游标资源类型',
cursor_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '装备主键游标权益',
updated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '游标提交时间UTC epoch ms',
PRIMARY KEY (job_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Wallet 资源装备物理清理游标'`); err != nil {
return err
}
// 命令表是 append-only 幂等事实,主键查询不会扫描 equipment 或 entitlement 大表。
// response_json 固化首个成功回包,使资源随后到期/下架也不会改变同 command_id 的重试语义。
_, err = db.ExecContext(ctx, `

View File

@ -61,11 +61,8 @@ func (r *Repository) GetVipState(ctx context.Context, userID int64) (ledger.VipS
return ledger.VipState{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
nowMS := time.Now().UnixMilli()
// 先清理失效装备指针,再用一个 repeatable-read 快照读取 program、会员、卡片和权益
// 防止后台改配置时返回“新 config_version + 旧 benefits”一类混合状态。
if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{userID}, []string{resourcedomain.TypeVIPTrialCard}, nowMS); err != nil {
return ledger.VipState{}, err
}
// 失效 trial-card equipment 由 Wallet 单写者后台物理清理;这里直接读取同一
// repeatable-read 快照,避免用户态查询产生 DELETE同时防止返回跨配置版本组合。
if err := r.ensureTieredVipResourceState(ctx, userID, nowMS); err != nil {
return ledger.VipState{}, err
}

View File

@ -31,9 +31,8 @@ func (r *Repository) ListVipPackages(ctx context.Context, userID int64) (ledger.
return ledger.VipState{}, nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
nowMS := time.Now().UnixMilli()
if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{userID}, []string{resourcedomain.TypeVIPTrialCard}, nowMS); err != nil {
return ledger.VipState{}, nil, err
}
// VIP 读取不再删除过期 trial-card equipment快照查询会忽略失效卡片
// 物理残留统一由 Wallet 单写者清理,避免读取套餐时与 appearance/gift-panel 争写锁。
if err := r.ensureTieredVipResourceState(ctx, userID, nowMS); err != nil {
return ledger.VipState{}, nil, err
}

View File

@ -1053,14 +1053,30 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
if policy.PlatformWithdrawalLimitPeriod == "" {
policy.PlatformWithdrawalLimitPeriod = "month"
}
legacyPointPolicy := policy.PolicyType == ledger.HostPolicyTypePointDiamond &&
policy.CoinSellerMinimumWithdrawUSDMinor == 0 &&
policy.PlatformMinimumWithdrawUSDMinor == 0
if policy.CoinSellerMinimumWithdrawUSDMinor <= 0 {
policy.CoinSellerMinimumWithdrawUSDMinor = policy.MinimumWithdrawUSDMinor
}
if policy.PlatformMinimumWithdrawUSDMinor <= 0 {
policy.PlatformMinimumWithdrawUSDMinor = policy.MinimumWithdrawUSDMinor
}
policy.MinimumWithdrawUSDMinor = policy.PlatformMinimumWithdrawUSDMinor
if legacyPointPolicy {
// 既有测试夹具创建于开关引入前,语义等同线上迁移默认开启;新关闭场景需显式填写两个渠道门槛。
policy.DiamondExchangeEnabled = true
}
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO host_agency_salary_policies (
app_code, policy_id, cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode, gift_coin_to_diamond_ratio,
point_diamonds_per_usd, coins_per_usd, minimum_withdraw_usd_minor, withdraw_fee_bps, agency_point_share_bps,
point_diamonds_per_usd, coins_per_usd, minimum_withdraw_usd_minor,
coin_seller_minimum_withdraw_usd_minor, platform_minimum_withdraw_usd_minor, diamond_exchange_enabled,
withdraw_fee_bps, agency_point_share_bps,
residual_diamond_to_usd_rate, coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
cycle_key = VALUES(cycle_key),
policy_version = VALUES(policy_version),
@ -1074,6 +1090,9 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
point_diamonds_per_usd = VALUES(point_diamonds_per_usd),
coins_per_usd = VALUES(coins_per_usd),
minimum_withdraw_usd_minor = VALUES(minimum_withdraw_usd_minor),
coin_seller_minimum_withdraw_usd_minor = VALUES(coin_seller_minimum_withdraw_usd_minor),
platform_minimum_withdraw_usd_minor = VALUES(platform_minimum_withdraw_usd_minor),
diamond_exchange_enabled = VALUES(diamond_exchange_enabled),
withdraw_fee_bps = VALUES(withdraw_fee_bps),
agency_point_share_bps = VALUES(agency_point_share_bps),
residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate),
@ -1086,7 +1105,9 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
effective_to_ms = VALUES(effective_to_ms),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, policy.PolicyID, policy.CycleKey, policy.PolicyVersion, policy.Name, policy.PolicyType, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio,
policy.PointDiamondsPerUSD, policy.CoinsPerUSD, policy.MinimumWithdrawUSDMinor, policy.WithdrawFeeBPS, policy.AgencyPointShareBPS, policy.ResidualDiamondToUSDRate,
policy.PointDiamondsPerUSD, policy.CoinsPerUSD, policy.MinimumWithdrawUSDMinor,
policy.CoinSellerMinimumWithdrawUSDMinor, policy.PlatformMinimumWithdrawUSDMinor, policy.DiamondExchangeEnabled,
policy.WithdrawFeeBPS, policy.AgencyPointShareBPS, policy.ResidualDiamondToUSDRate,
policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount,
policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount, policy.PlatformWithdrawalAllowedDays,
policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs)

View File

@ -79,25 +79,28 @@ func hostSalaryPolicyToProto(policy ledger.HostSalaryPolicy) *walletv1.HostSalar
levels = append(levels, hostSalaryPolicyLevelToProto(level))
}
return &walletv1.HostSalaryPolicy{
PolicyId: policy.PolicyID,
CycleKey: policy.CycleKey,
PolicyVersion: policy.PolicyVersion,
Name: policy.Name,
PolicyType: policy.PolicyType,
RegionId: policy.RegionID,
Status: policy.Status,
SettlementMode: policy.SettlementMode,
SettlementTriggerMode: policy.SettlementTriggerMode,
GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio,
PointDiamondsPerUsd: policy.PointDiamondsPerUSD,
CoinsPerUsd: policy.CoinsPerUSD,
MinimumWithdrawUsdMinor: policy.MinimumWithdrawUSDMinor,
WithdrawFeeBps: policy.WithdrawFeeBPS,
AgencyPointShareBps: policy.AgencyPointShareBPS,
ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate,
EffectiveFromMs: policy.EffectiveFromMs,
EffectiveToMs: policy.EffectiveToMs,
Levels: levels,
PolicyId: policy.PolicyID,
CycleKey: policy.CycleKey,
PolicyVersion: policy.PolicyVersion,
Name: policy.Name,
PolicyType: policy.PolicyType,
RegionId: policy.RegionID,
Status: policy.Status,
SettlementMode: policy.SettlementMode,
SettlementTriggerMode: policy.SettlementTriggerMode,
GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio,
PointDiamondsPerUsd: policy.PointDiamondsPerUSD,
CoinsPerUsd: policy.CoinsPerUSD,
MinimumWithdrawUsdMinor: policy.MinimumWithdrawUSDMinor,
CoinSellerMinimumWithdrawUsdMinor: policy.CoinSellerMinimumWithdrawUSDMinor,
PlatformMinimumWithdrawUsdMinor: policy.PlatformMinimumWithdrawUSDMinor,
DiamondExchangeEnabled: policy.DiamondExchangeEnabled,
WithdrawFeeBps: policy.WithdrawFeeBPS,
AgencyPointShareBps: policy.AgencyPointShareBPS,
ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate,
EffectiveFromMs: policy.EffectiveFromMs,
EffectiveToMs: policy.EffectiveToMs,
Levels: levels,
}
}

View File

@ -83,7 +83,10 @@ func (s *Server) GetPointWithdrawalConfig(ctx context.Context, req *walletv1.Get
Found: config.Found, PointsPerUsd: config.PointsPerUSD, CoinsPerUsd: config.CoinsPerUSD, FeeBps: int32(config.FeeBPS),
MinimumPoints: config.MinimumPoints, PolicyInstanceCode: config.PolicyInstanceCode,
PolicyType: config.PolicyType, MinimumWithdrawUsdMinor: config.MinimumWithdrawUSDMinor,
AgencyPointShareBps: config.AgencyPointShareBPS, PolicyId: config.PolicyID, PolicyVersion: config.PolicyVersion,
CoinSellerMinimumWithdrawUsdMinor: config.CoinSellerMinimumWithdrawUSDMinor,
PlatformMinimumWithdrawUsdMinor: config.PlatformMinimumWithdrawUSDMinor,
DiamondExchangeEnabled: &config.DiamondExchangeEnabled,
AgencyPointShareBps: config.AgencyPointShareBPS, PolicyId: config.PolicyID, PolicyVersion: config.PolicyVersion,
AvailabilityEvaluated: config.AvailabilityEvaluated,
CoinSellerAvailability: pointWithdrawalAvailabilityToProto(config.CoinSellerAvailability),
PlatformAvailability: pointWithdrawalAvailabilityToProto(config.PlatformAvailability),