黑名单,死锁问题解决
This commit is contained in:
parent
94a9824f68
commit
d3f29ae57e
File diff suppressed because it is too large
Load Diff
@ -98,6 +98,8 @@ message User {
|
|||||||
string profile_bg_img = 31;
|
string profile_bg_img = 31;
|
||||||
string contact_info = 32;
|
string contact_info = 32;
|
||||||
string withdraw_usdt_trc20_address = 33;
|
string withdraw_usdt_trc20_address = 33;
|
||||||
|
// content_blacklisted 只控制游戏列表和 Banner 等运营内容可见性,不改变登录、房间或账务状态。
|
||||||
|
bool content_blacklisted = 34;
|
||||||
}
|
}
|
||||||
|
|
||||||
// InviteOverview 是我的页可直接展示的邀请码和邀请计数 read model。
|
// InviteOverview 是我的页可直接展示的邀请码和邀请计数 read model。
|
||||||
@ -777,6 +779,56 @@ message AdminIssueUserAccessTokenResponse {
|
|||||||
int64 session_last_heartbeat_at_ms = 6;
|
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 是房间首屏只需要的用户展示基础资料。
|
// RoomBasicUser 是房间首屏只需要的用户展示基础资料。
|
||||||
message RoomBasicUser {
|
message RoomBasicUser {
|
||||||
int64 user_id = 1;
|
int64 user_id = 1;
|
||||||
@ -1548,6 +1600,9 @@ service UserService {
|
|||||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||||
rpc BatchGetUserAdminProfiles(BatchGetUserAdminProfilesRequest) returns (BatchGetUserAdminProfilesResponse);
|
rpc BatchGetUserAdminProfiles(BatchGetUserAdminProfilesRequest) returns (BatchGetUserAdminProfilesResponse);
|
||||||
rpc AdminIssueUserAccessToken(AdminIssueUserAccessTokenRequest) returns (AdminIssueUserAccessTokenResponse);
|
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 BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
|
||||||
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
||||||
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
||||||
|
|||||||
@ -19,33 +19,36 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
|
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
|
||||||
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
|
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
|
||||||
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
|
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
|
||||||
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
||||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||||
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
|
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
|
||||||
UserService_AdminIssueUserAccessToken_FullMethodName = "/hyapp.user.v1.UserService/AdminIssueUserAccessToken"
|
UserService_AdminIssueUserAccessToken_FullMethodName = "/hyapp.user.v1.UserService/AdminIssueUserAccessToken"
|
||||||
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
|
UserService_ListUserContentBlacklist_FullMethodName = "/hyapp.user.v1.UserService/ListUserContentBlacklist"
|
||||||
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
UserService_AdminAddUserContentBlacklist_FullMethodName = "/hyapp.user.v1.UserService/AdminAddUserContentBlacklist"
|
||||||
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
UserService_AdminRemoveUserContentBlacklist_FullMethodName = "/hyapp.user.v1.UserService/AdminRemoveUserContentBlacklist"
|
||||||
UserService_AuthorizeUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/AuthorizeUserAvatarUpload"
|
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
|
||||||
UserService_CompleteUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/CompleteUserAvatarUpload"
|
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
||||||
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
||||||
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
|
UserService_AuthorizeUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/AuthorizeUserAvatarUpload"
|
||||||
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
|
UserService_CompleteUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/CompleteUserAvatarUpload"
|
||||||
UserService_UpdateUserWithdrawAddress_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserWithdrawAddress"
|
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
||||||
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
|
||||||
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
|
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
|
||||||
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
|
UserService_UpdateUserWithdrawAddress_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserWithdrawAddress"
|
||||||
UserService_AdminBanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminBanUser"
|
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
||||||
UserService_AdminUnbanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminUnbanUser"
|
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
|
||||||
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
|
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
|
||||||
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
|
UserService_AdminBanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminBanUser"
|
||||||
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
|
UserService_AdminUnbanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminUnbanUser"
|
||||||
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
|
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
|
||||||
UserService_SearchInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/SearchInviteReferrer"
|
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
|
||||||
UserService_BindInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/BindInviteReferrer"
|
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.
|
// 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)
|
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
||||||
BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error)
|
BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error)
|
||||||
AdminIssueUserAccessToken(ctx context.Context, in *AdminIssueUserAccessTokenRequest, opts ...grpc.CallOption) (*AdminIssueUserAccessTokenResponse, 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)
|
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
|
||||||
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
||||||
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, 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
|
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) {
|
func (c *userServiceClient) BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(BatchGetRoomBasicUsersResponse)
|
out := new(BatchGetRoomBasicUsersResponse)
|
||||||
@ -374,6 +410,9 @@ type UserServiceServer interface {
|
|||||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||||
BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error)
|
BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error)
|
||||||
AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, 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)
|
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
|
||||||
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
||||||
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
||||||
@ -425,6 +464,15 @@ func (UnimplementedUserServiceServer) BatchGetUserAdminProfiles(context.Context,
|
|||||||
func (UnimplementedUserServiceServer) AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error) {
|
func (UnimplementedUserServiceServer) AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method AdminIssueUserAccessToken not implemented")
|
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) {
|
func (UnimplementedUserServiceServer) BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method BatchGetRoomBasicUsers not implemented")
|
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)
|
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) {
|
func _UserService_BatchGetRoomBasicUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(BatchGetRoomBasicUsersRequest)
|
in := new(BatchGetRoomBasicUsersRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -1027,6 +1129,18 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "AdminIssueUserAccessToken",
|
MethodName: "AdminIssueUserAccessToken",
|
||||||
Handler: _UserService_AdminIssueUserAccessToken_Handler,
|
Handler: _UserService_AdminIssueUserAccessToken_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListUserContentBlacklist",
|
||||||
|
Handler: _UserService_ListUserContentBlacklist_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AdminAddUserContentBlacklist",
|
||||||
|
Handler: _UserService_AdminAddUserContentBlacklist_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AdminRemoveUserContentBlacklist",
|
||||||
|
Handler: _UserService_AdminRemoveUserContentBlacklist_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "BatchGetRoomBasicUsers",
|
MethodName: "BatchGetRoomBasicUsers",
|
||||||
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
|
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
|
||||||
|
|||||||
@ -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"`
|
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"`
|
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"`
|
AgencyPointShareBps int32 `protobuf:"varint,19,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// 两个渠道的最低金额分别进入各自资金事务;旧 minimum_withdraw_usd_minor 仅保留为平台金额兼容别名。
|
||||||
sizeCache protoimpl.SizeCache
|
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() {
|
func (x *HostSalaryPolicy) Reset() {
|
||||||
@ -1669,6 +1674,27 @@ func (x *HostSalaryPolicy) GetAgencyPointShareBps() int32 {
|
|||||||
return 0
|
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 {
|
type GetActiveHostSalaryPolicyRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
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"`
|
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"`
|
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 展示比例。
|
// 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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
PlatformAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,14,opt,name=platform_availability,json=platformAvailability,proto3" json:"platform_availability,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
CoinSellerMinimumWithdrawUsdMinor int64 `protobuf:"varint,15,opt,name=coin_seller_minimum_withdraw_usd_minor,json=coinSellerMinimumWithdrawUsdMinor,proto3" json:"coin_seller_minimum_withdraw_usd_minor,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
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() {
|
func (x *GetPointWithdrawalConfigResponse) Reset() {
|
||||||
@ -27068,6 +27098,27 @@ func (x *GetPointWithdrawalConfigResponse) GetPlatformAvailability() *PointWithd
|
|||||||
return nil
|
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 在多图、动态资源间切换,
|
// VipBenefitPreviewItem 是同一权益下的一项预览内容;独立稳定键允许 Flutter 在多图、动态资源间切换,
|
||||||
// 且不会把数组下标误当成资源身份。
|
// 且不会把数组下标误当成资源身份。
|
||||||
type VipBenefitPreviewItem struct {
|
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" +
|
"\x17agency_salary_usd_minor\x18\x05 \x01(\x03R\x14agencySalaryUsdMinor\x12\x16\n" +
|
||||||
"\x06status\x18\x06 \x01(\tR\x06status\x12\x1d\n" +
|
"\x06status\x18\x06 \x01(\tR\x06status\x12\x1d\n" +
|
||||||
"\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" +
|
"\x10HostSalaryPolicy\x12\x1b\n" +
|
||||||
"\tpolicy_id\x18\x01 \x01(\x04R\bpolicyId\x12\x12\n" +
|
"\tpolicy_id\x18\x01 \x01(\x04R\bpolicyId\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\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" +
|
"\rcoins_per_usd\x18\x10 \x01(\x03R\vcoinsPerUsd\x12;\n" +
|
||||||
"\x1aminimum_withdraw_usd_minor\x18\x11 \x01(\x03R\x17minimumWithdrawUsdMinor\x12(\n" +
|
"\x1aminimum_withdraw_usd_minor\x18\x11 \x01(\x03R\x17minimumWithdrawUsdMinor\x12(\n" +
|
||||||
"\x10withdraw_fee_bps\x18\x12 \x01(\x05R\x0ewithdrawFeeBps\x123\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" +
|
" GetActiveHostSalaryPolicyRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\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" +
|
"used_count\x18\x04 \x01(\x03R\tusedCount\x12'\n" +
|
||||||
"\x0fremaining_count\x18\x05 \x01(\x03R\x0eremainingCount\x12!\n" +
|
"\x0fremaining_count\x18\x05 \x01(\x03R\x0eremainingCount\x12!\n" +
|
||||||
"\fallowed_days\x18\x06 \x01(\tR\vallowedDays\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" +
|
" GetPointWithdrawalConfigResponse\x12\x14\n" +
|
||||||
"\x05found\x18\x01 \x01(\bR\x05found\x12$\n" +
|
"\x05found\x18\x01 \x01(\bR\x05found\x12$\n" +
|
||||||
"\x0epoints_per_usd\x18\x02 \x01(\x03R\fpointsPerUsd\x12\x17\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" +
|
"\x0epolicy_version\x18\v \x01(\x04R\rpolicyVersion\x125\n" +
|
||||||
"\x16availability_evaluated\x18\f \x01(\bR\x15availabilityEvaluated\x12l\n" +
|
"\x16availability_evaluated\x18\f \x01(\bR\x15availabilityEvaluated\x12l\n" +
|
||||||
"\x18coin_seller_availability\x18\r \x01(\v22.hyapp.wallet.v1.PointWithdrawalActionAvailabilityR\x16coinSellerAvailability\x12g\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" +
|
"\x15VipBenefitPreviewItem\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"preview_id\x18\x01 \x01(\tR\tpreviewId\x12\x14\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[153].OneofWrappers = []any{}
|
||||||
file_proto_wallet_v1_wallet_proto_msgTypes[219].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[231].OneofWrappers = []any{}
|
||||||
|
file_proto_wallet_v1_wallet_proto_msgTypes[298].OneofWrappers = []any{}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
|
|||||||
@ -223,6 +223,11 @@ message HostSalaryPolicy {
|
|||||||
int64 minimum_withdraw_usd_minor = 17;
|
int64 minimum_withdraw_usd_minor = 17;
|
||||||
int32 withdraw_fee_bps = 18;
|
int32 withdraw_fee_bps = 18;
|
||||||
int32 agency_point_share_bps = 19;
|
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 {
|
message GetActiveHostSalaryPolicyRequest {
|
||||||
@ -3143,6 +3148,10 @@ message GetPointWithdrawalConfigResponse {
|
|||||||
bool availability_evaluated = 12;
|
bool availability_evaluated = 12;
|
||||||
PointWithdrawalActionAvailability coin_seller_availability = 13;
|
PointWithdrawalActionAvailability coin_seller_availability = 13;
|
||||||
PointWithdrawalActionAvailability platform_availability = 14;
|
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 在多图、动态资源间切换,
|
// VipBenefitPreviewItem 是同一权益下的一项预览内容;独立稳定键允许 Flutter 在多图、动态资源间切换,
|
||||||
|
|||||||
@ -92,9 +92,16 @@ var catalog = map[Code]Spec{
|
|||||||
CPAlreadyExistsSelf: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsSelf, "You already have a CP"),
|
CPAlreadyExistsSelf: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsSelf, "You already have a CP"),
|
||||||
CPAlreadyExistsOther: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsOther, "The other party already has a CP"),
|
CPAlreadyExistsOther: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsOther, "The other party already has a CP"),
|
||||||
|
|
||||||
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
||||||
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
|
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
|
||||||
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "wallet state changed, please retry"),
|
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"),
|
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "coin seller is not active"),
|
||||||
CoinSellerStockTypeInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockTypeInvalid, "invalid argument"),
|
CoinSellerStockTypeInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockTypeInvalid, "invalid argument"),
|
||||||
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
|
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
|
||||||
|
|||||||
@ -95,6 +95,8 @@ const (
|
|||||||
DuplicateBillingCommand Code = "DUPLICATE_BILLING_COMMAND"
|
DuplicateBillingCommand Code = "DUPLICATE_BILLING_COMMAND"
|
||||||
// LedgerConflict 表示账本并发写入或余额版本冲突。
|
// LedgerConflict 表示账本并发写入或余额版本冲突。
|
||||||
LedgerConflict Code = "LEDGER_CONFLICT"
|
LedgerConflict Code = "LEDGER_CONFLICT"
|
||||||
|
// WalletResourceReadTransient 表示钱包资源读模型遇到可安全重试的瞬时数据库冲突。
|
||||||
|
WalletResourceReadTransient Code = "WALLET_RESOURCE_READ_TRANSIENT"
|
||||||
// CoinSellerNotActive 表示目标用户不存在 active 币商身份。
|
// CoinSellerNotActive 表示目标用户不存在 active 币商身份。
|
||||||
CoinSellerNotActive Code = "COIN_SELLER_NOT_ACTIVE"
|
CoinSellerNotActive Code = "COIN_SELLER_NOT_ACTIVE"
|
||||||
// CoinSellerStockTypeInvalid 表示币商库存入账类型不属于支持范围。
|
// CoinSellerStockTypeInvalid 表示币商库存入账类型不属于支持范围。
|
||||||
|
|||||||
@ -217,6 +217,7 @@ type UserIdentity struct {
|
|||||||
type User struct {
|
type User struct {
|
||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
ContentBlacklisted bool `json:"contentBlacklisted"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
DisplayUserID string `json:"displayUserId"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
@ -438,6 +439,20 @@ func (c *GRPCClient) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanU
|
|||||||
return c.client.AdminUnbanUser(ctx, req)
|
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 {
|
func userStatusToProto(status string) userv1.UserStatus {
|
||||||
switch status {
|
switch status {
|
||||||
case "active":
|
case "active":
|
||||||
@ -494,6 +509,7 @@ func fromProtoUser(user *userv1.User) *User {
|
|||||||
return &User{
|
return &User{
|
||||||
UserID: user.GetUserId(),
|
UserID: user.GetUserId(),
|
||||||
Status: fromProtoStatus(user.GetStatus()),
|
Status: fromProtoStatus(user.GetStatus()),
|
||||||
|
ContentBlacklisted: user.GetContentBlacklisted(),
|
||||||
CreatedAtMs: user.GetCreatedAtMs(),
|
CreatedAtMs: user.GetCreatedAtMs(),
|
||||||
UpdatedAtMs: user.GetUpdatedAtMs(),
|
UpdatedAtMs: user.GetUpdatedAtMs(),
|
||||||
DisplayUserID: user.GetDisplayUserId(),
|
DisplayUserID: user.GetDisplayUserId(),
|
||||||
|
|||||||
@ -454,13 +454,17 @@ type HostAgencySalaryPolicy struct {
|
|||||||
// SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。
|
// SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。
|
||||||
SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"`
|
SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"`
|
||||||
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
|
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
|
||||||
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
|
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"`
|
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"`
|
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"`
|
// MinimumWithdrawUSDMinor 保留为旧客户端平台门槛别名;新配置和运行校验必须使用下面两个渠道字段。
|
||||||
WithdrawFeeBPS int32 `gorm:"column:withdraw_fee_bps;not null;default:0" json:"withdrawFeeBps"`
|
MinimumWithdrawUSDMinor int64 `gorm:"column:minimum_withdraw_usd_minor;not null;default:0" json:"minimumWithdrawUsdMinor"`
|
||||||
AgencyPointShareBPS int32 `gorm:"column:agency_point_share_bps;not null;default:0" json:"agencyPointShareBps"`
|
CoinSellerMinimumWithdrawUSDMinor int64 `gorm:"column:coin_seller_minimum_withdraw_usd_minor;not null;default:0" json:"coinSellerMinimumWithdrawUsdMinor"`
|
||||||
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
|
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。
|
// 提现次数限制随月度政策形成不可变快照;count=0 表示不限,period 只允许 day/week/month。
|
||||||
CoinSellerWithdrawalLimitPeriod string `gorm:"column:coin_seller_withdrawal_limit_period;size:16;not null;default:month" json:"coinSellerWithdrawalLimitPeriod"`
|
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"`
|
CoinSellerWithdrawalLimitCount int64 `gorm:"column:coin_seller_withdrawal_limit_count;not null;default:0" json:"coinSellerWithdrawalLimitCount"`
|
||||||
|
|||||||
209
server/admin/internal/modules/appuser/content_blacklist.go
Normal file
209
server/admin/internal/modules/appuser/content_blacklist.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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})
|
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) {
|
func (h *Handler) ListUserLoginLogs(c *gin.Context) {
|
||||||
userID, ok := parseInt64ID(c, "id")
|
userID, ok := parseInt64ID(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@ -15,6 +15,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
protected.POST("/exports/app-users", middleware.RequirePermission("app-user:export"), h.CreateExport)
|
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/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/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/login-logs", middleware.RequirePermission("app-user:view"), h.ListUserLoginLogs)
|
||||||
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)
|
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)
|
||||||
protected.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), h.UpdateUser)
|
protected.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), h.UpdateUser)
|
||||||
|
|||||||
@ -31,6 +31,8 @@ var (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
userClient userclient.Client
|
userClient userclient.Client
|
||||||
userAdminClient userAdminProjectionClient
|
userAdminClient userAdminProjectionClient
|
||||||
|
// contentBlacklistClient 只开放 user-service 的内容屏蔽治理契约;admin 不直连用户库维护第二份状态。
|
||||||
|
contentBlacklistClient userContentBlacklistAdminClient
|
||||||
// tokenIssuerClient 只在 gRPC client 具备后台重签 token 能力时非 nil。
|
// tokenIssuerClient 只在 gRPC client 具备后台重签 token 能力时非 nil。
|
||||||
tokenIssuerClient accessTokenIssuerClient
|
tokenIssuerClient accessTokenIssuerClient
|
||||||
activityClient activityclient.Client
|
activityClient activityclient.Client
|
||||||
@ -55,6 +57,14 @@ type userAdminProjectionClient interface {
|
|||||||
AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error)
|
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 和一次原子调整,游戏等级没有写入口。
|
// levelAdminProjectionClient 只允许后台读 overlay 和一次原子调整,游戏等级没有写入口。
|
||||||
type levelAdminProjectionClient interface {
|
type levelAdminProjectionClient interface {
|
||||||
BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error)
|
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 {
|
if adminClient, ok := any(userClient).(userAdminProjectionClient); ok {
|
||||||
service.userAdminClient = adminClient
|
service.userAdminClient = adminClient
|
||||||
}
|
}
|
||||||
|
if blacklistClient, ok := any(userClient).(userContentBlacklistAdminClient); ok {
|
||||||
|
service.contentBlacklistClient = blacklistClient
|
||||||
|
}
|
||||||
if tokenClient, ok := any(userClient).(accessTokenIssuerClient); ok {
|
if tokenClient, ok := any(userClient).(accessTokenIssuerClient); ok {
|
||||||
service.tokenIssuerClient = tokenClient
|
service.tokenIssuerClient = tokenClient
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,32 @@
|
|||||||
package hostagencypolicy
|
package hostagencypolicy
|
||||||
|
|
||||||
type policyRequest struct {
|
type policyRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PolicyType string `json:"policy_type"`
|
PolicyType string `json:"policy_type"`
|
||||||
RegionID int64 `json:"region_id"`
|
RegionID int64 `json:"region_id"`
|
||||||
RegionIDs []int64 `json:"region_ids"`
|
RegionIDs []int64 `json:"region_ids"`
|
||||||
CycleKey string `json:"cycle_key"`
|
CycleKey string `json:"cycle_key"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SettlementMode string `json:"settlement_mode"`
|
SettlementMode string `json:"settlement_mode"`
|
||||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||||
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
||||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||||
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
// MinimumWithdrawUSDMinor 只兼容旧后台;新后台必须分别提交两个渠道金额。
|
||||||
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
||||||
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
CoinSellerMinimumWithdrawUSDMinor int64 `json:"coin_seller_minimum_withdraw_usd_minor"`
|
||||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
PlatformMinimumWithdrawUSDMinor int64 `json:"platform_minimum_withdraw_usd_minor"`
|
||||||
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
DiamondExchangeEnabled *bool `json:"diamond_exchange_enabled"`
|
||||||
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
||||||
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
|
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
||||||
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
|
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||||
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
|
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
||||||
Description string `json:"description"`
|
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
||||||
Levels []levelRequest `json:"levels"`
|
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 {
|
type levelRequest struct {
|
||||||
|
|||||||
@ -7,42 +7,45 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type policyDTO struct {
|
type policyDTO struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PolicyType string `json:"policy_type"`
|
PolicyType string `json:"policy_type"`
|
||||||
RegionID int64 `json:"region_id"`
|
RegionID int64 `json:"region_id"`
|
||||||
RegionIDs []int64 `json:"region_ids"`
|
RegionIDs []int64 `json:"region_ids"`
|
||||||
CycleKey string `json:"cycle_key"`
|
CycleKey string `json:"cycle_key"`
|
||||||
PolicyVersion uint64 `json:"policy_version"`
|
PolicyVersion uint64 `json:"policy_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SettlementMode string `json:"settlement_mode"`
|
SettlementMode string `json:"settlement_mode"`
|
||||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||||
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
||||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||||
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
||||||
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
CoinSellerMinimumWithdrawUSDMinor int64 `json:"coin_seller_minimum_withdraw_usd_minor"`
|
||||||
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
PlatformMinimumWithdrawUSDMinor int64 `json:"platform_minimum_withdraw_usd_minor"`
|
||||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
|
||||||
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
||||||
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
||||||
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
|
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||||
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
|
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
||||||
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
|
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
||||||
Description string `json:"description"`
|
PlatformWithdrawalLimitPeriod string `json:"platform_withdrawal_limit_period"`
|
||||||
CreatedByAdminID uint `json:"created_by_admin_id"`
|
PlatformWithdrawalLimitCount int64 `json:"platform_withdrawal_limit_count"`
|
||||||
UpdatedByAdminID uint `json:"updated_by_admin_id"`
|
PlatformWithdrawalAllowedDays []int32 `json:"platform_withdrawal_allowed_days"`
|
||||||
PublishStatus string `json:"publish_status"`
|
Description string `json:"description"`
|
||||||
PublishError string `json:"publish_error"`
|
CreatedByAdminID uint `json:"created_by_admin_id"`
|
||||||
PublishedAtMS int64 `json:"published_at_ms"`
|
UpdatedByAdminID uint `json:"updated_by_admin_id"`
|
||||||
PublishedByAdminID uint `json:"published_by_admin_id"`
|
PublishStatus string `json:"publish_status"`
|
||||||
CopiedFromPolicyID uint `json:"copied_from_policy_id"`
|
PublishError string `json:"publish_error"`
|
||||||
CopiedFromCycleKey string `json:"copied_from_cycle_key"`
|
PublishedAtMS int64 `json:"published_at_ms"`
|
||||||
Inherited bool `json:"inherited"`
|
PublishedByAdminID uint `json:"published_by_admin_id"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
CopiedFromPolicyID uint `json:"copied_from_policy_id"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
CopiedFromCycleKey string `json:"copied_from_cycle_key"`
|
||||||
Levels []levelDTO `json:"levels"`
|
Inherited bool `json:"inherited"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
Levels []levelDTO `json:"levels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type levelDTO struct {
|
type levelDTO struct {
|
||||||
@ -61,48 +64,53 @@ type levelDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
|
func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
|
||||||
|
coinSellerMinimum, platformMinimum := resolvedPolicyWithdrawalMinimums(item)
|
||||||
levels := make([]levelDTO, 0, len(item.Levels))
|
levels := make([]levelDTO, 0, len(item.Levels))
|
||||||
for _, level := range item.Levels {
|
for _, level := range item.Levels {
|
||||||
levels = append(levels, levelFromModel(level))
|
levels = append(levels, levelFromModel(level))
|
||||||
}
|
}
|
||||||
// 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。
|
// 响应层只做展示友好的格式化:数据库 decimal 保留固定精度,接口去掉无意义尾零。
|
||||||
return policyDTO{
|
return policyDTO{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
AppCode: item.AppCode,
|
AppCode: item.AppCode,
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
PolicyType: normalizePolicyType(item.PolicyType),
|
PolicyType: normalizePolicyType(item.PolicyType),
|
||||||
RegionID: item.RegionID,
|
RegionID: item.RegionID,
|
||||||
RegionIDs: responseRegionIDs(item),
|
RegionIDs: responseRegionIDs(item),
|
||||||
CycleKey: item.CycleKey,
|
CycleKey: item.CycleKey,
|
||||||
PolicyVersion: item.PolicyVersion,
|
PolicyVersion: item.PolicyVersion,
|
||||||
Status: item.Status,
|
Status: item.Status,
|
||||||
SettlementMode: item.SettlementMode,
|
SettlementMode: item.SettlementMode,
|
||||||
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||||
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
|
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
|
||||||
PointDiamondsPerUSD: item.PointDiamondsPerUSD,
|
PointDiamondsPerUSD: item.PointDiamondsPerUSD,
|
||||||
CoinsPerUSD: item.CoinsPerUSD,
|
CoinsPerUSD: item.CoinsPerUSD,
|
||||||
MinimumWithdrawUSDMinor: item.MinimumWithdrawUSDMinor,
|
// 旧字段继续返回平台提现门槛,旧客户端不会因拆分字段而改变原展示和提交口径。
|
||||||
WithdrawFeeBPS: item.WithdrawFeeBPS,
|
MinimumWithdrawUSDMinor: platformMinimum,
|
||||||
AgencyPointShareBPS: item.AgencyPointShareBPS,
|
CoinSellerMinimumWithdrawUSDMinor: coinSellerMinimum,
|
||||||
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
|
PlatformMinimumWithdrawUSDMinor: platformMinimum,
|
||||||
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
DiamondExchangeEnabled: item.DiamondExchangeEnabled,
|
||||||
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
|
WithdrawFeeBPS: item.WithdrawFeeBPS,
|
||||||
PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
AgencyPointShareBPS: item.AgencyPointShareBPS,
|
||||||
PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount,
|
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
|
||||||
PlatformWithdrawalAllowedDays: parseWithdrawalDays(item.PlatformWithdrawalAllowedDays),
|
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
||||||
Description: item.Description,
|
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
|
||||||
CreatedByAdminID: item.CreatedByAdminID,
|
PlatformWithdrawalLimitPeriod: firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
||||||
UpdatedByAdminID: item.UpdatedByAdminID,
|
PlatformWithdrawalLimitCount: item.PlatformWithdrawalLimitCount,
|
||||||
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
|
PlatformWithdrawalAllowedDays: parseWithdrawalDays(item.PlatformWithdrawalAllowedDays),
|
||||||
PublishError: item.PublishError,
|
Description: item.Description,
|
||||||
PublishedAtMS: item.PublishedAtMS,
|
CreatedByAdminID: item.CreatedByAdminID,
|
||||||
PublishedByAdminID: item.PublishedByAdminID,
|
UpdatedByAdminID: item.UpdatedByAdminID,
|
||||||
CopiedFromPolicyID: item.CopiedFromPolicyID,
|
PublishStatus: firstNonBlank(item.PublishStatus, publishStatusDraft),
|
||||||
CopiedFromCycleKey: item.CopiedFromCycleKey,
|
PublishError: item.PublishError,
|
||||||
Inherited: item.CopiedFromPolicyID > 0 && item.CopiedFromCycleKey != "",
|
PublishedAtMS: item.PublishedAtMS,
|
||||||
CreatedAtMS: item.CreatedAtMS,
|
PublishedByAdminID: item.PublishedByAdminID,
|
||||||
UpdatedAtMS: item.UpdatedAtMS,
|
CopiedFromPolicyID: item.CopiedFromPolicyID,
|
||||||
Levels: levels,
|
CopiedFromCycleKey: item.CopiedFromCycleKey,
|
||||||
|
Inherited: item.CopiedFromPolicyID > 0 && item.CopiedFromCycleKey != "",
|
||||||
|
CreatedAtMS: item.CreatedAtMS,
|
||||||
|
UpdatedAtMS: item.UpdatedAtMS,
|
||||||
|
Levels: levels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -211,6 +211,10 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
|
|||||||
// Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。
|
// Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。
|
||||||
req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType)
|
req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType)
|
||||||
}
|
}
|
||||||
|
if req.DiamondExchangeEnabled == nil {
|
||||||
|
// PUT 仍兼容尚未提交新开关的旧后台:更新已关闭的政策时保留原值,不能因字段缺失自动重新开启。
|
||||||
|
req.DiamondExchangeEnabled = boolPointer(item.DiamondExchangeEnabled)
|
||||||
|
}
|
||||||
// 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。
|
// 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。
|
||||||
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
|
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
|
||||||
if err != nil {
|
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.PointDiamondsPerUSD = updated.PointDiamondsPerUSD
|
||||||
item.CoinsPerUSD = updated.CoinsPerUSD
|
item.CoinsPerUSD = updated.CoinsPerUSD
|
||||||
item.MinimumWithdrawUSDMinor = updated.MinimumWithdrawUSDMinor
|
item.MinimumWithdrawUSDMinor = updated.MinimumWithdrawUSDMinor
|
||||||
|
item.CoinSellerMinimumWithdrawUSDMinor = updated.CoinSellerMinimumWithdrawUSDMinor
|
||||||
|
item.PlatformMinimumWithdrawUSDMinor = updated.PlatformMinimumWithdrawUSDMinor
|
||||||
|
item.DiamondExchangeEnabled = updated.DiamondExchangeEnabled
|
||||||
item.WithdrawFeeBPS = updated.WithdrawFeeBPS
|
item.WithdrawFeeBPS = updated.WithdrawFeeBPS
|
||||||
item.AgencyPointShareBPS = updated.AgencyPointShareBPS
|
item.AgencyPointShareBPS = updated.AgencyPointShareBPS
|
||||||
item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
|
item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
|
||||||
@ -443,26 +450,29 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
type runtimeHostSalaryPolicySnapshot struct {
|
type runtimeHostSalaryPolicySnapshot struct {
|
||||||
CycleKey string
|
CycleKey string
|
||||||
PolicyVersion uint64
|
PolicyVersion uint64
|
||||||
Name string
|
Name string
|
||||||
PolicyType string
|
PolicyType string
|
||||||
RegionID int64
|
RegionID int64
|
||||||
Status string
|
Status string
|
||||||
SettlementMode string
|
SettlementMode string
|
||||||
SettlementTriggerMode string
|
SettlementTriggerMode string
|
||||||
GiftCoinToDiamondRatio string
|
GiftCoinToDiamondRatio string
|
||||||
PointDiamondsPerUSD int64
|
PointDiamondsPerUSD int64
|
||||||
CoinsPerUSD int64
|
CoinsPerUSD int64
|
||||||
MinimumWithdrawUSDMinor int64
|
MinimumWithdrawUSDMinor int64
|
||||||
WithdrawFeeBPS int32
|
CoinSellerMinimumWithdrawUSDMinor int64
|
||||||
AgencyPointShareBPS int32
|
PlatformMinimumWithdrawUSDMinor int64
|
||||||
ResidualDiamondToUSDRate string
|
DiamondExchangeEnabled bool
|
||||||
CoinSellerWithdrawalLimitPeriod string
|
WithdrawFeeBPS int32
|
||||||
CoinSellerWithdrawalLimitCount int64
|
AgencyPointShareBPS int32
|
||||||
PlatformWithdrawalLimitPeriod string
|
ResidualDiamondToUSDRate string
|
||||||
PlatformWithdrawalLimitCount int64
|
CoinSellerWithdrawalLimitPeriod string
|
||||||
PlatformWithdrawalAllowedDays string
|
CoinSellerWithdrawalLimitCount int64
|
||||||
|
PlatformWithdrawalLimitPeriod string
|
||||||
|
PlatformWithdrawalLimitCount int64
|
||||||
|
PlatformWithdrawalAllowedDays string
|
||||||
}
|
}
|
||||||
|
|
||||||
type runtimeHostSalaryLevelSnapshot struct {
|
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 {
|
func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error {
|
||||||
|
coinSellerMinimum, platformMinimum := resolvedPolicyWithdrawalMinimums(item)
|
||||||
// 先以完整快照尝试原子占位;复合主键包含 policy_version,已存在时只做自赋值,绝不改写业务字段。
|
// 先以完整快照尝试原子占位;复合主键包含 policy_version,已存在时只做自赋值,绝不改写业务字段。
|
||||||
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
|
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO host_agency_salary_policies (
|
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,
|
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,
|
residual_diamond_to_usd_rate,
|
||||||
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
|
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
|
||||||
created_at_ms, updated_at_ms
|
created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id)`,
|
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.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, normalizePolicyType(item.PolicyType), item.RegionID, item.Status,
|
||||||
item.SettlementMode, firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
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,
|
item.ResidualDiamondToUSDRate,
|
||||||
firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
|
firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
|
||||||
firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
|
firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
|
||||||
@ -504,7 +518,8 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
|
|||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
SELECT cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
|
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,
|
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,
|
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
|
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
|
||||||
FROM host_agency_salary_policies
|
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.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status,
|
||||||
&existing.SettlementMode, &existing.SettlementTriggerMode,
|
&existing.SettlementMode, &existing.SettlementTriggerMode,
|
||||||
&existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD,
|
&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.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount,
|
||||||
&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
|
&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
|
||||||
&existing.PlatformWithdrawalAllowedDays,
|
&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 {
|
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 &&
|
return existing.CycleKey == item.CycleKey && existing.PolicyVersion == policyVersion && existing.Name == item.Name &&
|
||||||
existing.PolicyType == normalizePolicyType(item.PolicyType) &&
|
existing.PolicyType == normalizePolicyType(item.PolicyType) &&
|
||||||
existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
|
existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
|
||||||
existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
|
existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
|
||||||
trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
|
trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
|
||||||
existing.PointDiamondsPerUSD == item.PointDiamondsPerUSD && existing.CoinsPerUSD == item.CoinsPerUSD &&
|
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 &&
|
existing.AgencyPointShareBPS == item.AgencyPointShareBPS &&
|
||||||
trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
|
trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
|
||||||
existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
|
existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
|
||||||
@ -755,7 +781,20 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
|||||||
}
|
}
|
||||||
pointDiamondsPerUSD := req.PointDiamondsPerUSD
|
pointDiamondsPerUSD := req.PointDiamondsPerUSD
|
||||||
coinsPerUSD := req.CoinsPerUSD
|
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
|
withdrawFeeBPS := req.WithdrawFeeBPS
|
||||||
agencyPointShareBPS := req.AgencyPointShareBPS
|
agencyPointShareBPS := req.AgencyPointShareBPS
|
||||||
if policyType == policyTypeSalaryDiamond {
|
if policyType == policyTypeSalaryDiamond {
|
||||||
@ -763,15 +802,18 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
|||||||
pointDiamondsPerUSD = 0
|
pointDiamondsPerUSD = 0
|
||||||
coinsPerUSD = 0
|
coinsPerUSD = 0
|
||||||
minimumWithdrawUSDMinor = 0
|
minimumWithdrawUSDMinor = 0
|
||||||
|
coinSellerMinimumWithdrawUSDMinor = 0
|
||||||
|
platformMinimumWithdrawUSDMinor = 0
|
||||||
|
diamondExchangeEnabled = false
|
||||||
withdrawFeeBPS = 0
|
withdrawFeeBPS = 0
|
||||||
agencyPointShareBPS = 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("钻石积分兑换或提现配置不正确")
|
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 的金额入口全部读取这份月度不可变快照;缺少任一比例时必须拒绝发布前保存,不能回退代码常量。
|
// POINT_DIAMOND 的金额入口全部读取这份月度不可变快照;缺少任一比例时必须拒绝发布前保存,不能回退代码常量。
|
||||||
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策必须配置每美元钻石、每美元金币和最低提现金额")
|
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策必须配置每美元钻石、每美元金币、币商最低提现金额和平台最低提现金额")
|
||||||
}
|
}
|
||||||
if policyType == policyTypePointDiamond && pointDiamondsPerUSD%100 != 0 {
|
if policyType == policyTypePointDiamond && pointDiamondsPerUSD%100 != 0 {
|
||||||
// HTTP 只接受美元分整数;比例必须能精确映射每一美分,禁止依赖向下取整吞掉用户积分。
|
// HTTP 只接受美元分整数;比例必须能精确映射每一美分,禁止依赖向下取整吞掉用户积分。
|
||||||
@ -821,31 +863,52 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
|||||||
Name: name,
|
Name: name,
|
||||||
PolicyType: policyType,
|
PolicyType: policyType,
|
||||||
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
|
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
|
||||||
RegionID: regionIDs[0],
|
RegionID: regionIDs[0],
|
||||||
RegionIDs: regionIDs,
|
RegionIDs: regionIDs,
|
||||||
CycleKey: cycleKey,
|
CycleKey: cycleKey,
|
||||||
Status: status,
|
Status: status,
|
||||||
SettlementMode: settlementMode,
|
SettlementMode: settlementMode,
|
||||||
SettlementTriggerMode: settlementTriggerMode,
|
SettlementTriggerMode: settlementTriggerMode,
|
||||||
GiftCoinToDiamondRatio: giftRatio,
|
GiftCoinToDiamondRatio: giftRatio,
|
||||||
PointDiamondsPerUSD: pointDiamondsPerUSD,
|
PointDiamondsPerUSD: pointDiamondsPerUSD,
|
||||||
CoinsPerUSD: coinsPerUSD,
|
CoinsPerUSD: coinsPerUSD,
|
||||||
MinimumWithdrawUSDMinor: minimumWithdrawUSDMinor,
|
MinimumWithdrawUSDMinor: minimumWithdrawUSDMinor,
|
||||||
WithdrawFeeBPS: withdrawFeeBPS,
|
CoinSellerMinimumWithdrawUSDMinor: coinSellerMinimumWithdrawUSDMinor,
|
||||||
AgencyPointShareBPS: agencyPointShareBPS,
|
PlatformMinimumWithdrawUSDMinor: platformMinimumWithdrawUSDMinor,
|
||||||
ResidualDiamondToUSDRate: residualRate,
|
DiamondExchangeEnabled: diamondExchangeEnabled,
|
||||||
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
|
WithdrawFeeBPS: withdrawFeeBPS,
|
||||||
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
|
AgencyPointShareBPS: agencyPointShareBPS,
|
||||||
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
|
ResidualDiamondToUSDRate: residualRate,
|
||||||
PlatformWithdrawalLimitCount: platformLimitCount,
|
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
|
||||||
PlatformWithdrawalAllowedDays: platformAllowedDays,
|
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
|
||||||
Description: description,
|
PlatformWithdrawalLimitPeriod: platformLimitPeriod,
|
||||||
CreatedByAdminID: actorID,
|
PlatformWithdrawalLimitCount: platformLimitCount,
|
||||||
UpdatedByAdminID: actorID,
|
PlatformWithdrawalAllowedDays: platformAllowedDays,
|
||||||
Levels: levels,
|
Description: description,
|
||||||
|
CreatedByAdminID: actorID,
|
||||||
|
UpdatedByAdminID: actorID,
|
||||||
|
Levels: levels,
|
||||||
}, nil
|
}, 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 {
|
func normalizePolicyType(value string) string {
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@ -245,9 +245,11 @@ func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t
|
|||||||
mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
|
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{
|
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",
|
"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",
|
"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{
|
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",
|
"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))
|
}).AddRow(1, 100, 150, 20, 50, "active", 1))
|
||||||
|
|||||||
@ -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: &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-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-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-content-blacklist", Path: "/app/users/content-blacklist", Icon: "visibility_off", 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-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user: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-level-config", Path: "/app/users/level-config", Icon: "military_tech", PermissionCode: "level-config: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-pretty-ids", Path: "/app/users/pretty-ids", Icon: "tag", PermissionCode: "pretty-id: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-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-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-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},
|
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -179,6 +179,11 @@ func (h *Handler) applyAuthenticatedBannerAudience(writer http.ResponseWriter, r
|
|||||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||||
return false
|
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 只做老版本参数兼容,不参与裁决。
|
// 已登录用户的区域由后端用户资料覆盖;客户端保留 region_id/country 只做老版本参数兼容,不参与裁决。
|
||||||
query.RegionID = user.GetRegionId()
|
query.RegionID = user.GetRegionId()
|
||||||
|
|||||||
@ -44,12 +44,22 @@ func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
|
|||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
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{
|
resp, err := h.gameClient.ListGames(request.Context(), &gamev1.ListGamesRequest{
|
||||||
Meta: gameMeta(request),
|
Meta: gameMeta(request),
|
||||||
UserId: userID,
|
UserId: userID,
|
||||||
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
|
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
|
||||||
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
|
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
|
||||||
RegionId: userResp.GetUser().GetRegionId(),
|
RegionId: user.GetRegionId(),
|
||||||
Language: requestLanguage(request),
|
Language: requestLanguage(request),
|
||||||
ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
|
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)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
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 在真实启动记录之外,
|
// 最近常玩仍传 scene、room、region、language 和 platform,让 game-service 在真实启动记录之外,
|
||||||
// 继续按当前房间场景和用户区域过滤,避免 App 展示用户已不能进入的游戏。
|
// 继续按当前房间场景和用户区域过滤,避免 App 展示用户已不能进入的游戏。
|
||||||
resp, err := h.gameClient.ListRecentGames(request.Context(), &gamev1.ListRecentGamesRequest{
|
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,
|
UserId: userID,
|
||||||
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
|
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
|
||||||
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
|
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
|
||||||
RegionId: userResp.GetUser().GetRegionId(),
|
RegionId: user.GetRegionId(),
|
||||||
Language: requestLanguage(request),
|
Language: requestLanguage(request),
|
||||||
ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
|
ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import (
|
|||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
"hyapp/pkg/roomid"
|
"hyapp/pkg/roomid"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/gateway-service/internal/appconfig"
|
"hyapp/services/gateway-service/internal/appconfig"
|
||||||
"hyapp/services/gateway-service/internal/auth"
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
"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) {
|
bagCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "bag", func() (*walletv1.ListUserResourcesResponse, bool, error) {
|
||||||
resp, err := h.roomGiftBagResources(request, viewerUserID)
|
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) {
|
comboConfigCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "combo_config", func() (appconfig.GiftComboConfig, bool, error) {
|
||||||
config, err := h.roomGiftComboConfig(totalCtx, app, viewerUserID)
|
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)
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bagResources, _, err := waitRoomGiftPanelStage(totalCtx, bagCh)
|
bagResources, bagDegraded, err := waitRoomGiftPanelStage(totalCtx, bagCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
if !bagDegraded || totalCtx.Err() != nil {
|
||||||
return
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 背包只影响用户持有礼物的附加列表,目录、余额和送礼主链路仍可安全展示;
|
||||||
|
// 当前请求返回空背包,下一次打开会重新读取,不缓存这次瞬时降级。
|
||||||
|
bagResources = &walletv1.ListUserResourcesResponse{}
|
||||||
}
|
}
|
||||||
recipientProfiles, _, err := waitRoomGiftPanelStage(totalCtx, profileCh)
|
recipientProfiles, _, err := waitRoomGiftPanelStage(totalCtx, profileCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -90,14 +90,17 @@ type pointDiamondBalanceData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pointDiamondPolicyData struct {
|
type pointDiamondPolicyData struct {
|
||||||
PointDiamondsPerUSD string `json:"point_diamonds_per_usd"`
|
PointDiamondsPerUSD string `json:"point_diamonds_per_usd"`
|
||||||
CoinsPerUSD string `json:"coins_per_usd"`
|
CoinsPerUSD string `json:"coins_per_usd"`
|
||||||
AgencyPointShareBPS string `json:"agency_point_share_bps"`
|
AgencyPointShareBPS string `json:"agency_point_share_bps"`
|
||||||
MinimumWithdrawUSDMinor string `json:"minimum_withdraw_usd_minor"`
|
MinimumWithdrawUSDMinor string `json:"minimum_withdraw_usd_minor"`
|
||||||
WithdrawFeeBPS string `json:"withdraw_fee_bps"`
|
CoinSellerMinimumWithdrawUSDMinor string `json:"coin_seller_minimum_withdraw_usd_minor"`
|
||||||
PolicyID string `json:"policy_id"`
|
PlatformMinimumWithdrawUSDMinor string `json:"platform_minimum_withdraw_usd_minor"`
|
||||||
PolicyVersion string `json:"policy_version"`
|
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
|
||||||
PolicyInstanceCode string `json:"policy_instance_code"`
|
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 {
|
type pointDiamondActionsData struct {
|
||||||
@ -170,14 +173,17 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
availabilityEvaluated := config.GetAvailabilityEvaluated()
|
availabilityEvaluated := config.GetAvailabilityEvaluated()
|
||||||
|
diamondExchangeEnabled := pointDiamondExchangeEnabled(config)
|
||||||
actions := pointDiamondActionsData{
|
actions := pointDiamondActionsData{
|
||||||
ExchangeToCoins: enabled,
|
ExchangeToCoins: enabled && diamondExchangeEnabled,
|
||||||
Withdraw: enabled && availabilityEvaluated && config.GetPlatformAvailability().GetAllowed(),
|
Withdraw: enabled && availabilityEvaluated && config.GetPlatformAvailability().GetAllowed(),
|
||||||
TransferToCoinSeller: enabled && availabilityEvaluated && config.GetCoinSellerAvailability().GetAllowed() && len(sellers) > 0,
|
TransferToCoinSeller: enabled && availabilityEvaluated && config.GetCoinSellerAvailability().GetAllowed() && len(sellers) > 0,
|
||||||
}
|
}
|
||||||
channels := []string{}
|
channels := []string{}
|
||||||
if enabled {
|
if enabled {
|
||||||
channels = append(channels, "COIN")
|
if actions.ExchangeToCoins {
|
||||||
|
channels = append(channels, "COIN")
|
||||||
|
}
|
||||||
if actions.Withdraw {
|
if actions.Withdraw {
|
||||||
channels = append(channels, "PLATFORM_USDT")
|
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,
|
"available_points": balance.AvailablePoints, "available_amount": balance.AvailableAmount,
|
||||||
"point_diamonds_per_usd": policy.PointDiamondsPerUSD, "coins_per_usd": policy.CoinsPerUSD,
|
"point_diamonds_per_usd": policy.PointDiamondsPerUSD, "coins_per_usd": policy.CoinsPerUSD,
|
||||||
"agency_point_share_bps": policy.AgencyPointShareBPS,
|
"agency_point_share_bps": policy.AgencyPointShareBPS,
|
||||||
"minimum_withdraw_usd_minor": policy.MinimumWithdrawUSDMinor, "withdraw_fee_bps": policy.WithdrawFeeBPS,
|
"minimum_withdraw_usd_minor": policy.MinimumWithdrawUSDMinor,
|
||||||
"withdraw_address": salaryWithdrawAddressFromProto(profile),
|
"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{
|
return pointDiamondPolicyData{
|
||||||
PointDiamondsPerUSD: strconv.FormatInt(config.GetPointsPerUsd(), 10), CoinsPerUSD: strconv.FormatInt(config.GetCoinsPerUsd(), 10),
|
PointDiamondsPerUSD: strconv.FormatInt(config.GetPointsPerUsd(), 10), CoinsPerUSD: strconv.FormatInt(config.GetCoinsPerUsd(), 10),
|
||||||
AgencyPointShareBPS: strconv.FormatInt(int64(config.GetAgencyPointShareBps()), 10),
|
AgencyPointShareBPS: strconv.FormatInt(int64(config.GetAgencyPointShareBps()), 10),
|
||||||
MinimumWithdrawUSDMinor: strconv.FormatInt(config.GetMinimumWithdrawUsdMinor(), 10),
|
MinimumWithdrawUSDMinor: strconv.FormatInt(config.GetMinimumWithdrawUsdMinor(), 10),
|
||||||
WithdrawFeeBPS: strconv.FormatInt(int64(config.GetFeeBps()), 10),
|
CoinSellerMinimumWithdrawUSDMinor: strconv.FormatInt(config.GetCoinSellerMinimumWithdrawUsdMinor(), 10),
|
||||||
PolicyID: strconv.FormatUint(config.GetPolicyId(), 10), PolicyVersion: strconv.FormatUint(config.GetPolicyVersion(), 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(),
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@ -315,6 +315,18 @@ CREATE TABLE IF NOT EXISTS admin_user_bans (
|
|||||||
KEY idx_admin_user_bans_expire (app_code, status, expires_at_ms)
|
KEY idx_admin_user_bans_expire (app_code, status, expires_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员用户封禁事实表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员用户封禁事实表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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,
|
-- 空 ban_id 解封会按用户选择最新 active 管理员事实;独立命令表把一次选择固定到 resolved_ban_id,
|
||||||
-- 避免网络重试继续释放下一条重叠封禁。
|
-- 避免网络重试继续释放下一条重叠封禁。
|
||||||
CREATE TABLE IF NOT EXISTS admin_user_ban_release_commands (
|
CREATE TABLE IF NOT EXISTS admin_user_ban_release_commands (
|
||||||
|
|||||||
@ -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='用户运营内容屏蔽当前态';
|
||||||
@ -289,12 +289,31 @@ type User struct {
|
|||||||
OnboardingStatus OnboardingStatus
|
OnboardingStatus OnboardingStatus
|
||||||
// Status 控制登录和业务可用性。
|
// Status 控制登录和业务可用性。
|
||||||
Status Status
|
Status Status
|
||||||
|
// ContentBlacklisted 只屏蔽游戏列表和 Banner 等运营内容;账号仍可登录,房间和账务状态不受影响。
|
||||||
|
ContentBlacklisted bool
|
||||||
// CreatedAtMs 是用户创建时间。
|
// CreatedAtMs 是用户创建时间。
|
||||||
CreatedAtMs int64
|
CreatedAtMs int64
|
||||||
// UpdatedAtMs 是用户主记录更新时间。
|
// UpdatedAtMs 是用户主记录更新时间。
|
||||||
UpdatedAtMs int64
|
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 是房间首屏热路径使用的最小用户展示资料,不能混入国家、靓号详情或装扮字段。
|
// RoomBasicUser 是房间首屏热路径使用的最小用户展示资料,不能混入国家、靓号详情或装扮字段。
|
||||||
type RoomBasicUser struct {
|
type RoomBasicUser struct {
|
||||||
// UserID 是系统内部不可变用户 ID,调用方用它和麦位/房主/当前用户状态关联。
|
// UserID 是系统内部不可变用户 ID,调用方用它和麦位/房主/当前用户状态关联。
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -202,6 +202,15 @@ type DeviceRepository interface {
|
|||||||
DeletePushToken(ctx context.Context, command userdomain.DeletePushTokenCommand) (bool, error)
|
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 的同库事务。
|
// ModerationRepository 持有用户状态和认证 session 的同库事务。
|
||||||
type ModerationRepository interface {
|
type ModerationRepository interface {
|
||||||
SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error)
|
SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error)
|
||||||
@ -276,6 +285,8 @@ type Service struct {
|
|||||||
roleSummaryRepository RoleSummaryRepository
|
roleSummaryRepository RoleSummaryRepository
|
||||||
// deviceRepository 持有设备 push token 绑定能力。
|
// deviceRepository 持有设备 push token 绑定能力。
|
||||||
deviceRepository DeviceRepository
|
deviceRepository DeviceRepository
|
||||||
|
// contentBlacklistRepository 持有游戏列表和 Banner 屏蔽当前态,不参与账号封禁或登录状态判断。
|
||||||
|
contentBlacklistRepository ContentBlacklistRepository
|
||||||
// moderationRepository 持有用户状态和 session 吊销事务能力。
|
// moderationRepository 持有用户状态和 session 吊销事务能力。
|
||||||
moderationRepository ModerationRepository
|
moderationRepository ModerationRepository
|
||||||
// roleScopePolicyResolver 与 Host 组织链路共享同一 owner 配置,经理治理不能复制 App 分支。
|
// roleScopePolicyResolver 与 Host 组织链路共享同一 owner 配置,经理治理不能复制 App 分支。
|
||||||
@ -323,6 +334,10 @@ func New(userRepository UserRepository, options ...Option) *Service {
|
|||||||
// 生产 MySQL 用户 repository 同时拥有 users 与头像授权表,凭证消费才能和资料更新保持原子。
|
// 生产 MySQL 用户 repository 同时拥有 users 与头像授权表,凭证消费才能和资料更新保持原子。
|
||||||
svc.avatarUploadRepository = repository
|
svc.avatarUploadRepository = repository
|
||||||
}
|
}
|
||||||
|
if repository, ok := userRepository.(ContentBlacklistRepository); ok {
|
||||||
|
// 生产 MySQL 用户 repository 同库维护屏蔽名单;测试替身未覆盖该能力时保持可选。
|
||||||
|
svc.contentBlacklistRepository = repository
|
||||||
|
}
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
// option 只改依赖或策略,不做 IO。
|
// option 只改依赖或策略,不做 IO。
|
||||||
option(svc)
|
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 配置用户国家修改冷却窗口。
|
// WithCountryChangeCooldown 配置用户国家修改冷却窗口。
|
||||||
func WithCountryChangeCooldown(cooldown time.Duration) Option {
|
func WithCountryChangeCooldown(cooldown time.Duration) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
@ -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,调用方按普通非邀请充值处理。
|
// GetInviteAttribution 按被邀请用户读取邀请关系;没有关系时返回 Found=false,调用方按普通非邀请充值处理。
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
@ -92,6 +92,24 @@ func toProtoUser(user userdomain.User) *userv1.User {
|
|||||||
Invite: toProtoInviteOverview(user.InviteOverview),
|
Invite: toProtoInviteOverview(user.InviteOverview),
|
||||||
PrettyId: user.PrettyID,
|
PrettyId: user.PrettyID,
|
||||||
PrettyDisplayUserId: user.PrettyDisplayUserID,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -784,6 +784,48 @@ func (s *Server) AdminIssueUserAccessToken(ctx context.Context, req *userv1.Admi
|
|||||||
}, nil
|
}, 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 批量返回房间首屏最小用户展示资料。
|
// BatchGetRoomBasicUsers 批量返回房间首屏最小用户展示资料。
|
||||||
func (s *Server) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
func (s *Server) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
||||||
ctx = contextWithApp(ctx, req.GetMeta())
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
|||||||
@ -16,6 +16,15 @@ red_packet_expiry_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "5s"
|
poll_interval: "5s"
|
||||||
batch_size: 50
|
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:
|
projection_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "60s"
|
poll_interval: "60s"
|
||||||
|
|||||||
@ -16,6 +16,15 @@ red_packet_expiry_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "5s"
|
poll_interval: "5s"
|
||||||
batch_size: 50
|
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:
|
projection_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "60s"
|
poll_interval: "60s"
|
||||||
|
|||||||
@ -16,6 +16,15 @@ red_packet_expiry_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "5s"
|
poll_interval: "5s"
|
||||||
batch_size: 50
|
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:
|
projection_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "60s"
|
poll_interval: "60s"
|
||||||
|
|||||||
@ -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 表示不调整礼物类型倍率',
|
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 每美元对应钻石积分',
|
point_diamonds_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 每美元对应钻石积分',
|
||||||
coins_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 提现费率基点',
|
withdraw_fee_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 提现费率基点',
|
||||||
agency_point_share_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 '月底剩余钻石转美元比例',
|
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)
|
KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 行加锁;不对装备表做历史扫描。
|
-- 装备命令以 (app_code,command_id) 点查并先于 entitlement/equipment 行加锁;不对装备表做历史扫描。
|
||||||
-- 记录首个成功回包快照,确保网络重试不会因资源随后到期或后台下架而改变业务结果。
|
-- 记录首个成功回包快照,确保网络重试不会因资源随后到期或后台下架而改变业务结果。
|
||||||
CREATE TABLE IF NOT EXISTS user_resource_equipment_commands (
|
CREATE TABLE IF NOT EXISTS user_resource_equipment_commands (
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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 资源装备物理清理游标';
|
||||||
@ -54,6 +54,7 @@ type App struct {
|
|||||||
projectionWorkerCfg config.ProjectionWorkerConfig
|
projectionWorkerCfg config.ProjectionWorkerConfig
|
||||||
googlePaidSyncWorkerCfg config.GooglePaidSyncWorkerConfig
|
googlePaidSyncWorkerCfg config.GooglePaidSyncWorkerConfig
|
||||||
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
|
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
|
||||||
|
resourceEquipmentCleanupWorkerCfg config.ResourceEquipmentCleanupWorkerConfig
|
||||||
walletOutboxTopic string
|
walletOutboxTopic string
|
||||||
realtimeWalletOutboxTopic string
|
realtimeWalletOutboxTopic string
|
||||||
walletEventTagMode config.WalletEventTagMode
|
walletEventTagMode config.WalletEventTagMode
|
||||||
@ -232,6 +233,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
projectionWorkerCfg: cfg.ProjectionWorker,
|
projectionWorkerCfg: cfg.ProjectionWorker,
|
||||||
googlePaidSyncWorkerCfg: googlePaidSyncWorkerCfg,
|
googlePaidSyncWorkerCfg: googlePaidSyncWorkerCfg,
|
||||||
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
|
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
|
||||||
|
resourceEquipmentCleanupWorkerCfg: cfg.ResourceEquipmentCleanupWorker,
|
||||||
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
|
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
|
||||||
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
|
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
|
||||||
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,
|
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,
|
||||||
|
|||||||
@ -35,4 +35,9 @@ func (a *App) runBackgroundWorkers() {
|
|||||||
if a.redPacketExpiryWorkerCfg.Enabled {
|
if a.redPacketExpiryWorkerCfg.Enabled {
|
||||||
a.startRedPacketExpiryWorker(ctx)
|
a.startRedPacketExpiryWorker(ctx)
|
||||||
}
|
}
|
||||||
|
if a.resourceEquipmentCleanupWorkerCfg.Enabled {
|
||||||
|
// 装备残留是 Wallet owner 数据;多副本只由 advisory lock winner 执行物理删除,
|
||||||
|
// gateway/cron 都不能直连钱包库或在用户请求内代做清理。
|
||||||
|
a.startResourceEquipmentCleanupWorker(ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -47,6 +47,8 @@ type Config struct {
|
|||||||
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
|
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
|
||||||
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
|
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
|
||||||
ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
|
ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
|
||||||
|
// ResourceEquipmentCleanupWorker 把失效装备的物理删除收敛到 Wallet owner 后台,读取接口只做有效性过滤。
|
||||||
|
ResourceEquipmentCleanupWorker ResourceEquipmentCleanupWorkerConfig `yaml:"resource_equipment_cleanup_worker"`
|
||||||
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
||||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||||
// OutboxArchive 把已投递事实归档到外部存储;purge 仅对恢复验证且命中正向安全策略的精确成员开放。
|
// OutboxArchive 把已投递事实归档到外部存储;purge 仅对恢复验证且命中正向安全策略的精确成员开放。
|
||||||
@ -336,6 +338,17 @@ type ExternalRechargeReconcileWorkerConfig struct {
|
|||||||
BatchSize int `yaml:"batch_size"`
|
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 返回本地开发默认配置。
|
// Default 返回本地开发默认配置。
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
@ -402,6 +415,15 @@ func Default() Config {
|
|||||||
PollInterval: 30 * time.Second,
|
PollInterval: 30 * time.Second,
|
||||||
BatchSize: 50,
|
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{
|
OutboxWorker: OutboxWorkerConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
PollInterval: time.Second,
|
PollInterval: time.Second,
|
||||||
@ -600,6 +622,10 @@ func Load(path string) (Config, error) {
|
|||||||
cfg.ExternalRecharge.V5PayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayReturnURL)
|
cfg.ExternalRecharge.V5PayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayReturnURL)
|
||||||
// USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。
|
// USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。
|
||||||
cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker)
|
cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker)
|
||||||
|
cfg.ResourceEquipmentCleanupWorker = normalizeResourceEquipmentCleanupWorkerConfig(
|
||||||
|
cfg.ResourceEquipmentCleanupWorker,
|
||||||
|
Default().ResourceEquipmentCleanupWorker,
|
||||||
|
)
|
||||||
cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
|
cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
|
||||||
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
|
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
|
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
|
||||||
@ -953,6 +979,40 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig, defaults OutboxWorkerCo
|
|||||||
return cfg
|
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) {
|
func normalizeOutboxArchiveConfig(cfg OutboxArchiveConfig, defaults OutboxArchiveConfig) (OutboxArchiveConfig, error) {
|
||||||
cfg.Mode = OutboxArchiveMode(strings.ToLower(strings.TrimSpace(string(cfg.Mode))))
|
cfg.Mode = OutboxArchiveMode(strings.ToLower(strings.TrimSpace(string(cfg.Mode))))
|
||||||
if cfg.Mode == "" {
|
if cfg.Mode == "" {
|
||||||
|
|||||||
@ -39,17 +39,21 @@ type HostSalaryPolicy struct {
|
|||||||
SettlementMode string
|
SettlementMode string
|
||||||
SettlementTriggerMode string
|
SettlementTriggerMode string
|
||||||
// GiftCoinToDiamondRatio 是叠加在礼物类型倍率上的 Host 政策系数,1 表示不调整。
|
// GiftCoinToDiamondRatio 是叠加在礼物类型倍率上的 Host 政策系数,1 表示不调整。
|
||||||
GiftCoinToDiamondRatio string
|
GiftCoinToDiamondRatio string
|
||||||
PointDiamondsPerUSD int64
|
PointDiamondsPerUSD int64
|
||||||
CoinsPerUSD int64
|
CoinsPerUSD int64
|
||||||
MinimumWithdrawUSDMinor int64
|
// MinimumWithdrawUSDMinor 是历史客户端兼容值,始终投影平台提现门槛;资金校验必须读取下面的渠道字段。
|
||||||
WithdrawFeeBPS int32
|
MinimumWithdrawUSDMinor int64
|
||||||
AgencyPointShareBPS int32
|
CoinSellerMinimumWithdrawUSDMinor int64
|
||||||
ResidualDiamondToUSDRate string
|
PlatformMinimumWithdrawUSDMinor int64
|
||||||
CoinSellerWithdrawalLimitPeriod string
|
DiamondExchangeEnabled bool
|
||||||
CoinSellerWithdrawalLimitCount int64
|
WithdrawFeeBPS int32
|
||||||
PlatformWithdrawalLimitPeriod string
|
AgencyPointShareBPS int32
|
||||||
PlatformWithdrawalLimitCount int64
|
ResidualDiamondToUSDRate string
|
||||||
|
CoinSellerWithdrawalLimitPeriod string
|
||||||
|
CoinSellerWithdrawalLimitCount int64
|
||||||
|
PlatformWithdrawalLimitPeriod string
|
||||||
|
PlatformWithdrawalLimitCount int64
|
||||||
// PlatformWithdrawalAllowedDays 是 UTC 月内日的规范逗号串;空串表示不限制提现日期。
|
// PlatformWithdrawalAllowedDays 是 UTC 月内日的规范逗号串;空串表示不限制提现日期。
|
||||||
PlatformWithdrawalAllowedDays string
|
PlatformWithdrawalAllowedDays string
|
||||||
EffectiveFromMs int64
|
EffectiveFromMs int64
|
||||||
|
|||||||
@ -81,20 +81,24 @@ type PointToCoinReceipt struct {
|
|||||||
|
|
||||||
// PointWithdrawalRuntimeConfig 是已发布 wallet 政策中钱包页需要展示并用于冻结的 POINT 参数。
|
// PointWithdrawalRuntimeConfig 是已发布 wallet 政策中钱包页需要展示并用于冻结的 POINT 参数。
|
||||||
type PointWithdrawalRuntimeConfig struct {
|
type PointWithdrawalRuntimeConfig struct {
|
||||||
Found bool
|
Found bool
|
||||||
PointsPerUSD int64
|
PointsPerUSD int64
|
||||||
CoinsPerUSD int64
|
CoinsPerUSD int64
|
||||||
FeeBPS int64
|
FeeBPS int64
|
||||||
MinimumPoints int64
|
MinimumPoints int64
|
||||||
PolicyInstanceCode string
|
PolicyInstanceCode string
|
||||||
PolicyType string
|
PolicyType string
|
||||||
MinimumWithdrawUSDMinor int64
|
// MinimumWithdrawUSDMinor 兼容旧客户端,值等于平台提现门槛。
|
||||||
AgencyPointShareBPS int32
|
MinimumWithdrawUSDMinor int64
|
||||||
PolicyID uint64
|
CoinSellerMinimumWithdrawUSDMinor int64
|
||||||
PolicyVersion uint64
|
PlatformMinimumWithdrawUSDMinor int64
|
||||||
AvailabilityEvaluated bool
|
DiamondExchangeEnabled bool
|
||||||
CoinSellerAvailability PointWithdrawalActionAvailability
|
AgencyPointShareBPS int32
|
||||||
PlatformAvailability PointWithdrawalActionAvailability
|
PolicyID uint64
|
||||||
|
PolicyVersion uint64
|
||||||
|
AvailabilityEvaluated bool
|
||||||
|
CoinSellerAvailability PointWithdrawalActionAvailability
|
||||||
|
PlatformAvailability PointWithdrawalActionAvailability
|
||||||
}
|
}
|
||||||
|
|
||||||
// PointWithdrawalActionAvailability 是钱包按当前 UTC 日期和已消费次数计算的实时能力;
|
// PointWithdrawalActionAvailability 是钱包按当前 UTC 日期和已消费次数计算的实时能力;
|
||||||
|
|||||||
@ -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,
|
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,
|
p.settlement_mode, p.settlement_trigger_mode,
|
||||||
CAST(p.gift_coin_to_diamond_ratio AS CHAR),
|
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),
|
CAST(p.residual_diamond_to_usd_rate AS CHAR),
|
||||||
p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count,
|
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,
|
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(
|
err := q.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.PolicyType, &policy.RegionID, &policy.Status,
|
&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.PolicyType, &policy.RegionID, &policy.Status,
|
||||||
&policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio,
|
&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.AgencyPointShareBPS,
|
||||||
&policy.ResidualDiamondToUSDRate,
|
&policy.ResidualDiamondToUSDRate,
|
||||||
&policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount,
|
&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 {
|
if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond && policy.PolicyType != ledger.HostPolicyTypePointDiamond {
|
||||||
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Internal, "host policy type is invalid")
|
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)
|
levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID, policy.PolicyVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.HostSalaryPolicy{}, false, err
|
return ledger.HostSalaryPolicy{}, false, err
|
||||||
|
|||||||
@ -82,10 +82,15 @@ type WalletOutboxArchiveReceipt struct {
|
|||||||
// AcquireWalletOutboxArchiveAppLock 用 MySQL connection-scoped advisory lock 保证多副本下每个 App 只有一个归档者。
|
// AcquireWalletOutboxArchiveAppLock 用 MySQL connection-scoped advisory lock 保证多副本下每个 App 只有一个归档者。
|
||||||
// release 必须 defer 调用;专用 Conn 在释放前不会回到连接池,避免锁跟随错误会话。
|
// release 必须 defer 调用;专用 Conn 在释放前不会回到连接池,避免锁跟随错误会话。
|
||||||
func (r *Repository) AcquireWalletOutboxArchiveAppLock(ctx context.Context) (release func(), acquired bool, err error) {
|
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 {
|
if r == nil || r.db == nil {
|
||||||
return nil, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
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)
|
conn, err := r.db.Conn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
|
|||||||
@ -143,7 +143,7 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.PointToCoinSellerReceipt{}, err
|
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")
|
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum")
|
||||||
}
|
}
|
||||||
pointAmount, feeUSDMinor, netUSDMinor, sellerCoinAmount, err = calculatePointDiamondCoinSellerAmounts(
|
pointAmount, feeUSDMinor, netUSDMinor, sellerCoinAmount, err = calculatePointDiamondCoinSellerAmounts(
|
||||||
@ -326,7 +326,7 @@ func (r *Repository) getPointWithdrawalConfig(ctx context.Context, appCode strin
|
|||||||
}
|
}
|
||||||
return ledger.PointWithdrawalRuntimeConfig{}, err
|
return ledger.PointWithdrawalRuntimeConfig{}, err
|
||||||
}
|
}
|
||||||
minimumPoints, calculateErr := checkedMul(policy.MinimumWithdrawUSDMinor, policy.PointDiamondsPerUSD)
|
minimumPoints, calculateErr := checkedMul(policy.PlatformMinimumWithdrawUSDMinor, policy.PointDiamondsPerUSD)
|
||||||
if calculateErr != nil {
|
if calculateErr != nil {
|
||||||
return ledger.PointWithdrawalRuntimeConfig{}, calculateErr
|
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,
|
Found: true, PointsPerUSD: policy.PointDiamondsPerUSD, CoinsPerUSD: policy.CoinsPerUSD,
|
||||||
FeeBPS: int64(policy.WithdrawFeeBPS), MinimumPoints: minimumPoints / 100,
|
FeeBPS: int64(policy.WithdrawFeeBPS), MinimumPoints: minimumPoints / 100,
|
||||||
PolicyInstanceCode: fmt.Sprintf("host:%d:%d", policy.PolicyID, policy.PolicyVersion), PolicyType: policy.PolicyType,
|
PolicyInstanceCode: fmt.Sprintf("host:%d:%d", policy.PolicyID, policy.PolicyVersion), PolicyType: policy.PolicyType,
|
||||||
MinimumWithdrawUSDMinor: policy.MinimumWithdrawUSDMinor, AgencyPointShareBPS: policy.AgencyPointShareBPS,
|
MinimumWithdrawUSDMinor: policy.PlatformMinimumWithdrawUSDMinor,
|
||||||
PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion,
|
CoinSellerMinimumWithdrawUSDMinor: policy.CoinSellerMinimumWithdrawUSDMinor,
|
||||||
|
PlatformMinimumWithdrawUSDMinor: policy.PlatformMinimumWithdrawUSDMinor,
|
||||||
|
DiamondExchangeEnabled: policy.DiamondExchangeEnabled,
|
||||||
|
AgencyPointShareBPS: policy.AgencyPointShareBPS,
|
||||||
|
PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion,
|
||||||
}
|
}
|
||||||
if userID > 0 {
|
if userID > 0 {
|
||||||
now := time.UnixMilli(nowMS).UTC()
|
now := time.UnixMilli(nowMS).UTC()
|
||||||
|
|||||||
@ -69,6 +69,11 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.PointToCoinReceipt{}, err
|
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
|
ratioPointAmount, ratioCoinAmount = policy.PointDiamondsPerUSD, policy.CoinsPerUSD
|
||||||
policyID, policyVersion = policy.PolicyID, policy.PolicyVersion
|
policyID, policyVersion = policy.PolicyID, policy.PolicyVersion
|
||||||
} else {
|
} else {
|
||||||
@ -177,6 +182,8 @@ func (r *Repository) resolvePointDiamondHostPolicy(ctx context.Context, tx *sql.
|
|||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
SELECT p.policy_id, p.policy_version, binding.cycle_key, binding.region_id,
|
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.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.withdraw_fee_bps, p.agency_point_share_bps,
|
||||||
p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count,
|
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_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(
|
LIMIT 1`, appcode.FromContext(ctx), regionID, cycleKey, ledger.HostPolicyTypePointDiamond).Scan(
|
||||||
&policy.PolicyID, &policy.PolicyVersion, &policy.CycleKey, &policy.RegionID,
|
&policy.PolicyID, &policy.PolicyVersion, &policy.CycleKey, &policy.RegionID,
|
||||||
&policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor,
|
&policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor,
|
||||||
|
&policy.CoinSellerMinimumWithdrawUSDMinor, &policy.PlatformMinimumWithdrawUSDMinor,
|
||||||
|
&policy.DiamondExchangeEnabled,
|
||||||
&policy.WithdrawFeeBPS, &policy.AgencyPointShareBPS,
|
&policy.WithdrawFeeBPS, &policy.AgencyPointShareBPS,
|
||||||
&policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount,
|
&policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount,
|
||||||
&policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount,
|
&policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount,
|
||||||
@ -207,8 +216,24 @@ func (r *Repository) resolvePointDiamondHostPolicy(ctx context.Context, tx *sql.
|
|||||||
return ledger.HostSalaryPolicy{}, err
|
return ledger.HostSalaryPolicy{}, err
|
||||||
}
|
}
|
||||||
policy.PolicyType = ledger.HostPolicyTypePointDiamond
|
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 ledger.HostSalaryPolicy{}, xerr.New(xerr.Internal, "point diamond policy is invalid")
|
||||||
}
|
}
|
||||||
return policy, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
|
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 {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
@ -25,7 +30,6 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
|
|||||||
}
|
}
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
|
|
||||||
pruneResourceTypes := []string{}
|
|
||||||
where := `WHERE e.app_code = ? AND e.user_id = ?`
|
where := `WHERE e.app_code = ? AND e.user_id = ?`
|
||||||
args := []any{appcode.FromContext(ctx), query.UserID}
|
args := []any{appcode.FromContext(ctx), query.UserID}
|
||||||
if strings.TrimSpace(query.ResourceType) != "" {
|
if strings.TrimSpace(query.ResourceType) != "" {
|
||||||
@ -33,14 +37,9 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
|
|||||||
if !resourcedomain.ValidResourceType(resourceType) {
|
if !resourcedomain.ValidResourceType(resourceType) {
|
||||||
return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
|
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 {
|
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`
|
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) {
|
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 {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
@ -517,10 +521,8 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
|
|||||||
}
|
}
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
|
|
||||||
if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil {
|
// appearance/在线用户批量读不再物理清理 equipment;查询本身完整过滤失效权益,
|
||||||
return nil, err
|
// 后台任务按稳定主键分页删除残留行,读请求不会因为清理死锁成为 1213 受害者。
|
||||||
}
|
|
||||||
|
|
||||||
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
|
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
|
||||||
args = append(args, nowMs, nowMs)
|
args = append(args, nowMs, nowMs)
|
||||||
typeFilter := ""
|
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 {
|
func isEquipableResourceType(resourceType string) bool {
|
||||||
switch resourcedomain.NormalizeResourceType(resourceType) {
|
switch resourcedomain.NormalizeResourceType(resourceType) {
|
||||||
// 麦位中心图标和声波动效必须使用两个独立的 resource_type 单选槽;用户切换 mic_seat_icon
|
// 麦位中心图标和声波动效必须使用两个独立的 resource_type 单选槽;用户切换 mic_seat_icon
|
||||||
|
|||||||
@ -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 scan;row-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
|
||||||
|
}
|
||||||
@ -108,7 +108,7 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S
|
|||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return ledger.SalaryWithdrawalReceipt{}, resolveErr
|
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")
|
return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum")
|
||||||
}
|
}
|
||||||
pointProduct, calculateErr := checkedMul(command.GrossUSDMinor, policy.PointDiamondsPerUSD)
|
pointProduct, calculateErr := checkedMul(command.GrossUSDMinor, policy.PointDiamondsPerUSD)
|
||||||
|
|||||||
@ -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) {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
// 渠道门槛不做启动时 UPDATE 回填:旧快照以 0 标记未拆分,资金事务读取时回退历史单一门槛。
|
||||||
|
// 这样兼容升级只在线追加小型配置表字段,不扫描任何钱包或提现流水。
|
||||||
if _, err := db.ExecContext(ctx, `
|
if _, err := db.ExecContext(ctx, `
|
||||||
ALTER TABLE host_agency_salary_policies
|
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
|
return err
|
||||||
}
|
}
|
||||||
if _, err := db.ExecContext(ctx, `
|
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 大表。
|
// 命令表是 append-only 幂等事实,主键查询不会扫描 equipment 或 entitlement 大表。
|
||||||
// response_json 固化首个成功回包,使资源随后到期/下架也不会改变同 command_id 的重试语义。
|
// response_json 固化首个成功回包,使资源随后到期/下架也不会改变同 command_id 的重试语义。
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
|
|||||||
@ -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")
|
return ledger.VipState{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
}
|
}
|
||||||
nowMS := time.Now().UnixMilli()
|
nowMS := time.Now().UnixMilli()
|
||||||
// 先清理失效装备指针,再用一个 repeatable-read 快照读取 program、会员、卡片和权益,
|
// 失效 trial-card equipment 由 Wallet 单写者后台物理清理;这里直接读取同一
|
||||||
// 防止后台改配置时返回“新 config_version + 旧 benefits”一类混合状态。
|
// repeatable-read 快照,避免用户态查询产生 DELETE,同时防止返回跨配置版本组合。
|
||||||
if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{userID}, []string{resourcedomain.TypeVIPTrialCard}, nowMS); err != nil {
|
|
||||||
return ledger.VipState{}, err
|
|
||||||
}
|
|
||||||
if err := r.ensureTieredVipResourceState(ctx, userID, nowMS); err != nil {
|
if err := r.ensureTieredVipResourceState(ctx, userID, nowMS); err != nil {
|
||||||
return ledger.VipState{}, err
|
return ledger.VipState{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
return ledger.VipState{}, nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
nowMS := time.Now().UnixMilli()
|
nowMS := time.Now().UnixMilli()
|
||||||
if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{userID}, []string{resourcedomain.TypeVIPTrialCard}, nowMS); err != nil {
|
// VIP 读取不再删除过期 trial-card equipment;快照查询会忽略失效卡片,
|
||||||
return ledger.VipState{}, nil, err
|
// 物理残留统一由 Wallet 单写者清理,避免读取套餐时与 appearance/gift-panel 争写锁。
|
||||||
}
|
|
||||||
if err := r.ensureTieredVipResourceState(ctx, userID, nowMS); err != nil {
|
if err := r.ensureTieredVipResourceState(ctx, userID, nowMS); err != nil {
|
||||||
return ledger.VipState{}, nil, err
|
return ledger.VipState{}, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1053,14 +1053,30 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
|
|||||||
if policy.PlatformWithdrawalLimitPeriod == "" {
|
if policy.PlatformWithdrawalLimitPeriod == "" {
|
||||||
policy.PlatformWithdrawalLimitPeriod = "month"
|
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(), `
|
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||||
INSERT INTO host_agency_salary_policies (
|
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,
|
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,
|
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,
|
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
|
||||||
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
|
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
cycle_key = VALUES(cycle_key),
|
cycle_key = VALUES(cycle_key),
|
||||||
policy_version = VALUES(policy_version),
|
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),
|
point_diamonds_per_usd = VALUES(point_diamonds_per_usd),
|
||||||
coins_per_usd = VALUES(coins_per_usd),
|
coins_per_usd = VALUES(coins_per_usd),
|
||||||
minimum_withdraw_usd_minor = VALUES(minimum_withdraw_usd_minor),
|
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),
|
withdraw_fee_bps = VALUES(withdraw_fee_bps),
|
||||||
agency_point_share_bps = VALUES(agency_point_share_bps),
|
agency_point_share_bps = VALUES(agency_point_share_bps),
|
||||||
residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate),
|
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),
|
effective_to_ms = VALUES(effective_to_ms),
|
||||||
updated_at_ms = VALUES(updated_at_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,
|
`, 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.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount,
|
||||||
policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount, policy.PlatformWithdrawalAllowedDays,
|
policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount, policy.PlatformWithdrawalAllowedDays,
|
||||||
policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs)
|
policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs)
|
||||||
|
|||||||
@ -79,25 +79,28 @@ func hostSalaryPolicyToProto(policy ledger.HostSalaryPolicy) *walletv1.HostSalar
|
|||||||
levels = append(levels, hostSalaryPolicyLevelToProto(level))
|
levels = append(levels, hostSalaryPolicyLevelToProto(level))
|
||||||
}
|
}
|
||||||
return &walletv1.HostSalaryPolicy{
|
return &walletv1.HostSalaryPolicy{
|
||||||
PolicyId: policy.PolicyID,
|
PolicyId: policy.PolicyID,
|
||||||
CycleKey: policy.CycleKey,
|
CycleKey: policy.CycleKey,
|
||||||
PolicyVersion: policy.PolicyVersion,
|
PolicyVersion: policy.PolicyVersion,
|
||||||
Name: policy.Name,
|
Name: policy.Name,
|
||||||
PolicyType: policy.PolicyType,
|
PolicyType: policy.PolicyType,
|
||||||
RegionId: policy.RegionID,
|
RegionId: policy.RegionID,
|
||||||
Status: policy.Status,
|
Status: policy.Status,
|
||||||
SettlementMode: policy.SettlementMode,
|
SettlementMode: policy.SettlementMode,
|
||||||
SettlementTriggerMode: policy.SettlementTriggerMode,
|
SettlementTriggerMode: policy.SettlementTriggerMode,
|
||||||
GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio,
|
GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio,
|
||||||
PointDiamondsPerUsd: policy.PointDiamondsPerUSD,
|
PointDiamondsPerUsd: policy.PointDiamondsPerUSD,
|
||||||
CoinsPerUsd: policy.CoinsPerUSD,
|
CoinsPerUsd: policy.CoinsPerUSD,
|
||||||
MinimumWithdrawUsdMinor: policy.MinimumWithdrawUSDMinor,
|
MinimumWithdrawUsdMinor: policy.MinimumWithdrawUSDMinor,
|
||||||
WithdrawFeeBps: policy.WithdrawFeeBPS,
|
CoinSellerMinimumWithdrawUsdMinor: policy.CoinSellerMinimumWithdrawUSDMinor,
|
||||||
AgencyPointShareBps: policy.AgencyPointShareBPS,
|
PlatformMinimumWithdrawUsdMinor: policy.PlatformMinimumWithdrawUSDMinor,
|
||||||
ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate,
|
DiamondExchangeEnabled: policy.DiamondExchangeEnabled,
|
||||||
EffectiveFromMs: policy.EffectiveFromMs,
|
WithdrawFeeBps: policy.WithdrawFeeBPS,
|
||||||
EffectiveToMs: policy.EffectiveToMs,
|
AgencyPointShareBps: policy.AgencyPointShareBPS,
|
||||||
Levels: levels,
|
ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate,
|
||||||
|
EffectiveFromMs: policy.EffectiveFromMs,
|
||||||
|
EffectiveToMs: policy.EffectiveToMs,
|
||||||
|
Levels: levels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
Found: config.Found, PointsPerUsd: config.PointsPerUSD, CoinsPerUsd: config.CoinsPerUSD, FeeBps: int32(config.FeeBPS),
|
||||||
MinimumPoints: config.MinimumPoints, PolicyInstanceCode: config.PolicyInstanceCode,
|
MinimumPoints: config.MinimumPoints, PolicyInstanceCode: config.PolicyInstanceCode,
|
||||||
PolicyType: config.PolicyType, MinimumWithdrawUsdMinor: config.MinimumWithdrawUSDMinor,
|
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,
|
AvailabilityEvaluated: config.AvailabilityEvaluated,
|
||||||
CoinSellerAvailability: pointWithdrawalAvailabilityToProto(config.CoinSellerAvailability),
|
CoinSellerAvailability: pointWithdrawalAvailabilityToProto(config.CoinSellerAvailability),
|
||||||
PlatformAvailability: pointWithdrawalAvailabilityToProto(config.PlatformAvailability),
|
PlatformAvailability: pointWithdrawalAvailabilityToProto(config.PlatformAvailability),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user