merge main into test

This commit is contained in:
zhx 2026-07-02 15:01:05 +08:00
commit fedd4ea416
200 changed files with 22642 additions and 8191 deletions

File diff suppressed because it is too large Load Diff

View File

@ -483,6 +483,21 @@ message CreateAgencyResponse {
AgencyMembership membership = 3;
}
message AdminAddAgencyHostRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 admin_user_id = 3;
int64 agency_id = 4;
int64 target_user_id = 5;
string reason = 6;
}
message AdminAddAgencyHostResponse {
Agency agency = 1;
HostProfile host_profile = 2;
AgencyMembership membership = 3;
}
message CloseAgencyRequest {
RequestMeta meta = 1;
string command_id = 2;
@ -582,6 +597,7 @@ service UserHostAdminService {
rpc CreateCoinSeller(CreateCoinSellerRequest) returns (CreateCoinSellerResponse);
rpc SetCoinSellerStatus(SetCoinSellerStatusRequest) returns (SetCoinSellerStatusResponse);
rpc CreateAgency(CreateAgencyRequest) returns (CreateAgencyResponse);
rpc AdminAddAgencyHost(AdminAddAgencyHostRequest) returns (AdminAddAgencyHostResponse);
rpc CloseAgency(CloseAgencyRequest) returns (CloseAgencyResponse);
rpc DeleteAgency(DeleteAgencyRequest) returns (DeleteAgencyResponse);
rpc SetAgencyStatus(SetAgencyStatusRequest) returns (SetAgencyStatusResponse);

View File

@ -1005,6 +1005,7 @@ const (
UserHostAdminService_CreateCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateCoinSeller"
UserHostAdminService_SetCoinSellerStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetCoinSellerStatus"
UserHostAdminService_CreateAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateAgency"
UserHostAdminService_AdminAddAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostAdminService/AdminAddAgencyHost"
UserHostAdminService_CloseAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CloseAgency"
UserHostAdminService_DeleteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/DeleteAgency"
UserHostAdminService_SetAgencyStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyStatus"
@ -1023,6 +1024,7 @@ type UserHostAdminServiceClient interface {
CreateCoinSeller(ctx context.Context, in *CreateCoinSellerRequest, opts ...grpc.CallOption) (*CreateCoinSellerResponse, error)
SetCoinSellerStatus(ctx context.Context, in *SetCoinSellerStatusRequest, opts ...grpc.CallOption) (*SetCoinSellerStatusResponse, error)
CreateAgency(ctx context.Context, in *CreateAgencyRequest, opts ...grpc.CallOption) (*CreateAgencyResponse, error)
AdminAddAgencyHost(ctx context.Context, in *AdminAddAgencyHostRequest, opts ...grpc.CallOption) (*AdminAddAgencyHostResponse, error)
CloseAgency(ctx context.Context, in *CloseAgencyRequest, opts ...grpc.CallOption) (*CloseAgencyResponse, error)
DeleteAgency(ctx context.Context, in *DeleteAgencyRequest, opts ...grpc.CallOption) (*DeleteAgencyResponse, error)
SetAgencyStatus(ctx context.Context, in *SetAgencyStatusRequest, opts ...grpc.CallOption) (*SetAgencyStatusResponse, error)
@ -1097,6 +1099,16 @@ func (c *userHostAdminServiceClient) CreateAgency(ctx context.Context, in *Creat
return out, nil
}
func (c *userHostAdminServiceClient) AdminAddAgencyHost(ctx context.Context, in *AdminAddAgencyHostRequest, opts ...grpc.CallOption) (*AdminAddAgencyHostResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminAddAgencyHostResponse)
err := c.cc.Invoke(ctx, UserHostAdminService_AdminAddAgencyHost_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostAdminServiceClient) CloseAgency(ctx context.Context, in *CloseAgencyRequest, opts ...grpc.CallOption) (*CloseAgencyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CloseAgencyResponse)
@ -1149,6 +1161,7 @@ type UserHostAdminServiceServer interface {
CreateCoinSeller(context.Context, *CreateCoinSellerRequest) (*CreateCoinSellerResponse, error)
SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error)
CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error)
AdminAddAgencyHost(context.Context, *AdminAddAgencyHostRequest) (*AdminAddAgencyHostResponse, error)
CloseAgency(context.Context, *CloseAgencyRequest) (*CloseAgencyResponse, error)
DeleteAgency(context.Context, *DeleteAgencyRequest) (*DeleteAgencyResponse, error)
SetAgencyStatus(context.Context, *SetAgencyStatusRequest) (*SetAgencyStatusResponse, error)
@ -1181,6 +1194,9 @@ func (UnimplementedUserHostAdminServiceServer) SetCoinSellerStatus(context.Conte
func (UnimplementedUserHostAdminServiceServer) CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateAgency not implemented")
}
func (UnimplementedUserHostAdminServiceServer) AdminAddAgencyHost(context.Context, *AdminAddAgencyHostRequest) (*AdminAddAgencyHostResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminAddAgencyHost not implemented")
}
func (UnimplementedUserHostAdminServiceServer) CloseAgency(context.Context, *CloseAgencyRequest) (*CloseAgencyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CloseAgency not implemented")
}
@ -1322,6 +1338,24 @@ func _UserHostAdminService_CreateAgency_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_AdminAddAgencyHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminAddAgencyHostRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostAdminServiceServer).AdminAddAgencyHost(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostAdminService_AdminAddAgencyHost_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostAdminServiceServer).AdminAddAgencyHost(ctx, req.(*AdminAddAgencyHostRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_CloseAgency_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CloseAgencyRequest)
if err := dec(in); err != nil {
@ -1425,6 +1459,10 @@ var UserHostAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreateAgency",
Handler: _UserHostAdminService_CreateAgency_Handler,
},
{
MethodName: "AdminAddAgencyHost",
Handler: _UserHostAdminService_AdminAddAgencyHost_Handler,
},
{
MethodName: "CloseAgency",
Handler: _UserHostAdminService_CloseAgency_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -87,6 +87,7 @@ message User {
string pretty_display_user_id = 30;
string profile_bg_img = 31;
string contact_info = 32;
string withdraw_usdt_trc20_address = 33;
}
// InviteOverview read model
@ -746,6 +747,18 @@ message UpdateUserContactInfoResponse {
User user = 1;
}
// UpdateUserWithdrawAddressRequest wallet-service
message UpdateUserWithdrawAddressRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string usdt_trc20_address = 3;
}
// UpdateUserWithdrawAddressResponse
message UpdateUserWithdrawAddressResponse {
User user = 1;
}
// ChangeUserCountryRequest App
message ChangeUserCountryRequest {
RequestMeta meta = 1;
@ -1278,6 +1291,13 @@ message SetPrettyDisplayIDStatusRequest {
string reason = 5;
}
message RecyclePrettyDisplayIDRequest {
RequestMeta meta = 1;
string pretty_id = 2;
string reason = 3;
int64 operator_admin_id = 4;
}
message PrettyDisplayIDResponse {
PrettyDisplayID item = 1;
}
@ -1309,6 +1329,7 @@ service UserService {
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc UpdateUserProfileBackground(UpdateUserProfileBackgroundRequest) returns (UpdateUserProfileBackgroundResponse);
rpc UpdateUserContactInfo(UpdateUserContactInfoRequest) returns (UpdateUserContactInfoResponse);
rpc UpdateUserWithdrawAddress(UpdateUserWithdrawAddressRequest) returns (UpdateUserWithdrawAddressResponse);
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc AdminChangeUserCountry(AdminChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);
@ -1414,5 +1435,6 @@ service UserPrettyDisplayIDAdminService {
rpc GeneratePrettyDisplayIDs(GeneratePrettyDisplayIDsRequest) returns (GeneratePrettyDisplayIDsResponse);
rpc ListPrettyDisplayIDs(ListPrettyDisplayIDsRequest) returns (ListPrettyDisplayIDsResponse);
rpc SetPrettyDisplayIDStatus(SetPrettyDisplayIDStatusRequest) returns (PrettyDisplayIDResponse);
rpc RecyclePrettyDisplayID(RecyclePrettyDisplayIDRequest) returns (PrettyDisplayIDResponse);
rpc AdminGrantPrettyDisplayID(AdminGrantPrettyDisplayIDRequest) returns (AdminGrantPrettyDisplayIDResponse);
}

View File

@ -29,6 +29,7 @@ const (
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
UserService_UpdateUserWithdrawAddress_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserWithdrawAddress"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
@ -56,6 +57,7 @@ type UserServiceClient interface {
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, in *UpdateUserProfileBackgroundRequest, opts ...grpc.CallOption) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(ctx context.Context, in *UpdateUserContactInfoRequest, opts ...grpc.CallOption) (*UpdateUserContactInfoResponse, error)
UpdateUserWithdrawAddress(ctx context.Context, in *UpdateUserWithdrawAddressRequest, opts ...grpc.CallOption) (*UpdateUserWithdrawAddressResponse, error)
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, in *AdminChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
@ -175,6 +177,16 @@ func (c *userServiceClient) UpdateUserContactInfo(ctx context.Context, in *Updat
return out, nil
}
func (c *userServiceClient) UpdateUserWithdrawAddress(ctx context.Context, in *UpdateUserWithdrawAddressRequest, opts ...grpc.CallOption) (*UpdateUserWithdrawAddressResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserWithdrawAddressResponse)
err := c.cc.Invoke(ctx, UserService_UpdateUserWithdrawAddress_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChangeUserCountryResponse)
@ -281,6 +293,7 @@ type UserServiceServer interface {
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error)
UpdateUserWithdrawAddress(context.Context, *UpdateUserWithdrawAddressRequest) (*UpdateUserWithdrawAddressResponse, error)
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(context.Context, *AdminChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
@ -330,6 +343,9 @@ func (UnimplementedUserServiceServer) UpdateUserProfileBackground(context.Contex
func (UnimplementedUserServiceServer) UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserContactInfo not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserWithdrawAddress(context.Context, *UpdateUserWithdrawAddressRequest) (*UpdateUserWithdrawAddressResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserWithdrawAddress not implemented")
}
func (UnimplementedUserServiceServer) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ChangeUserCountry not implemented")
}
@ -558,6 +574,24 @@ func _UserService_UpdateUserContactInfo_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _UserService_UpdateUserWithdrawAddress_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserWithdrawAddressRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).UpdateUserWithdrawAddress(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_UpdateUserWithdrawAddress_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).UpdateUserWithdrawAddress(ctx, req.(*UpdateUserWithdrawAddressRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_ChangeUserCountry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeUserCountryRequest)
if err := dec(in); err != nil {
@ -767,6 +801,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateUserContactInfo",
Handler: _UserService_UpdateUserContactInfo_Handler,
},
{
MethodName: "UpdateUserWithdrawAddress",
Handler: _UserService_UpdateUserWithdrawAddress_Handler,
},
{
MethodName: "ChangeUserCountry",
Handler: _UserService_ChangeUserCountry_Handler,
@ -3171,6 +3209,7 @@ const (
UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/GeneratePrettyDisplayIDs"
UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/ListPrettyDisplayIDs"
UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/SetPrettyDisplayIDStatus"
UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/RecyclePrettyDisplayID"
UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/AdminGrantPrettyDisplayID"
)
@ -3186,6 +3225,7 @@ type UserPrettyDisplayIDAdminServiceClient interface {
GeneratePrettyDisplayIDs(ctx context.Context, in *GeneratePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(ctx context.Context, in *ListPrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(ctx context.Context, in *SetPrettyDisplayIDStatusRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error)
RecyclePrettyDisplayID(ctx context.Context, in *RecyclePrettyDisplayIDRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error)
}
@ -3257,6 +3297,16 @@ func (c *userPrettyDisplayIDAdminServiceClient) SetPrettyDisplayIDStatus(ctx con
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) RecyclePrettyDisplayID(ctx context.Context, in *RecyclePrettyDisplayIDRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGrantPrettyDisplayIDResponse)
@ -3279,6 +3329,7 @@ type UserPrettyDisplayIDAdminServiceServer interface {
GeneratePrettyDisplayIDs(context.Context, *GeneratePrettyDisplayIDsRequest) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(context.Context, *ListPrettyDisplayIDsRequest) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error)
RecyclePrettyDisplayID(context.Context, *RecyclePrettyDisplayIDRequest) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error)
mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer()
}
@ -3308,6 +3359,9 @@ func (UnimplementedUserPrettyDisplayIDAdminServiceServer) ListPrettyDisplayIDs(c
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPrettyDisplayIDStatus not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) RecyclePrettyDisplayID(context.Context, *RecyclePrettyDisplayIDRequest) (*PrettyDisplayIDResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RecyclePrettyDisplayID not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminGrantPrettyDisplayID not implemented")
}
@ -3441,6 +3495,24 @@ func _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler(srv inter
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecyclePrettyDisplayIDRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).RecyclePrettyDisplayID(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).RecyclePrettyDisplayID(ctx, req.(*RecyclePrettyDisplayIDRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGrantPrettyDisplayIDRequest)
if err := dec(in); err != nil {
@ -3490,6 +3562,10 @@ var UserPrettyDisplayIDAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetPrettyDisplayIDStatus",
Handler: _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler,
},
{
MethodName: "RecyclePrettyDisplayID",
Handler: _UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_Handler,
},
{
MethodName: "AdminGrantPrettyDisplayID",
Handler: _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -356,6 +356,59 @@ message TransferSalaryToCoinSellerResponse {
int64 rate_max_usd_minor = 8;
}
// FreezeSalaryWithdrawalRequest
message FreezeSalaryWithdrawalRequest {
string command_id = 1;
int64 user_id = 2;
string salary_asset_type = 3;
int64 salary_usd_minor = 4;
string reason = 5;
string app_code = 6;
string withdrawal_ref = 7;
}
message FreezeSalaryWithdrawalResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 salary_usd_minor = 3;
}
// SettleSalaryWithdrawalRequest
message SettleSalaryWithdrawalRequest {
string command_id = 1;
int64 user_id = 2;
string salary_asset_type = 3;
int64 salary_usd_minor = 4;
int64 operator_user_id = 5;
string reason = 6;
string app_code = 7;
string withdrawal_application_id = 8;
}
message SettleSalaryWithdrawalResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 salary_usd_minor = 3;
}
// ReleaseSalaryWithdrawalRequest
message ReleaseSalaryWithdrawalRequest {
string command_id = 1;
int64 user_id = 2;
string salary_asset_type = 3;
int64 salary_usd_minor = 4;
int64 operator_user_id = 5;
string reason = 6;
string app_code = 7;
string withdrawal_application_id = 8;
}
message ReleaseSalaryWithdrawalResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 salary_usd_minor = 3;
}
// Resource
message Resource {
string app_code = 1;
@ -983,6 +1036,7 @@ message RechargeBill {
int64 exchange_coin_amount = 16;
int64 exchange_usd_minor_amount = 17;
int64 created_at_ms = 18;
int64 provider_amount_minor = 19;
}
message ListRechargeBillsRequest {
@ -1485,6 +1539,8 @@ message WalletTransaction {
int64 counterparty_user_id = 9;
string room_id = 10;
int64 created_at_ms = 11;
int64 transfer_usd_minor = 12;
string transfer_currency_code = 13;
}
message ListWalletTransactionsRequest {
@ -2073,6 +2129,9 @@ service WalletService {
rpc ListCoinSellerSalaryExchangeRateTiers(ListCoinSellerSalaryExchangeRateTiersRequest) returns (ListCoinSellerSalaryExchangeRateTiersResponse);
rpc ExchangeSalaryToCoin(ExchangeSalaryToCoinRequest) returns (ExchangeSalaryToCoinResponse);
rpc TransferSalaryToCoinSeller(TransferSalaryToCoinSellerRequest) returns (TransferSalaryToCoinSellerResponse);
rpc FreezeSalaryWithdrawal(FreezeSalaryWithdrawalRequest) returns (FreezeSalaryWithdrawalResponse);
rpc SettleSalaryWithdrawal(SettleSalaryWithdrawalRequest) returns (SettleSalaryWithdrawalResponse);
rpc ReleaseSalaryWithdrawal(ReleaseSalaryWithdrawalRequest) returns (ReleaseSalaryWithdrawalResponse);
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse);
rpc GetResource(GetResourceRequest) returns (GetResourceResponse);
rpc CreateResource(CreateResourceRequest) returns (ResourceResponse);

View File

@ -214,6 +214,9 @@ const (
WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListCoinSellerSalaryExchangeRateTiers"
WalletService_ExchangeSalaryToCoin_FullMethodName = "/hyapp.wallet.v1.WalletService/ExchangeSalaryToCoin"
WalletService_TransferSalaryToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferSalaryToCoinSeller"
WalletService_FreezeSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/FreezeSalaryWithdrawal"
WalletService_SettleSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/SettleSalaryWithdrawal"
WalletService_ReleaseSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/ReleaseSalaryWithdrawal"
WalletService_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources"
WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource"
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
@ -313,6 +316,9 @@ type WalletServiceClient interface {
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(ctx context.Context, in *ExchangeSalaryToCoinRequest, opts ...grpc.CallOption) (*ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(ctx context.Context, in *TransferSalaryToCoinSellerRequest, opts ...grpc.CallOption) (*TransferSalaryToCoinSellerResponse, error)
FreezeSalaryWithdrawal(ctx context.Context, in *FreezeSalaryWithdrawalRequest, opts ...grpc.CallOption) (*FreezeSalaryWithdrawalResponse, error)
SettleSalaryWithdrawal(ctx context.Context, in *SettleSalaryWithdrawalRequest, opts ...grpc.CallOption) (*SettleSalaryWithdrawalResponse, error)
ReleaseSalaryWithdrawal(ctx context.Context, in *ReleaseSalaryWithdrawalRequest, opts ...grpc.CallOption) (*ReleaseSalaryWithdrawalResponse, error)
ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error)
GetResource(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*GetResourceResponse, error)
CreateResource(ctx context.Context, in *CreateResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error)
@ -531,6 +537,36 @@ func (c *walletServiceClient) TransferSalaryToCoinSeller(ctx context.Context, in
return out, nil
}
func (c *walletServiceClient) FreezeSalaryWithdrawal(ctx context.Context, in *FreezeSalaryWithdrawalRequest, opts ...grpc.CallOption) (*FreezeSalaryWithdrawalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(FreezeSalaryWithdrawalResponse)
err := c.cc.Invoke(ctx, WalletService_FreezeSalaryWithdrawal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SettleSalaryWithdrawal(ctx context.Context, in *SettleSalaryWithdrawalRequest, opts ...grpc.CallOption) (*SettleSalaryWithdrawalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SettleSalaryWithdrawalResponse)
err := c.cc.Invoke(ctx, WalletService_SettleSalaryWithdrawal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ReleaseSalaryWithdrawal(ctx context.Context, in *ReleaseSalaryWithdrawalRequest, opts ...grpc.CallOption) (*ReleaseSalaryWithdrawalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReleaseSalaryWithdrawalResponse)
err := c.cc.Invoke(ctx, WalletService_ReleaseSalaryWithdrawal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourcesResponse)
@ -1330,6 +1366,9 @@ type WalletServiceServer interface {
ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error)
FreezeSalaryWithdrawal(context.Context, *FreezeSalaryWithdrawalRequest) (*FreezeSalaryWithdrawalResponse, error)
SettleSalaryWithdrawal(context.Context, *SettleSalaryWithdrawalRequest) (*SettleSalaryWithdrawalResponse, error)
ReleaseSalaryWithdrawal(context.Context, *ReleaseSalaryWithdrawalRequest) (*ReleaseSalaryWithdrawalResponse, error)
ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error)
GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, error)
CreateResource(context.Context, *CreateResourceRequest) (*ResourceResponse, error)
@ -1457,6 +1496,15 @@ func (UnimplementedWalletServiceServer) ExchangeSalaryToCoin(context.Context, *E
func (UnimplementedWalletServiceServer) TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TransferSalaryToCoinSeller not implemented")
}
func (UnimplementedWalletServiceServer) FreezeSalaryWithdrawal(context.Context, *FreezeSalaryWithdrawalRequest) (*FreezeSalaryWithdrawalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FreezeSalaryWithdrawal not implemented")
}
func (UnimplementedWalletServiceServer) SettleSalaryWithdrawal(context.Context, *SettleSalaryWithdrawalRequest) (*SettleSalaryWithdrawalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SettleSalaryWithdrawal not implemented")
}
func (UnimplementedWalletServiceServer) ReleaseSalaryWithdrawal(context.Context, *ReleaseSalaryWithdrawalRequest) (*ReleaseSalaryWithdrawalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReleaseSalaryWithdrawal not implemented")
}
func (UnimplementedWalletServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListResources not implemented")
}
@ -1946,6 +1994,60 @@ func _WalletService_TransferSalaryToCoinSeller_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _WalletService_FreezeSalaryWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(FreezeSalaryWithdrawalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).FreezeSalaryWithdrawal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_FreezeSalaryWithdrawal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).FreezeSalaryWithdrawal(ctx, req.(*FreezeSalaryWithdrawalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SettleSalaryWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SettleSalaryWithdrawalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SettleSalaryWithdrawal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SettleSalaryWithdrawal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SettleSalaryWithdrawal(ctx, req.(*SettleSalaryWithdrawalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ReleaseSalaryWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReleaseSalaryWithdrawalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ReleaseSalaryWithdrawal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ReleaseSalaryWithdrawal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ReleaseSalaryWithdrawal(ctx, req.(*ReleaseSalaryWithdrawalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourcesRequest)
if err := dec(in); err != nil {
@ -3409,6 +3511,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "TransferSalaryToCoinSeller",
Handler: _WalletService_TransferSalaryToCoinSeller_Handler,
},
{
MethodName: "FreezeSalaryWithdrawal",
Handler: _WalletService_FreezeSalaryWithdrawal_Handler,
},
{
MethodName: "SettleSalaryWithdrawal",
Handler: _WalletService_SettleSalaryWithdrawal_Handler,
},
{
MethodName: "ReleaseSalaryWithdrawal",
Handler: _WalletService_ReleaseSalaryWithdrawal_Handler,
},
{
MethodName: "ListResources",
Handler: _WalletService_ListResources_Handler,

View File

@ -10,7 +10,9 @@
| package_name | platform | app_code | status |
| --- | --- | --- | --- |
| `com.org.laluparty` | `android` | `lalu` | `active` |
| `com.org.laluparty` | 任意或空 | `lalu` | `active` |
| `com.app.huwaa` | 任意或空 | `huwaa` | `active` |
| `com.chat.fami` | 任意或空 | `fami` | `active` |
新增 App 时,只新增 `apps` 注册表记录和对应 App 的基础配置/种子数据,不新建一套服务,也不复制业务代码。

View File

@ -0,0 +1,94 @@
# 数据大屏统计口径
本文档描述 `statistics-service` 提供给后台数据大屏的字段口径。查询入口是 `server/admin` 转发到 `statistics-service``/internal/v1/statistics/overview`,返回字段同时用于 `overview``daily_series``country_breakdown``daily_country_breakdown`
## 通用规则
- 时间范围统一是 `[start_ms, end_ms)`,包含开始毫秒,不包含结束毫秒。
- 默认统计时区是 `UTC`;传 `stat_tz=Asia/Shanghai` 时按中国自然日查询统计读模型。
- 国家和区域只使用事件里的国家/区域字段或统计服务保存的用户维度不用服务器所在地、IP 或本地时区推断。
- 查询只读 `statistics-service` 聚合表,不回查 wallet、room、game、user owner 明细表。
- `coin_total` 为兼容前端保留 JSON 字段名,展示名改为“用户金币数”。
## 字段口径
| 字段 | 展示名 | 口径 |
| --- | --- | --- |
| `app_code` | 应用编码 | 当前查询的租户编码。 |
| `stat_tz` | 统计时区 | `UTC``Asia/Shanghai`。 |
| `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 |
| `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 |
| `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 |
| `active_users` | 活跃用户 | `RoomUserJoined``GameOrderSettled` 和注册当天活跃写入 `stat_user_day_activity` 后按用户去重。 |
| `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 |
| `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 |
| `recharge_usd_minor` | 总充值 | `WalletRechargeRecorded.usd_minor_amount` 累加,单位是美元最小单位。 |
| `new_user_recharge_usd_minor` | 新增充值 | `recharge_sequence=1` 的充值金额累加。 |
| `coin_seller_recharge_usd_minor` | 币商充值 | 币商进货 USDT 和未识别为 Google/MifaPay/USDT-TRC20/币商转账的充值美元最小单位。 |
| `coin_seller_stock_coin` | 币商充值金币 | `WalletCoinSellerStockPurchased.coin_amount` 累加。 |
| `mifapay_recharge_usd_minor` | 三方充值 | `recharge_type=mifapay/v5pay` 的充值美元最小单位。 |
| `google_recharge_usd_minor` | Google 充值 | `recharge_type=google/google_play` 的充值美元最小单位。 |
| `coin_seller_transfer_coin` | 币商出货金币 | 币商向用户转入普通 COIN 金币数量。 |
| `coin_total` | 用户金币数 | 用户普通 COIN 钱包 `available+frozen` 余额总和;历史日取当天 23:59:59.999 前最后一次余额,今日按 `WalletBalanceChanged` 增量滚动更新;多日总览只取结束日快照,日序列展示每天快照。 |
| `consumed_coin` | 消耗金币 | 游戏投入 + 普通礼物投入 + lucky 礼物投入 + 转盘投入 + 发红包。游戏来自 `GameOrderSettled debit/bet`,房间礼物来自 `RoomGiftSent.coin_spent`,钱包只补 `direct_gift_debit``wheel_draw_debit``red_packet_create`。 |
| `output_coin` | 产出金币 | 游戏产出 + 收礼产出 + lucky 礼物产出 + 转盘奖励金币 + 红包领取和退回。游戏来自 `GameOrderSettled credit/payout`,收礼来自 `WalletGiftIncomeCredited`lucky 来自 `WalletLuckyGiftRewardCredited`,转盘金币来自 `WalletWheelRewardCredited`,红包来自 `red_packet_claim/refund`。 |
| `consume_output_ratio` | 消耗产出比 | `output_coin / consumed_coin`;消耗为 0 时返回 0。 |
| `consume_output_delta` | 消耗产出差额 | `consumed_coin - output_coin`。 |
| `platform_grant_coin` | 平台发放金币 | 排行榜奖励、活动奖励、签到/任务奖励、火箭奖励中的 COIN 金币发放;包括对应 `Wallet*RewardCredited``resource_grant` COIN 入账不含人工发放、装扮价值、lucky 返奖、转盘产出。 |
| `manual_grant_coin` | 人工发放金币 | `WalletBalanceChanged biz_type=manual_credit` 的普通 COIN 正向发放。 |
| `salary_usd_minor` | 工资总和(主播/代理) | 主播和代理工资钱包 `HOST_SALARY_USD + AGENCY_SALARY_USD``available+frozen` 余额总和;历史日取当天 23:59:59.999 前最后一次余额,今日由补数任务按当前已有分录刷新;多日总览只取结束日快照,日序列展示每天快照;不包含 BD/Admin 工资和 residual。 |
| `salary_transfer_coin` | 工资兑换金币 | 工资转给币商时币商 `COIN_SELLER_COIN` 入账金币数量,维度按发起转账用户。 |
| `mic_online_ms` | 麦上总时长 | `user_mic_daily_stats.mic_online_ms` 按日、国家、区域聚合。 |
| `mic_online_users` | 麦上用户数 | 当日 `mic_online_ms > 0` 的去重用户数。 |
| `avg_mic_online_ms` | 平均麦上时长 | `mic_online_ms / mic_online_users`。 |
| `gift_coin_spent` | 礼物流水 | 非机器人真实 `RoomGiftSent.coin_spent`,历史事件无 `coin_spent` 时回退 `gift_value`。 |
| `real_room_robot_normal_gift_coin` | 真人房机器人普通礼物 | `real_room_robot_gift=true` 且非 lucky/super lucky 的礼物金币。 |
| `real_room_robot_lucky_gift_coin` | 真人房机器人 lucky 礼物 | 真人房机器人 lucky 礼物金币。 |
| `real_room_robot_super_gift_coin` | 真人房机器人 super lucky 礼物 | 真人房机器人 super lucky 礼物金币。 |
| `real_room_robot_total_gift_coin` | 真人房机器人总礼物 | 上面三类机器人礼物金币合计。 |
| `real_room_robot_gift_room_count` | 真人房机器人投放房间数 | 有真人房机器人礼物的房间去重数。 |
| `real_room_robot_avg_room_gift_coin` | 真人房机器人房均投放 | `real_room_robot_total_gift_coin / real_room_robot_gift_room_count`。 |
| `lucky_gift_turnover` | lucky 礼物流水 | `RoomGiftSent.pool_id` 非空的送礼金币。 |
| `lucky_gift_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 |
| `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 |
| `lucky_gift_profit` | lucky 礼物利润 | `lucky_gift_turnover - lucky_gift_payout`。 |
| `lucky_gift_payout_rate` | lucky 礼物返奖率 | `lucky_gift_payout / lucky_gift_turnover`。 |
| `lucky_gift_profit_rate` | lucky 礼物利润率 | `lucky_gift_profit / lucky_gift_turnover`。 |
| `game_turnover` | 游戏流水 | `GameOrderSettled op_type=debit/bet` 的金币。 |
| `game_payout` | 游戏返奖 | `GameOrderSettled op_type=credit/payout` 的金币。 |
| `game_refund` | 游戏退款 | `GameOrderSettled op_type=refund/reverse` 的金币。 |
| `game_players` | 游戏玩家数 | 有游戏投入的用户按日、游戏平台、游戏去重后汇总。 |
| `game_profit` | 游戏利润 | `game_turnover - game_payout - game_refund`。 |
| `game_profit_rate` | 游戏利润率 | `game_profit / game_turnover`。 |
| `super_lucky_gift_turnover` | super lucky 流水 | `pool_id` 包含 `super_lucky` 的奖池流水。 |
| `super_lucky_gift_payout` | super lucky 返奖 | `pool_id` 包含 `super_lucky` 的奖池返奖。 |
| `super_lucky_gift_profit` | super lucky 利润 | `super_lucky_gift_turnover - super_lucky_gift_payout`。 |
| `super_lucky_gift_profit_rate` | super lucky 利润率 | `super_lucky_gift_profit / super_lucky_gift_turnover`。 |
| `arpu_usd_minor` | ARPU | `recharge_usd_minor / active_users`。 |
| `arppu_usd_minor` | ARPPU | `recharge_usd_minor / paid_users`。 |
| `up_value_usd_minor` | UP 值 | 当前与 ARPPU 同口径。 |
## 对比字段
以下字段是当前周期与上一等长周期的变化率:`recharge_delta_rate``new_user_recharge_delta_rate``active_users_delta_rate``paid_users_delta_rate``new_paid_users_delta_rate``arpu_delta_rate``arppu_delta_rate``gift_coin_spent_delta_rate``coin_seller_transfer_coin_delta_rate``lucky_gift_turnover_delta_rate``lucky_gift_payout_delta_rate``lucky_gift_profit_delta_rate``game_turnover_delta_rate``game_profit_delta_rate`
变化率公式是 `(当前值 - 上期值) / abs(上期值)`;上期为 0 且当前大于 0 时返回 1。
## 明细对象
| 字段 | 口径 |
| --- | --- |
| `retention` | `registered_users` 为注册 cohort`day1/day7/day30_users` 为注册后第 1/7/30 天有活跃的用户rate 为对应用户数除以注册用户数。 |
| `daily_series` | 按日返回本页主要字段;`coin_total``salary_usd_minor` 是每天余额快照,其它流水字段按当天累加。 |
| `country_breakdown` | 按国家返回本页主要字段;`coin_total``salary_usd_minor` 是查询结束日该国家余额快照,其它字段按查询周期累加。 |
| `daily_country_breakdown` | 按日 + 国家返回主要字段,用于国家弹窗明细。 |
| `game_ranking` | 按 `platform_code/game_id` 聚合游戏流水、返奖、退款、玩家数、利润和利润率。 |
| `lucky_gift_pools` | 按 `pool_id` 聚合 lucky 礼物流水、返奖、利润、返奖率和利润率。 |
| `report_metric_sources` | 返回字段来源和口径说明,供前端展示或排查。 |
## 当前边界
- 转盘礼物/道具的 RTP 价值在 activity wheel 记录里有 `rtp_value_coins`,但 wallet 资源 outbox 当前不是稳定 MQ 统计输入;统计服务现在只计转盘金币奖励,礼物价值需要资源奖励事件可稳定发布后接入。
- 平台发放的礼物价值同理,需要 `ResourceGranted/ResourceGroupGranted` 事件携带可统计的资源金币价值和来源;当前只可靠计入 COIN 入账和已存在的 `Wallet*RewardCredited` 金币奖励。
- 装扮价值不进入 `platform_grant_coin``output_coin`
- 后台人工发放只进入 `manual_grant_coin`,不进入 `platform_grant_coin`

View File

@ -6,7 +6,7 @@
- 每个用户有一个可展示、可复制的永久邀请码。
- 新用户注册或完成资料时可以选填邀请码。
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
- 非空邀请码必须能解析到同一 `app_code`、同一 `region_id` 下的有效邀请人。
- 邀请关系一旦绑定不可被客户端修改。
- 邀请关系必须落 MySQLRedis 只能做短 TTL 查询缓存。
- 邀请统计必须拆成 `invite_count``valid_invite_count`:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。
@ -39,7 +39,7 @@
### Code Ownership
- 邀请码属于用户不属于设备、session、三方账号或 Agency。
- 邀请码按 `app_code` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户。
- 邀请码按 `app_code + region_id` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户,不同区域用户也不能互相绑定邀请关系
- 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner历史邀请关系不删除。
- 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。
@ -49,7 +49,7 @@
| --- | --- |
| 注册不填邀请码 | 创建用户,不创建邀请关系 |
| 注册填写有效邀请码 | 创建用户,并绑定 `inviter_user_id` |
| 注册填写不存在、停用、跨 App 或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 |
| 注册填写不存在、停用、跨 App、跨区域或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 |
| 用户填写自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
| 已有邀请关系的用户再次提交邀请码 | 不修改原关系 |
| 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 |
@ -359,7 +359,7 @@ CREATE TABLE user_invite_counters (
3. 创建 `users`
4. 创建用户自己的 `user_invite_codes`
5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。
6. 校验 owner active、非自己、同 `app_code`
6. 校验 owner active、非自己、同 `app_code`、同 `region_id`
7. 插入 `user_invite_relations`
8. 创建 `third_party_identities``auth_sessions`
9. 写 `UserRegistered` / `UserInvited` outbox 事件。
@ -521,6 +521,7 @@ Redis miss 或异常时必须回 MySQL 或 fail-closed不能把不存在当
| 新用户填有效邀请码 | 注册成功,关系表写入 inviter |
| 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 |
| 邀请码跨 App | 返回 `INVALID_INVITE_CODE` |
| 邀请码跨区域 | 返回 `INVALID_INVITE_CODE` |
| 邀请人 disabled/banned | 返回 `INVALID_INVITE_CODE` |
| 用户尝试填自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
| 已绑定关系后再次提交邀请码 | 原关系不变 |

View File

@ -66,6 +66,8 @@ flowchart LR
## 口径定义
数据大屏字段级口径以 [数据大屏统计口径](./数据大屏统计口径.md) 为准;本节只保留统计服务的核心边界。
### 用户与活跃
| 指标 | 口径 |
@ -98,8 +100,8 @@ flowchart LR
| 指标 | 口径 |
| --- | --- |
| 礼物消耗 | `RoomGiftSent.gift_value` 累加 |
| 幸运礼物流水 | `RoomGiftSent.pool_id` 非空时的 `gift_value` 累加 |
| 礼物消耗 | `RoomGiftSent.coin_spent` 累加,历史事件无 `coin_spent` 时回退 `gift_value` |
| 幸运礼物流水 | `RoomGiftSent.pool_id` 非空时的 `coin_spent` 累加,历史事件无 `coin_spent` 时回退 `gift_value` |
| 幸运礼物付费用户 | `pool_id` 非空的送礼用户按 UTC 日去重 |
房间礼物事件必须在 wallet 扣费成功后产生。统计服务不读取钱包流水来反推礼物消耗。

View File

@ -5,11 +5,19 @@ github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1Ig
github.com/envoyproxy/go-control-plane v0.13.0/go.mod h1:GRaKG3dwvFoTg4nj7aXdZnvMg4d7nvT/wl9WgVXn3Q8=
github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=

174
pkg/dingtalkrobot/client.go Normal file
View File

@ -0,0 +1,174 @@
package dingtalkrobot
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Config 保存钉钉机器人请求所需的敏感参数;调用方必须从运行环境注入真实 webhook。
type Config struct {
WebhookURL string
Secret string
AtMobiles []string
AtAll bool
RequestTimeout time.Duration
}
// MarkdownMessage 是当前财务通知使用的唯一消息格式,避免业务层拼钉钉专属 JSON。
type MarkdownMessage struct {
Title string
Text string
}
// Client 封装钉钉机器人加签、HTTP 请求和返回码校验gateway/admin 复用同一套边界。
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
now func() time.Time
}
// New 创建钉钉机器人客户端httpClient 仅用于测试或特殊运行时注入。
func New(config Config, httpClient *http.Client) (*Client, error) {
webhookURL := strings.TrimSpace(config.WebhookURL)
if webhookURL == "" {
return nil, errors.New("dingtalk webhook url is required")
}
if _, err := url.ParseRequestURI(webhookURL); err != nil {
return nil, fmt.Errorf("dingtalk webhook url is invalid: %w", err)
}
timeout := config.RequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
if httpClient == nil {
httpClient = &http.Client{Timeout: timeout}
}
return &Client{
webhookURL: webhookURL,
secret: strings.TrimSpace(config.Secret),
atMobiles: compactStrings(config.AtMobiles),
atAll: config.AtAll,
httpClient: httpClient,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
// SendMarkdown 发送 markdown 消息;钉钉业务错误码也视为失败,调用方决定是否回滚业务。
func (c *Client) SendMarkdown(ctx context.Context, message MarkdownMessage) error {
if c == nil || c.httpClient == nil {
return errors.New("dingtalk client is not configured")
}
title := strings.TrimSpace(message.Title)
text := strings.TrimSpace(message.Text)
if title == "" || text == "" {
return errors.New("dingtalk markdown title and text are required")
}
body, err := json.Marshal(markdownPayload{
MsgType: "markdown",
Markdown: markdownBody{
Title: title,
Text: text,
},
At: atBody{
AtMobiles: c.atMobiles,
IsAtAll: c.atAll,
},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("dingtalk returned http %d: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
var result sendResponse
if err := json.Unmarshal(payload, &result); err != nil {
return fmt.Errorf("decode dingtalk response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
func (c *Client) signedWebhookURL() string {
if c.secret == "" {
return c.webhookURL
}
parsed, err := url.Parse(c.webhookURL)
if err != nil {
return c.webhookURL
}
timestamp := c.now().UnixMilli()
query := parsed.Query()
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
query.Set("sign", sign(timestamp, c.secret))
parsed.RawQuery = query.Encode()
return parsed.String()
}
func sign(timestamp int64, secret string) string {
// 钉钉加签要求 timestamp + "\n" + secret 作为明文secret 同时作为 HMAC key。
payload := fmt.Sprintf("%d\n%s", timestamp, secret)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
type markdownPayload struct {
MsgType string `json:"msgtype"`
Markdown markdownBody `json:"markdown"`
At atBody `json:"at"`
}
type markdownBody struct {
Title string `json:"title"`
Text string `json:"text"`
}
type atBody struct {
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
}
type sendResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}

View File

@ -62,6 +62,7 @@ var catalog = map[Code]Spec{
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
RegionMismatch: spec(codes.PermissionDenied, httpStatusForbidden, RegionMismatch, "不是同一个地区"),
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
DeviceAlreadyRegistered: spec(
codes.FailedPrecondition,

View File

@ -69,6 +69,8 @@ const (
RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT"
// RegionDisabled 表示区域已经停用,不能继续配置国家。
RegionDisabled Code = "REGION_DISABLED"
// RegionMismatch 表示当前动作要求双方同一区域,但用户、组织或上级身份的实时区域不一致。
RegionMismatch Code = "REGION_MISMATCH"
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
InvalidInviteCode Code = "INVALID_INVITE_CODE"
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。

View File

@ -94,6 +94,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(DeviceAlreadyRegistered),
publicMessage: "device already registered",
},
{
name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
code: RegionMismatch,
grpcCode: codes.PermissionDenied,
httpStatus: httpStatusForbidden,
publicCode: string(RegionMismatch),
publicMessage: "不是同一个地区",
},
}
for _, test := range tests {

View File

@ -0,0 +1,12 @@
USE hyapp_wallet;
-- 金币流水和统计回放按 app/asset/user/time 顺序读取 wallet_entries
-- 这个索引让单资产余额回放和用户金币流水命中同一条有序访问路径,避免全表扫描后排序。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_entries' AND INDEX_NAME = 'idx_wallet_entries_asset_user_time') = 0,
'ALTER TABLE wallet_entries ADD INDEX idx_wallet_entries_asset_user_time (app_code, asset_type, user_id, created_at_ms, entry_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,2 @@
ALTER TABLE external_recharge_orders
ADD INDEX idx_external_recharge_orders_wallet_tx (app_code, wallet_transaction_id);

View File

@ -0,0 +1,12 @@
USE hyapp_wallet;
-- 主播提现后台页会用来源工资用户反查币商侧 COIN_SELLER_COIN 分录;
-- counterparty_user_id 是工资来源用户,组合索引避免按资产时间大范围扫描后再过滤来源用户。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_entries' AND INDEX_NAME = 'idx_wallet_entries_asset_counterparty_time') = 0,
'ALTER TABLE wallet_entries ADD INDEX idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -17,6 +17,7 @@ import (
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
@ -39,6 +40,8 @@ import (
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
@ -47,6 +50,7 @@ import (
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
hostwithdrawalmodule "hyapp-admin-server/internal/modules/hostwithdrawal"
inviteactivityrewardmodule "hyapp-admin-server/internal/modules/inviteactivityreward"
jobmodule "hyapp-admin-server/internal/modules/job"
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
@ -66,6 +70,7 @@ import (
roomturnoverrewardmodule "hyapp-admin-server/internal/modules/roomturnoverreward"
searchmodule "hyapp-admin-server/internal/modules/search"
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
teammodule "hyapp-admin-server/internal/modules/team"
teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy"
teamsalarysettlementmodule "hyapp-admin-server/internal/modules/teamsalarysettlement"
uploadmodule "hyapp-admin-server/internal/modules/upload"
@ -80,6 +85,9 @@ import (
"hyapp-admin-server/internal/service"
mysqlDriver "github.com/go-sql-driver/mysql"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
@ -171,6 +179,16 @@ func main() {
defer robotProfileDB.Close()
robotProfileSource = gamemanagementmodule.NewLikeiRobotProfileSource(robotProfileDB)
}
moneyRegionSources, closeMoneyRegionSources, err := connectMoneyRegionSources(context.Background(), cfg.MoneyRegionSources)
if err != nil {
fatalRuntime("connect_money_region_sources_failed", err)
}
defer closeMoneyRegionSources(context.Background())
dashboardExternalSources, closeDashboardExternalSources, err := connectDashboardExternalSources(context.Background(), cfg.DashboardExternalSources, dashboardRegionResolvers(moneyRegionSources)...)
if err != nil {
fatalRuntime("connect_dashboard_external_sources_failed", err)
}
defer closeDashboardExternalSources()
store := repository.New(db)
if cfg.MySQLAutoMigrate {
@ -253,6 +271,24 @@ func main() {
}
objectUploader = uploader
}
var financeApplicationOptions []financeapplicationmodule.Option
if cfg.FinanceNotifications.DingTalk.Enabled && strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) != "" {
client, err := dingtalkclient.New(dingtalkclient.Config{
WebhookURL: cfg.FinanceNotifications.DingTalk.WebhookURL,
Secret: cfg.FinanceNotifications.DingTalk.Secret,
AtMobiles: cfg.FinanceNotifications.DingTalk.AtMobiles,
AtAll: cfg.FinanceNotifications.DingTalk.AtAll,
RequestTimeout: cfg.FinanceNotifications.DingTalk.RequestTimeout,
}, nil)
if err != nil {
fatalRuntime("create_finance_dingtalk_client_failed", err)
}
financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithApplicationNotifier(financeapplicationmodule.NewDingTalkNotifier(client)))
}
financeApplicationOptions = append(financeApplicationOptions,
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
)
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
@ -268,8 +304,10 @@ func main() {
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn)),
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...)),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
@ -283,12 +321,13 @@ func main() {
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...)),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
@ -302,6 +341,7 @@ func main() {
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Team: teammodule.New(store, auditHandler),
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
TeamSalarySettlement: teamSalarySettlementHandler,
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
@ -354,6 +394,111 @@ func dialBackendGRPC(addr string) (*grpc.ClientConn, error) {
)
}
func connectMoneyRegionSources(ctx context.Context, configs []config.MoneyRegionSourceConfig) ([]paymentmodule.MoneyRegionSource, func(context.Context), error) {
sources := []paymentmodule.MoneyRegionSource{}
clients := []*mongo.Client{}
cleanup := func(closeCtx context.Context) {
for _, client := range clients {
if client != nil {
_ = client.Disconnect(closeCtx)
}
}
}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
continue
}
connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
// 财务范围页面依赖 legacy likei 区域目录;启用后启动阶段先 ping避免运行时静默漏掉 Yumi/Aslan 区域。
client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
if err != nil {
cancel()
cleanup(ctx)
return nil, nil, err
}
if err := client.Ping(connectCtx, readpref.Primary()); err != nil {
cancel()
_ = client.Disconnect(ctx)
cleanup(ctx)
return nil, nil, err
}
cancel()
clients = append(clients, client)
source := paymentmodule.NewMongoMoneyRegionSource(client.Database(sourceConfig.MongoDatabase), sourceConfig)
if source != nil {
sources = append(sources, source)
}
}
return sources, cleanup, nil
}
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
for _, source := range sources {
resolver, ok := source.(dashboardmodule.ExternalDashboardRegionResolver)
if ok && resolver != nil {
resolvers = append(resolvers, resolver)
}
}
return resolvers
}
func connectDashboardExternalSources(ctx context.Context, configs []config.DashboardExternalSourceConfig, regionResolvers ...dashboardmodule.ExternalDashboardRegionResolver) ([]dashboardmodule.ExternalDashboardSource, func(), error) {
sources := []dashboardmodule.ExternalDashboardSource{}
dbs := []*sql.DB{}
cleanup := func() {
for _, db := range dbs {
if db != nil {
_ = db.Close()
}
}
}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
continue
}
switch strings.ToLower(strings.TrimSpace(sourceConfig.SourceType)) {
case "aslan_http":
// Aslan 大屏数据由对方 console 免鉴权接口返回;这里不建立数据库连接,避免 admin 绕过 Aslan 服务端口径。
source, err := dashboardmodule.NewAslanHTTPDashboardSource(sourceConfig, regionResolvers...)
if err != nil {
cleanup()
return nil, nil, err
}
if source != nil {
sources = append(sources, source)
}
continue
case "", "mysql":
default:
cleanup()
return nil, nil, errors.New("unsupported dashboard external source type: " + sourceConfig.SourceType)
}
db, err := sql.Open("mysql", sourceConfig.MySQLDSN)
if err != nil {
cleanup()
return nil, nil, err
}
pingCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
// Yumi 大屏使用外接项目 dashboard-cdc-worker 的只读聚合库;启动期先 ping避免 admin 正常启动但 Yumi 查询静默回退到错误事实域。
if err := db.PingContext(pingCtx); err != nil {
cancel()
_ = db.Close()
cleanup()
return nil, nil, err
}
cancel()
source := dashboardmodule.NewMySQLExternalDashboardSource(db, sourceConfig, regionResolvers...)
if source == nil {
_ = db.Close()
continue
}
dbs = append(dbs, db)
sources = append(sources, source)
}
return sources, cleanup, nil
}
func ensureDatabase(dsn string) error {
parsed, err := mysqlDriver.ParseDSN(dsn)
if err != nil {

View File

@ -14,6 +14,15 @@ wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?par
robot_profile_source:
mysql_dsn: "likei_reader:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=UTC"
request_timeout: "5s"
money_region_sources:
- enabled: true
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: "mongodb://REPLACE_ME"
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
mysql_auto_migrate: false
migrations:
enabled: true
@ -36,7 +45,7 @@ bootstrap:
seed_demo_data: false
username: "admin"
name: "admin"
team: "平台运维"
team: "运营团队"
password: "REPLACE_WITH_TEMP_BOOTSTRAP_PASSWORD"
user_service:
addr: "10.2.1.16:13005"
@ -59,6 +68,31 @@ game_service:
statistics_service:
base_url: "http://10.2.1.16:13010"
request_timeout: "3s"
dashboard_external_sources:
- enabled: true
type: "mysql"
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mysql_dsn: "likei_reader:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=Asia%2FRiyadh"
stat_timezone: "Asia/Riyadh"
request_timeout: "5s"
- enabled: true
type: "aslan_http"
app_code: "aslan"
app_name: "Aslan"
base_url: "https://console.atuchat.com"
overview_path: "/console/datav/aslan/region-country/statistics"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
finance_notifications:
dingtalk:
enabled: true
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
at_mobiles: []
at_all: false
request_timeout: "3s"
tencent-cos:
enabled: true
secret-id: "REPLACE_ME"

View File

@ -14,6 +14,15 @@ wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&
robot_profile_source:
mysql_dsn: ""
request_timeout: "5s"
money_region_sources:
- enabled: false
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: ""
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
mysql_auto_migrate: true
migrations:
enabled: true
@ -35,7 +44,7 @@ bootstrap:
seed_demo_data: true
username: "admin"
name: "admin"
team: "平台运维"
team: "运营团队"
password: "admin123"
user_service:
addr: "127.0.0.1:13005"
@ -58,6 +67,31 @@ game_service:
statistics_service:
base_url: "http://127.0.0.1:13010"
request_timeout: "3s"
dashboard_external_sources:
- enabled: false
type: "mysql"
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mysql_dsn: ""
stat_timezone: "Asia/Riyadh"
request_timeout: "5s"
- enabled: false
type: "aslan_http"
app_code: "aslan"
app_name: "Aslan"
base_url: "https://console.atuchat.com"
overview_path: "/console/datav/aslan/region-country/statistics"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
finance_notifications:
dingtalk:
enabled: true
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
at_mobiles: []
at_all: false
request_timeout: "3s"
tencent-cos:
enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"

View File

@ -9,7 +9,8 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/redis/go-redis/v9 v9.18.0
github.com/tencentyun/cos-go-sdk-v5 v0.7.66
golang.org/x/crypto v0.32.0
go.mongodb.org/mongo-driver/v2 v2.7.0
golang.org/x/crypto v0.33.0
google.golang.org/grpc v1.68.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
@ -43,6 +44,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@ -53,9 +55,14 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
)

View File

@ -62,6 +62,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@ -105,23 +107,59 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=

View File

@ -13,35 +13,38 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
RobotService RobotServiceConfig `yaml:"robot_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
RobotService RobotServiceConfig `yaml:"robot_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
}
type MigrationConfig struct {
@ -85,11 +88,48 @@ type StatisticsServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type DashboardExternalSourceConfig struct {
Enabled bool `yaml:"enabled"`
SourceType string `yaml:"type"`
AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MySQLDSN string `yaml:"mysql_dsn"`
BaseURL string `yaml:"base_url"`
OverviewPath string `yaml:"overview_path"`
StatTimezone string `yaml:"stat_timezone"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type FinanceNotificationsConfig struct {
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
}
type DingTalkRobotConfig struct {
Enabled bool `yaml:"enabled"`
WebhookURL string `yaml:"webhook_url"`
Secret string `yaml:"secret"`
AtMobiles []string `yaml:"at_mobiles"`
AtAll bool `yaml:"at_all"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type RobotProfileSourceConfig struct {
MySQLDSN string `yaml:"mysql_dsn"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type MoneyRegionSourceConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"`
MongoCollection string `yaml:"mongo_collection"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -138,6 +178,26 @@ func Default() Config {
RobotProfileSource: RobotProfileSourceConfig{
RequestTimeout: 5 * time.Second,
},
MoneyRegionSources: []MoneyRegionSourceConfig{
{
Enabled: false,
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
MongoDatabase: "tarab_all",
MongoCollection: "sys_region_config",
RequestTimeout: 5 * time.Second,
},
{
Enabled: false,
AppCode: "aslan",
AppName: "Aslan",
SysOrigin: "ATYOU",
MongoDatabase: "tarab_all",
MongoCollection: "sys_region_config",
RequestTimeout: 5 * time.Second,
},
},
MySQLAutoMigrate: true,
Migrations: MigrationConfig{
Enabled: true,
@ -158,7 +218,7 @@ func Default() Config {
SeedDemoData: true,
Username: "admin",
Name: "admin",
Team: "平台运维",
Team: "运营团队",
Password: "admin123",
},
BootstrapPassword: "admin123",
@ -190,6 +250,33 @@ func Default() Config {
BaseURL: "http://127.0.0.1:13010",
RequestTimeout: 3 * time.Second,
},
DashboardExternalSources: []DashboardExternalSourceConfig{
{
Enabled: false,
SourceType: "mysql",
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
StatTimezone: "Asia/Riyadh",
RequestTimeout: 5 * time.Second,
},
{
Enabled: false,
SourceType: "aslan_http",
AppCode: "aslan",
AppName: "Aslan",
BaseURL: "https://console.atuchat.com",
OverviewPath: "/console/datav/aslan/region-country/statistics",
StatTimezone: "Asia/Shanghai",
RequestTimeout: 5 * time.Second,
},
},
FinanceNotifications: FinanceNotificationsConfig{
DingTalk: DingTalkRobotConfig{
Enabled: true,
RequestTimeout: 3 * time.Second,
},
},
TencentCOS: TencentCOSConfig{
Enabled: false,
ObjectPrefix: "admin",
@ -265,7 +352,7 @@ func (cfg *Config) Normalize() {
cfg.Bootstrap.Name = cfg.Bootstrap.Username
}
if cfg.Bootstrap.Team == "" {
cfg.Bootstrap.Team = "平台运维"
cfg.Bootstrap.Team = "运营团队"
}
if cfg.Jobs.LeaseTTL <= 0 {
cfg.Jobs.LeaseTTL = 2 * time.Minute
@ -316,10 +403,68 @@ func (cfg *Config) Normalize() {
if cfg.StatisticsService.RequestTimeout <= 0 {
cfg.StatisticsService.RequestTimeout = 3 * time.Second
}
for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index]
// 外接 App 大屏的数据源类型在配置层归一化;查询层只根据类型读取对应的外部聚合事实,不回退到 hyapp statistics-service。
source.SourceType = strings.ToLower(strings.TrimSpace(source.SourceType))
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MySQLDSN = strings.TrimSpace(source.MySQLDSN)
source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/")
source.OverviewPath = "/" + strings.TrimLeft(strings.TrimSpace(source.OverviewPath), "/")
if source.OverviewPath == "/" {
source.OverviewPath = ""
}
if source.SourceType == "" {
if source.BaseURL != "" || source.OverviewPath != "" {
source.SourceType = "aslan_http"
} else {
source.SourceType = "mysql"
}
}
source.StatTimezone = strings.TrimSpace(source.StatTimezone)
if source.StatTimezone == "" {
if source.SourceType == "aslan_http" {
source.StatTimezone = "Asia/Shanghai"
} else {
source.StatTimezone = "Asia/Riyadh"
}
}
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyFinanceNotificationEnvOverrides()
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles)
if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second
}
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
}
for index := range cfg.MoneyRegionSources {
source := &cfg.MoneyRegionSources[index]
// legacy 区域来源只服务财务范围目录这里先规整配置handler 层只处理业务过滤,不重复处理大小写和默认集合名。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
if source.MongoDatabase == "" {
source.MongoDatabase = "tarab_all"
}
source.MongoCollection = strings.TrimSpace(source.MongoCollection)
if source.MongoCollection == "" {
source.MongoCollection = "sys_region_config"
}
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -435,12 +580,76 @@ func (cfg Config) Validate() error {
if cfg.StatisticsService.RequestTimeout <= 0 {
return errors.New("statistics_service.request_timeout must be greater than 0")
}
for index, source := range cfg.DashboardExternalSources {
if !source.Enabled {
continue
}
if strings.TrimSpace(source.AppCode) == "" {
return fmt.Errorf("dashboard_external_sources[%d].app_code is required when enabled", index)
}
switch strings.ToLower(strings.TrimSpace(source.SourceType)) {
case "mysql":
if strings.TrimSpace(source.SysOrigin) == "" {
return fmt.Errorf("dashboard_external_sources[%d].sys_origin is required when mysql source is enabled", index)
}
if strings.TrimSpace(source.MySQLDSN) == "" {
return fmt.Errorf("dashboard_external_sources[%d].mysql_dsn is required when mysql source is enabled", index)
}
case "aslan_http":
if strings.TrimSpace(source.BaseURL) == "" {
return fmt.Errorf("dashboard_external_sources[%d].base_url is required when aslan_http source is enabled", index)
}
if !strings.HasPrefix(source.BaseURL, "http://") && !strings.HasPrefix(source.BaseURL, "https://") {
return fmt.Errorf("dashboard_external_sources[%d].base_url must be an absolute url", index)
}
if strings.TrimSpace(source.OverviewPath) == "" {
return fmt.Errorf("dashboard_external_sources[%d].overview_path is required when aslan_http source is enabled", index)
}
default:
return fmt.Errorf("dashboard_external_sources[%d].type must be one of mysql, aslan_http", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
}
}
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.WebhookURL != "" {
if !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "http://") && !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "https://") {
return errors.New("finance_notifications.dingtalk.webhook_url must be an absolute url")
}
}
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
return errors.New("finance_notifications.dingtalk.request_timeout must be greater than 0")
}
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
}
if cfg.RobotProfileSource.RequestTimeout <= 0 {
return errors.New("robot_profile_source.request_timeout must be greater than 0")
}
for index, source := range cfg.MoneyRegionSources {
if !source.Enabled {
continue
}
// 线上 likei Mongo 是外部只读事实来源;启用但缺 URI/库/集合时必须启动失败,不能让财务范围静默漏掉 Yumi 区域。
if strings.TrimSpace(source.AppCode) == "" {
return fmt.Errorf("money_region_sources[%d].app_code is required when enabled", index)
}
if strings.TrimSpace(source.SysOrigin) == "" {
return fmt.Errorf("money_region_sources[%d].sys_origin is required when enabled", index)
}
if strings.TrimSpace(source.MongoURI) == "" {
return fmt.Errorf("money_region_sources[%d].mongo_uri is required when enabled", index)
}
if strings.TrimSpace(source.MongoDatabase) == "" {
return fmt.Errorf("money_region_sources[%d].mongo_database is required when enabled", index)
}
if strings.TrimSpace(source.MongoCollection) == "" {
return fmt.Errorf("money_region_sources[%d].mongo_collection is required when enabled", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("money_region_sources[%d].request_timeout must be greater than 0", index)
}
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")
@ -470,6 +679,54 @@ func (cfg Config) Validate() error {
return nil
}
func (cfg *Config) applyFinanceNotificationEnvOverrides() {
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" {
cfg.FinanceNotifications.DingTalk.Enabled = parseBoolEnv(value)
}
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" {
// 机器人 Webhook 属于密钥级配置;生产可以只在运行环境注入,避免把真实 access_token 写进仓库里的 YAML。
cfg.FinanceNotifications.DingTalk.WebhookURL = value
cfg.FinanceNotifications.DingTalk.Enabled = true
}
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" {
cfg.FinanceNotifications.DingTalk.Secret = value
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" {
cfg.FinanceNotifications.DingTalk.AtMobiles = strings.Split(value, ",")
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" {
cfg.FinanceNotifications.DingTalk.AtAll = parseBoolEnv(value)
}
}
func firstEnv(keys ...string) string {
for _, key := range keys {
if value := os.Getenv(key); strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func parseBoolEnv(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func validEnvironment(value string) bool {
switch value {
case "local", "dev", "staging", "prod":

View File

@ -33,6 +33,12 @@ func TestLoadConfigYAML(t *testing.T) {
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS)
}
if !cfg.FinanceNotifications.DingTalk.Enabled {
t.Fatalf("finance dingtalk notification should be enabled by default")
}
if cfg.FinanceNotifications.DingTalk.WebhookURL == "" || cfg.FinanceNotifications.DingTalk.Secret == "" {
t.Fatalf("finance dingtalk notification config should be populated: %#v", cfg.FinanceNotifications.DingTalk)
}
}
func TestLoadEmptyPathUsesDefault(t *testing.T) {
@ -45,3 +51,23 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) {
t.Fatalf("Load(\"\") = %#v, want default", cfg)
}
}
func TestFinanceDingTalkEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "https://oapi.dingtalk.com/robot/send?access_token=test")
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "SEC_TEST")
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES", "13800138000, 13900139000")
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL", "false")
cfg := Default()
cfg.Normalize()
if !cfg.FinanceNotifications.DingTalk.Enabled {
t.Fatal("DingTalk should be enabled when webhook env is provided")
}
if cfg.FinanceNotifications.DingTalk.Secret != "SEC_TEST" {
t.Fatalf("secret mismatch: %q", cfg.FinanceNotifications.DingTalk.Secret)
}
if len(cfg.FinanceNotifications.DingTalk.AtMobiles) != 2 {
t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles)
}
}

View File

@ -0,0 +1,169 @@
package dingtalk
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Config struct {
WebhookURL string
Secret string
AtMobiles []string
AtAll bool
RequestTimeout time.Duration
}
type MarkdownMessage struct {
Title string
Text string
}
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
now func() time.Time
}
func New(config Config, httpClient *http.Client) (*Client, error) {
webhookURL := strings.TrimSpace(config.WebhookURL)
if webhookURL == "" {
return nil, errors.New("dingtalk webhook url is required")
}
if _, err := url.ParseRequestURI(webhookURL); err != nil {
return nil, fmt.Errorf("dingtalk webhook url is invalid: %w", err)
}
timeout := config.RequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
if httpClient == nil {
httpClient = &http.Client{Timeout: timeout}
}
return &Client{
webhookURL: webhookURL,
secret: strings.TrimSpace(config.Secret),
atMobiles: compactStrings(config.AtMobiles),
atAll: config.AtAll,
httpClient: httpClient,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
func (c *Client) SendMarkdown(ctx context.Context, message MarkdownMessage) error {
if c == nil || c.httpClient == nil {
return errors.New("dingtalk client is not configured")
}
title := strings.TrimSpace(message.Title)
text := strings.TrimSpace(message.Text)
if title == "" || text == "" {
return errors.New("dingtalk markdown title and text are required")
}
body, err := json.Marshal(markdownPayload{
MsgType: "markdown",
Markdown: markdownBody{
Title: title,
Text: text,
},
At: atBody{
AtMobiles: c.atMobiles,
IsAtAll: c.atAll,
},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("dingtalk returned http %d: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
var result sendResponse
if err := json.Unmarshal(payload, &result); err != nil {
return fmt.Errorf("decode dingtalk response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
func (c *Client) signedWebhookURL() string {
if c.secret == "" {
return c.webhookURL
}
parsed, err := url.Parse(c.webhookURL)
if err != nil {
return c.webhookURL
}
timestamp := c.now().UnixMilli()
query := parsed.Query()
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
query.Set("sign", sign(timestamp, c.secret))
parsed.RawQuery = query.Encode()
return parsed.String()
}
func sign(timestamp int64, secret string) string {
// 钉钉加签要求 timestamp + "\n" + secret 作为明文secret 同时作为 HMAC key签名再 Base64 并作为 URL query 参数传递。
payload := fmt.Sprintf("%d\n%s", timestamp, secret)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
type markdownPayload struct {
MsgType string `json:"msgtype"`
Markdown markdownBody `json:"markdown"`
At atBody `json:"at"`
}
type markdownBody struct {
Title string `json:"title"`
Text string `json:"text"`
}
type atBody struct {
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
}
type sendResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}

View File

@ -0,0 +1,75 @@
package dingtalk
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSendMarkdownSignsRequestAndSendsPayload(t *testing.T) {
var captured struct {
Query map[string]string
Body markdownPayload
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.Query = map[string]string{
"access_token": r.URL.Query().Get("access_token"),
"timestamp": r.URL.Query().Get("timestamp"),
"sign": r.URL.Query().Get("sign"),
}
if err := json.NewDecoder(r.Body).Decode(&captured.Body); err != nil {
t.Fatalf("decode request body: %v", err)
}
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`))
}))
defer server.Close()
client, err := New(Config{
WebhookURL: server.URL + "?access_token=token",
Secret: "SEC_TEST",
AtMobiles: []string{" 13800138000 ", ""},
AtAll: false,
}, server.Client())
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.now = func() time.Time { return time.UnixMilli(1710000000123).UTC() }
if err := client.SendMarkdown(context.Background(), MarkdownMessage{Title: "财务申请", Text: "### 财务申请待审核"}); err != nil {
t.Fatalf("SendMarkdown() error = %v", err)
}
if captured.Query["access_token"] != "token" {
t.Fatalf("access token mismatch: %q", captured.Query["access_token"])
}
if captured.Query["timestamp"] != "1710000000123" {
t.Fatalf("timestamp mismatch: %q", captured.Query["timestamp"])
}
if captured.Query["sign"] != sign(1710000000123, "SEC_TEST") {
t.Fatalf("sign mismatch: %q", captured.Query["sign"])
}
if captured.Body.MsgType != "markdown" || captured.Body.Markdown.Title != "财务申请" || captured.Body.Markdown.Text == "" {
t.Fatalf("markdown payload mismatch: %+v", captured.Body)
}
if len(captured.Body.At.AtMobiles) != 1 || captured.Body.At.AtMobiles[0] != "13800138000" {
t.Fatalf("at mobiles mismatch: %+v", captured.Body.At.AtMobiles)
}
}
func TestSendMarkdownReturnsDingTalkError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errcode":310000,"errmsg":"keywords not in content"}`))
}))
defer server.Close()
client, err := New(Config{WebhookURL: server.URL}, server.Client())
if err != nil {
t.Fatalf("New() error = %v", err)
}
if err := client.SendMarkdown(context.Background(), MarkdownMessage{Title: "财务申请", Text: "text"}); err == nil {
t.Fatal("SendMarkdown() error = nil, want dingtalk errcode")
}
}

View File

@ -25,6 +25,7 @@ type Client interface {
GeneratePrettyDisplayIDs(ctx context.Context, req GeneratePrettyDisplayIDsRequest) (*PrettyDisplayIDGenerationBatch, error)
ListPrettyDisplayIDs(ctx context.Context, req ListPrettyDisplayIDsRequest) ([]*PrettyDisplayID, int64, error)
SetPrettyDisplayIDStatus(ctx context.Context, req SetPrettyDisplayIDStatusRequest) (*PrettyDisplayID, error)
RecyclePrettyDisplayID(ctx context.Context, req RecyclePrettyDisplayIDRequest) (*PrettyDisplayID, error)
AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error)
ListCountries(ctx context.Context, req ListCountriesRequest) ([]*Country, error)
UpdateCountry(ctx context.Context, req UpdateCountryRequest) (*Country, error)
@ -38,7 +39,9 @@ type Client interface {
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error)
SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error)
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error)
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
DeleteAgency(ctx context.Context, req DeleteAgencyRequest) (*Agency, error)
SetAgencyStatus(ctx context.Context, req SetAgencyStatusRequest) (*Agency, error)
@ -533,6 +536,12 @@ type GetCoinSellerProfileRequest struct {
UserID int64
}
type GetUserRoleSummaryRequest struct {
RequestID string
Caller string
UserID int64
}
type CreateAgencyRequest struct {
RequestID string
Caller string
@ -545,6 +554,16 @@ type CreateAgencyRequest struct {
Reason string
}
type AdminAddAgencyHostRequest struct {
RequestID string
Caller string
CommandID string
AdminUserID int64
AgencyID int64
TargetUserID int64
Reason string
}
type CloseAgencyRequest struct {
RequestID string
Caller string
@ -676,6 +695,22 @@ type CoinSellerProfile struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type UserRoleSummary struct {
UserID int64 `json:"userId,string"`
IsHost bool `json:"isHost"`
IsAgency bool `json:"isAgency"`
IsBD bool `json:"isBd"`
IsBDLeader bool `json:"isBdLeader"`
IsCoinSeller bool `json:"isCoinSeller"`
HostStatus string `json:"hostStatus"`
AgencyID int64 `json:"agencyId,string"`
BDID int64 `json:"bdId,string"`
BDStatus string `json:"bdStatus"`
CoinSellerStatus string `json:"coinSellerStatus"`
PendingRoleInvitations int64 `json:"pendingRoleInvitations"`
IsManager bool `json:"isManager"`
}
type AgencyMembership struct {
MembershipID int64 `json:"membershipId,string"`
AgencyID int64 `json:"agencyId,string"`
@ -782,6 +817,17 @@ func (c *GRPCClient) GetCoinSellerProfile(ctx context.Context, req GetCoinSeller
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
}
func (c *GRPCClient) GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error) {
resp, err := c.hostClient.GetUserRoleSummary(ctx, &userv1.GetUserRoleSummaryRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
UserId: req.UserID,
})
if err != nil {
return nil, err
}
return fromProtoUserRoleSummary(resp.GetSummary()), nil
}
func (c *GRPCClient) CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error) {
resp, err := c.hostAdminClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
@ -803,6 +849,25 @@ func (c *GRPCClient) CreateAgency(ctx context.Context, req CreateAgencyRequest)
}, nil
}
func (c *GRPCClient) AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error) {
resp, err := c.hostAdminClient.AdminAddAgencyHost(ctx, &userv1.AdminAddAgencyHostRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
CommandId: req.CommandID,
AdminUserId: req.AdminUserID,
AgencyId: req.AgencyID,
TargetUserId: req.TargetUserID,
Reason: req.Reason,
})
if err != nil {
return nil, err
}
return &CreateAgencyResult{
Agency: fromProtoAgency(resp.GetAgency()),
HostProfile: fromProtoHostProfile(resp.GetHostProfile()),
Membership: fromProtoAgencyMembership(resp.GetMembership()),
}, nil
}
func (c *GRPCClient) CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error) {
resp, err := c.hostAdminClient.CloseAgency(ctx, &userv1.CloseAgencyRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
@ -938,6 +1003,27 @@ func fromProtoCoinSellerProfile(profile *userv1.CoinSellerProfile) *CoinSellerPr
}
}
func fromProtoUserRoleSummary(summary *userv1.UserRoleSummary) *UserRoleSummary {
if summary == nil {
return nil
}
return &UserRoleSummary{
UserID: summary.GetUserId(),
IsHost: summary.GetIsHost(),
IsAgency: summary.GetIsAgency(),
IsBD: summary.GetIsBd(),
IsBDLeader: summary.GetIsBdLeader(),
IsCoinSeller: summary.GetIsCoinSeller(),
HostStatus: summary.GetHostStatus(),
AgencyID: summary.GetAgencyId(),
BDID: summary.GetBdId(),
BDStatus: summary.GetBdStatus(),
CoinSellerStatus: summary.GetCoinSellerStatus(),
PendingRoleInvitations: summary.GetPendingRoleInvitations(),
IsManager: summary.GetIsManager(),
}
}
func fromProtoAgencyMembership(membership *userv1.AgencyMembership) *AgencyMembership {
if membership == nil {
return nil

View File

@ -31,6 +31,7 @@ type PrettyDisplayID struct {
DisplayUserID string `json:"display_user_id"`
Status string `json:"status"`
AssignedUserID int64 `json:"assigned_user_id,string"`
AssignedUser *User `json:"assigned_user,omitempty"`
AssignedLeaseID string `json:"assigned_lease_id"`
AssignedAtMs int64 `json:"assigned_at_ms"`
ReleasedAtMs int64 `json:"released_at_ms"`
@ -113,6 +114,14 @@ type SetPrettyDisplayIDStatusRequest struct {
OperatorAdminID int64
}
type RecyclePrettyDisplayIDRequest struct {
RequestID string
Caller string
PrettyID string
Reason string
OperatorAdminID int64
}
type AdminGrantPrettyDisplayIDRequest struct {
RequestID string
Caller string
@ -238,6 +247,20 @@ func (c *GRPCClient) SetPrettyDisplayIDStatus(ctx context.Context, req SetPretty
return fromProtoPrettyDisplayID(resp.GetItem()), nil
}
func (c *GRPCClient) RecyclePrettyDisplayID(ctx context.Context, req RecyclePrettyDisplayIDRequest) (*PrettyDisplayID, error) {
// 回收必须走 user-service 的后台 RPC由 owner service 同事务恢复默认短号并释放靓号池记录。
resp, err := c.prettyClient.RecyclePrettyDisplayID(ctx, &userv1.RecyclePrettyDisplayIDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PrettyId: req.PrettyID,
Reason: req.Reason,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayID(resp.GetItem()), nil
}
func (c *GRPCClient) AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error) {
// 后台发放返回当前用户身份快照admin-server 原样转给前端,方便页面确认最终展示号。
resp, err := c.prettyClient.AdminGrantPrettyDisplayID(ctx, &userv1.AdminGrantPrettyDisplayIDRequest{

View File

@ -38,6 +38,8 @@ type Client interface {
ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error)
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
SettleSalaryWithdrawal(ctx context.Context, req *walletv1.SettleSalaryWithdrawalRequest) (*walletv1.SettleSalaryWithdrawalResponse, error)
ReleaseSalaryWithdrawal(ctx context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
@ -180,6 +182,14 @@ func (c *GRPCClient) AdminCreditCoinSellerStock(ctx context.Context, req *wallet
return c.client.AdminCreditCoinSellerStock(ctx, req)
}
func (c *GRPCClient) SettleSalaryWithdrawal(ctx context.Context, req *walletv1.SettleSalaryWithdrawalRequest) (*walletv1.SettleSalaryWithdrawalResponse, error) {
return c.client.SettleSalaryWithdrawal(ctx, req)
}
func (c *GRPCClient) ReleaseSalaryWithdrawal(ctx context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error) {
return c.client.ReleaseSalaryWithdrawal(ctx, req)
}
func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
return c.client.ListRechargeBills(ctx, req)
}

View File

@ -5,11 +5,22 @@ const (
UserStatusLocked = "locked"
UserStatusDisabled = "disabled"
TeamProductResearchID uint = 1
TeamOperationsID uint = 2
JobStatusPending = "pending"
JobStatusRunning = "running"
JobStatusSucceeded = "succeeded"
JobStatusFailed = "failed"
JobStatusCanceled = "canceled"
FinanceApplicationStatusPending = "pending"
FinanceApplicationStatusApproved = "approved"
FinanceApplicationStatusRejected = "rejected"
WithdrawalApplicationStatusPending = "pending"
WithdrawalApplicationStatusApproved = "approved"
WithdrawalApplicationStatusRejected = "rejected"
)
type User struct {
@ -18,6 +29,8 @@ type User struct {
Name string `gorm:"size:80;not null" json:"name"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Team string `gorm:"size:80" json:"team"`
TeamID *uint `gorm:"column:team_id;index" json:"teamId"`
TeamRecord Team `gorm:"foreignKey:TeamID;references:ID" json:"-"`
Status string `gorm:"size:24;index;not null;default:active" json:"status"`
MFAEnabled bool `gorm:"not null;default:false" json:"mfaEnabled"`
LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"`
@ -30,6 +43,23 @@ func (User) TableName() string {
return "admin_users"
}
type Team struct {
ID uint `gorm:"primaryKey" json:"id"`
ParentID *uint `gorm:"column:parent_id;index" json:"parentId"`
Parent *Team `gorm:"foreignKey:ParentID;references:ID" json:"-"`
Name string `gorm:"size:80;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:255" json:"description"`
Sort int `gorm:"not null;default:0" json:"sort"`
ParentName string `gorm:"column:parent_name;->;-:migration" json:"parentName,omitempty"`
UserCount int64 `gorm:"column:user_count;->;-:migration" json:"userCount"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (Team) TableName() string {
return "admin_teams"
}
type Role struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:80;uniqueIndex;not null" json:"name"`
@ -387,6 +417,63 @@ func (TemporaryPaymentLinkOwner) TableName() string {
return "admin_temporary_payment_link_owners"
}
type FinanceApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`
Operation string `gorm:"size:64;index:idx_admin_finance_app_operation;not null" json:"operation"`
WalletIdentity string `gorm:"size:32;not null;default:''" json:"walletIdentity"`
TargetUserID string `gorm:"size:64;index:idx_admin_finance_app_target;not null" json:"targetUserId"`
CoinAmount int64 `gorm:"not null;default:0" json:"coinAmount"`
RechargeAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"rechargeAmount"`
CredentialImageURL string `gorm:"size:1024;not null;default:''" json:"credentialImageUrl"`
CredentialText string `gorm:"type:text" json:"credentialText"`
ApplicantUserID uint `gorm:"index:idx_admin_finance_app_applicant;not null" json:"applicantUserId"`
ApplicantName string `gorm:"size:64;not null;default:''" json:"applicantName"`
AuditorUserID *uint `gorm:"index:idx_admin_finance_app_auditor" json:"auditorUserId"`
AuditorName string `gorm:"size:64;not null;default:''" json:"auditorName"`
Status string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null;default:pending" json:"status"`
AuditRemark string `gorm:"type:text" json:"auditRemark"`
AuditedAtMS *int64 `gorm:"column:audited_at_ms" json:"auditedAtMs"`
WalletCommandID string `gorm:"size:128;not null;default:''" json:"walletCommandId"`
WalletTransactionID string `gorm:"size:128;not null;default:''" json:"walletTransactionId"`
WalletAssetType string `gorm:"size:64;not null;default:''" json:"walletAssetType"`
WalletAmountDelta int64 `gorm:"not null;default:0" json:"walletAmountDelta"`
WalletBalanceAfter int64 `gorm:"not null;default:0" json:"walletBalanceAfter"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (FinanceApplication) TableName() string {
return "admin_finance_applications"
}
type UserWithdrawalApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"`
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"`
AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"`
AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"`
Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"`
ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"`
ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"`
AuditRemark string `gorm:"type:text" json:"auditRemark"`
AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"`
ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (UserWithdrawalApplication) TableName() string {
return "admin_user_withdrawal_applications"
}
type AdminJob struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:80;index;not null" json:"type"`

View File

@ -42,6 +42,7 @@ func (h *Handler) CreateUser(c *gin.Context) {
Username: req.Username,
Name: req.Name,
Team: req.Team,
TeamID: req.TeamID,
Status: req.Status,
Password: req.Password,
MFAEnabled: req.MFAEnabled,
@ -122,6 +123,7 @@ func (h *Handler) UpdateUser(c *gin.Context) {
user, err := h.service.UpdateUser(id, UpdateUserInput{
Name: req.Name,
Team: req.Team,
TeamID: req.TeamID,
Status: req.Status,
MFAEnabled: req.MFAEnabled,
RoleIDs: req.RoleIDs,

View File

@ -4,6 +4,7 @@ type userRequest struct {
Username string `json:"username" binding:"required"`
Name string `json:"name" binding:"required"`
Team string `json:"team"`
TeamID *uint `json:"teamId"`
Status string `json:"status"`
Password string `json:"password"`
MFAEnabled bool `json:"mfaEnabled"`
@ -13,6 +14,7 @@ type userRequest struct {
type updateUserRequest struct {
Name *string `json:"name"`
Team *string `json:"team"`
TeamID *uint `json:"teamId"`
Status *string `json:"status"`
MFAEnabled *bool `json:"mfaEnabled"`
RoleIDs *[]uint `json:"roleIds"`

View File

@ -12,8 +12,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers)
protected.POST("/users/batch/status", middleware.RequirePermission("user:status"), h.BatchUpdateUserStatus)
protected.GET("/users/:id", middleware.RequirePermission("user:view"), h.GetUser)
protected.GET("/users/:id/finance-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.GET("/users/:id/money-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.PATCH("/users/:id", middleware.RequirePermission("user:update"), h.UpdateUser)
protected.PUT("/users/:id/finance-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PUT("/users/:id/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus)
protected.POST("/users/:id/reset-password", middleware.RequirePermission("user:reset-password"), h.ResetUserPassword)

View File

@ -25,6 +25,7 @@ type CreateUserInput struct {
Username string
Name string
Team string
TeamID *uint
Status string
Password string
MFAEnabled bool
@ -34,6 +35,7 @@ type CreateUserInput struct {
type UpdateUserInput struct {
Name *string
Team *string
TeamID *uint
Status *string
MFAEnabled *bool
RoleIDs *[]uint
@ -84,12 +86,17 @@ func (s *AdminUserService) CreateUser(input CreateUserInput) (*model.User, Passw
if err != nil {
return nil, PasswordResult{}, err
}
teamID, teamName, err := s.store.NormalizeUserTeam(input.TeamID, input.Team)
if err != nil {
return nil, PasswordResult{}, err
}
user := model.User{
Username: strings.TrimSpace(input.Username),
Name: strings.TrimSpace(input.Name),
PasswordHash: hash,
Team: strings.TrimSpace(input.Team),
Team: teamName,
TeamID: teamID,
Status: shared.DefaultString(input.Status, model.UserStatusActive),
MFAEnabled: input.MFAEnabled,
}
@ -131,7 +138,19 @@ func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.Us
updates["name"] = strings.TrimSpace(*input.Name)
}
if input.Team != nil {
updates["team"] = strings.TrimSpace(*input.Team)
teamID, teamName, err := s.store.NormalizeUserTeam(input.TeamID, *input.Team)
if err != nil {
return nil, err
}
updates["team"] = teamName
updates["team_id"] = teamID
} else if input.TeamID != nil {
teamID, teamName, err := s.store.NormalizeUserTeam(input.TeamID, "")
if err != nil {
return nil, err
}
updates["team"] = teamName
updates["team_id"] = teamID
}
if input.Status != nil {
updates["status"] = strings.TrimSpace(*input.Status)
@ -184,7 +203,7 @@ func (s *AdminUserService) ExportUsers(actor shared.Actor, options repository.Li
user.Username,
user.Name,
strings.Join(roleNames(user.Roles), "、"),
user.Team,
shared.UserTeamName(user),
user.Status,
shared.BoolText(user.MFAEnabled),
lastLogin,

View File

@ -72,16 +72,28 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list
}
// 后台只做只读查询:金币流水事实仍以 wallet_entries 追加分录为准,不在 admin 库冗余账务状态。
// 普通打开列表时不带 biz_typetotal 只依赖 entries 自身;避免为了一个总数对百万级 COIN 分录逐行回表 join 交易主表。
whereSQL, args := coinLedgerWhere(appCode, query, userIDs)
var total int64
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
if query.BizType == "" {
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
}
} else {
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
}
}
rows, err := s.walletDB.QueryContext(ctx, `

View File

@ -0,0 +1,697 @@
package dashboard
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/config"
)
type AslanHTTPDashboardSource struct {
client *http.Client
appCode string
appName string
baseURL string
overviewPath string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
}
type aslanRegionStatistics struct {
Date string `json:"date"`
RegionID string `json:"regionId"`
RegionCode string `json:"regionCode"`
RegionName string `json:"regionName"`
DailyRecharge aslanDecimal `json:"dailyRecharge"`
DailyActive aslanInt64 `json:"dailyActive"`
DailyRegister aslanInt64 `json:"dailyRegister"`
GoldBalance aslanInt64 `json:"goldBalance"`
HostSalaryBalance aslanDecimal `json:"hostSalaryBalance"`
Countries []aslanCountryStatistics `json:"countries"`
}
type aslanCountryStatistics struct {
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
DailyRecharge aslanDecimal `json:"dailyRecharge"`
DailyActive aslanInt64 `json:"dailyActive"`
DailyRegister aslanInt64 `json:"dailyRegister"`
GoldBalance aslanInt64 `json:"goldBalance"`
HostSalaryBalance aslanDecimal `json:"hostSalaryBalance"`
}
type aslanDecimal struct {
raw string
}
type aslanInt64 struct {
value int64
}
type aslanDashboardMetric struct {
RechargeUSDMinor int64
ActiveUsers int64
NewUsers int64
CoinTotal int64
SalaryUSDMinor int64
}
type aslanCountryAccumulator struct {
CountryCode string
CountryName string
RegionID int64
RegionCode string
RegionName string
Flow aslanDashboardMetric
Snapshot aslanDashboardMetric
}
// NewAslanHTTPDashboardSource 接入 Aslan console 已暴露的免鉴权数据口径admin 不连接 Aslan 数据库,
// 只把远端接口结果转换成本后台大屏已有的响应字段。
func NewAslanHTTPDashboardSource(sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
if !sourceConfig.Enabled {
return nil, nil
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
if appCode == "" {
return nil, nil
}
baseURL := strings.TrimRight(strings.TrimSpace(sourceConfig.BaseURL), "/")
overviewPath := "/" + strings.TrimLeft(strings.TrimSpace(sourceConfig.OverviewPath), "/")
if baseURL == "" || overviewPath == "/" {
return nil, fmt.Errorf("aslan dashboard source %s requires base_url and overview_path", appCode)
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
if err == nil {
err = fmt.Errorf("missing scheme or host")
}
return nil, fmt.Errorf("parse aslan dashboard base_url %q: %w", baseURL, err)
}
statTimezone := strings.TrimSpace(sourceConfig.StatTimezone)
if statTimezone == "" {
statTimezone = "Asia/Shanghai"
}
timeout := sourceConfig.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return &AslanHTTPDashboardSource{
client: &http.Client{Timeout: timeout},
appCode: appCode,
appName: strings.TrimSpace(sourceConfig.AppName),
baseURL: baseURL,
overviewPath: overviewPath,
statTimezone: statTimezone,
requestTimeout: timeout,
regionResolvers: compactExternalDashboardRegionResolvers(regionResolvers),
}, nil
}
func (s *AslanHTTPDashboardSource) AppCode() string {
if s == nil {
return ""
}
return s.appCode
}
func (s *AslanHTTPDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if s == nil || s.client == nil {
return nil, fmt.Errorf("aslan dashboard source is not configured")
}
countryFilter, err := s.countryFilter(ctx, query)
if err != nil {
return nil, err
}
requestTimezone := strings.TrimSpace(query.StatTZ)
if requestTimezone == "" {
requestTimezone = s.statTimezone
}
requestLocation, err := time.LoadLocation(requestTimezone)
if err != nil {
return nil, fmt.Errorf("load aslan dashboard request timezone %s: %w", requestTimezone, err)
}
if _, err := time.LoadLocation(s.statTimezone); err != nil {
return nil, fmt.Errorf("load aslan dashboard storage timezone %s: %w", s.statTimezone, err)
}
startDate, endDate, startMS, endMS := externalDashboardDateRange(query, requestLocation)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
current, err := s.loadRange(queryCtx, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
previous, err := s.loadRange(queryCtx, previousStart, startDate, countryFilter)
if err != nil {
return nil, err
}
response := current.Total.toMap()
response["app_code"] = s.appCode
response["app_name"] = firstExternalDashboardValue(s.appName, s.appCode)
response["sys_origin"] = "ATYOU"
response["stat_tz"] = s.statTimezone
response["request_stat_tz"] = requestTimezone
response["start_ms"] = startMS
response["end_ms"] = endMS
response["daily_series"] = current.DailySeries
response["country_breakdown"] = current.CountryBreakdown
response["daily_country_breakdown"] = current.DailyCountryBreakdown
response["retention"] = aslanRetentionMap(current.Total)
response["report_metric_sources"] = aslanDashboardMetricSources()
response["updated_at_ms"] = time.Now().UTC().UnixMilli()
applyExternalDashboardDeltas(response, current.Total, previous.Total)
response["coin_total_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.CoinTotal), nullableMetricInt64(previous.Total.CoinTotal))
response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor))
applyAslanUnavailableFields(response)
return response, nil
}
type aslanRangeOverview struct {
Total externalDashboardMetric
DailySeries []map[string]any
CountryBreakdown []map[string]any
DailyCountryBreakdown []map[string]any
}
func (s *AslanHTTPDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) {
if query.CountryID > 0 {
return externalDashboardCountryFilter{}, fmt.Errorf("aslan dashboard source %s does not support numeric country_id filter", s.appCode)
}
if query.RegionID <= 0 {
return externalDashboardCountryFilter{}, nil
}
for _, resolver := range s.regionResolvers {
codes, handled, err := resolver.CountryCodesForRegion(ctx, s.appCode, query.RegionID)
if err != nil {
return externalDashboardCountryFilter{}, err
}
if !handled {
continue
}
return externalDashboardCountryFilter{
Applied: true,
RegionID: query.RegionID,
Codes: normalizeExternalCountryCodes(codes),
}, nil
}
// Aslan 接口返回的是 legacy 区域字符串admin 前端使用的 region_id 可能是 Mongo ObjectId 的稳定 int64 映射。
// 缺 legacy 映射时才回退尝试远端 regionId 数字值,兼容历史区域本身就是数字字符串的环境。
return externalDashboardCountryFilter{RegionID: query.RegionID}, nil
}
func (s *AslanHTTPDashboardSource) loadRange(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (aslanRangeOverview, error) {
totalFlow := aslanDashboardMetric{}
totalSnapshot := aslanDashboardMetric{}
dailySeries := []map[string]any{}
dailyCountries := []map[string]any{}
countryAggregates := map[string]*aslanCountryAccumulator{}
for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
regions, err := s.fetchDay(ctx, day)
if err != nil {
return aslanRangeOverview{}, err
}
dayFlow := aslanDashboardMetric{}
daySnapshot := aslanDashboardMetric{}
dayCountries := map[string]*aslanCountryAccumulator{}
for _, region := range regions {
if !s.regionIncluded(region, countryFilter) {
continue
}
for _, country := range region.Countries {
countryCode := strings.ToUpper(strings.TrimSpace(country.CountryCode))
if countryCode == "" || !countryIncluded(countryCode, countryFilter) {
continue
}
metric, err := aslanCountryMetric(country)
if err != nil {
return aslanRangeOverview{}, fmt.Errorf("map aslan country %s on %s: %w", countryCode, dashboardSQLDate(day), err)
}
dayFlow.addFlow(metric)
daySnapshot.addSnapshot(metric)
totalFlow.addFlow(metric)
dayAggregate := dayCountries[countryCode]
if dayAggregate == nil {
dayAggregate = &aslanCountryAccumulator{CountryCode: countryCode}
dayCountries[countryCode] = dayAggregate
}
dayAggregate.addCountry(region, country, metric, countryFilter.RegionID)
}
}
totalSnapshot = daySnapshot
dayMetric := aslanCombinedMetric(dayFlow, daySnapshot)
dayRow := dayMetric.toMap()
dayLabel := dashboardSQLDate(day)
dayRow["label"] = dayLabel
dayRow["stat_day"] = dayLabel
applyAslanUnavailableFields(dayRow)
dailySeries = append(dailySeries, dayRow)
mergeAslanCountryDay(countryAggregates, dayCountries)
dailyCountries = append(dailyCountries, aslanDailyCountryRows(dayLabel, dayCountries)...)
}
return aslanRangeOverview{
Total: aslanCombinedMetric(totalFlow, totalSnapshot),
DailySeries: dailySeries,
CountryBreakdown: aslanCountryRows(countryAggregates),
DailyCountryBreakdown: dailyCountries,
}, nil
}
func (s *AslanHTTPDashboardSource) fetchDay(ctx context.Context, day time.Time) ([]aslanRegionStatistics, error) {
regions, err := s.fetchDayWithDateParam(ctx, day, dashboardSQLDate(day))
if err == nil {
return regions, nil
}
var dateErr aslanDateParameterError
if !errors.As(err, &dateErr) {
return nil, err
}
// 线上 Aslan 旧版本把 LocalDate 参数按 yyyyMMdd 转换;当前代码标注是 ISO date。这里只在明确日期转换失败时降级兼容两版服务。
return s.fetchDayWithDateParam(ctx, day, day.Format("20060102"))
}
func (s *AslanHTTPDashboardSource) fetchDayWithDateParam(ctx context.Context, day time.Time, dateParam string) ([]aslanRegionStatistics, error) {
endpoint, err := url.Parse(s.baseURL + s.overviewPath)
if err != nil {
return nil, fmt.Errorf("build aslan dashboard request url: %w", err)
}
values := endpoint.Query()
values.Set("date", dateParam)
endpoint.RawQuery = values.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request aslan dashboard %s: %w", dashboardSQLDate(day), err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode == http.StatusBadRequest && bytes.Contains(body, []byte("LocalDate")) {
return nil, aslanDateParameterError{dateParam: dateParam}
}
return nil, fmt.Errorf("aslan dashboard returned status %d for %s: %s", resp.StatusCode, dashboardSQLDate(day), truncateExternalDashboardError(body))
}
regions, err := decodeAslanRegions(body)
if err != nil {
return nil, fmt.Errorf("decode aslan dashboard %s: %w", dashboardSQLDate(day), err)
}
return regions, nil
}
type aslanDateParameterError struct {
dateParam string
}
func (e aslanDateParameterError) Error() string {
return "aslan dashboard date parameter rejected: " + e.dateParam
}
func truncateExternalDashboardError(body []byte) string {
text := strings.TrimSpace(string(body))
if len(text) > 300 {
return text[:300]
}
return text
}
func decodeAslanRegions(body []byte) ([]aslanRegionStatistics, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil, nil
}
if trimmed[0] == '[' {
var regions []aslanRegionStatistics
if err := json.Unmarshal(trimmed, &regions); err != nil {
return nil, err
}
return regions, nil
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &envelope); err != nil {
return nil, err
}
for _, key := range []string{"data", "result", "body"} {
raw := bytes.TrimSpace(envelope[key])
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
continue
}
var regions []aslanRegionStatistics
if err := json.Unmarshal(raw, &regions); err != nil {
return nil, err
}
return regions, nil
}
return nil, fmt.Errorf("missing array payload")
}
func (s *AslanHTTPDashboardSource) regionIncluded(region aslanRegionStatistics, countryFilter externalDashboardCountryFilter) bool {
if countryFilter.Applied || countryFilter.RegionID <= 0 {
return true
}
regionID, err := strconv.ParseInt(strings.TrimSpace(region.RegionID), 10, 64)
return err == nil && regionID == countryFilter.RegionID
}
func countryIncluded(countryCode string, countryFilter externalDashboardCountryFilter) bool {
if !countryFilter.Applied {
return true
}
for _, code := range countryFilter.Codes {
if code == countryCode {
return true
}
}
return false
}
func aslanCountryMetric(country aslanCountryStatistics) (aslanDashboardMetric, error) {
recharge, err := country.DailyRecharge.Minor()
if err != nil {
return aslanDashboardMetric{}, err
}
salary, err := country.HostSalaryBalance.Minor()
if err != nil {
return aslanDashboardMetric{}, err
}
return aslanDashboardMetric{
RechargeUSDMinor: recharge,
ActiveUsers: country.DailyActive.Int64(),
NewUsers: country.DailyRegister.Int64(),
CoinTotal: country.GoldBalance.Int64(),
SalaryUSDMinor: salary,
}, nil
}
func aslanCombinedMetric(flow aslanDashboardMetric, snapshot aslanDashboardMetric) externalDashboardMetric {
metric := externalDashboardMetric{
NewUsers: flow.NewUsers,
ActiveUsers: flow.ActiveUsers,
UserRechargeUSDMinor: flow.RechargeUSDMinor,
RechargeUSDMinor: flow.RechargeUSDMinor,
CoinTotal: int64Ptr(snapshot.CoinTotal),
SalaryUSDMinor: int64Ptr(snapshot.SalaryUSDMinor),
D1RetentionUsers: 0,
D1RetentionBaseUsers: 0,
D7RetentionUsers: 0,
D7RetentionBaseUsers: 0,
D30RetentionUsers: 0,
D30RetentionBaseUsers: 0,
}
metric.finalize()
return metric
}
func (m *aslanDashboardMetric) addFlow(item aslanDashboardMetric) {
m.RechargeUSDMinor += item.RechargeUSDMinor
m.ActiveUsers += item.ActiveUsers
m.NewUsers += item.NewUsers
}
func (m *aslanDashboardMetric) addSnapshot(item aslanDashboardMetric) {
m.CoinTotal += item.CoinTotal
m.SalaryUSDMinor += item.SalaryUSDMinor
}
func (a *aslanCountryAccumulator) addCountry(region aslanRegionStatistics, country aslanCountryStatistics, metric aslanDashboardMetric, requestRegionID int64) {
a.CountryName = firstExternalDashboardValue(country.CountryName, a.CountryName, a.CountryCode)
a.RegionCode = firstExternalDashboardValue(region.RegionCode, a.RegionCode)
a.RegionName = firstExternalDashboardValue(region.RegionName, a.RegionName)
if requestRegionID > 0 {
a.RegionID = requestRegionID
} else if a.RegionID == 0 {
if regionID, err := strconv.ParseInt(strings.TrimSpace(region.RegionID), 10, 64); err == nil {
a.RegionID = regionID
}
}
a.Flow.addFlow(metric)
a.Snapshot.addSnapshot(metric)
}
func mergeAslanCountryDay(total map[string]*aslanCountryAccumulator, day map[string]*aslanCountryAccumulator) {
for countryCode, dayCountry := range day {
aggregate := total[countryCode]
if aggregate == nil {
aggregate = &aslanCountryAccumulator{
CountryCode: dayCountry.CountryCode,
CountryName: dayCountry.CountryName,
RegionID: dayCountry.RegionID,
RegionCode: dayCountry.RegionCode,
RegionName: dayCountry.RegionName,
}
total[countryCode] = aggregate
}
aggregate.CountryName = firstExternalDashboardValue(dayCountry.CountryName, aggregate.CountryName, aggregate.CountryCode)
aggregate.RegionID = maxPositiveInt64(aggregate.RegionID, dayCountry.RegionID)
aggregate.RegionCode = firstExternalDashboardValue(dayCountry.RegionCode, aggregate.RegionCode)
aggregate.RegionName = firstExternalDashboardValue(dayCountry.RegionName, aggregate.RegionName)
aggregate.Flow.addFlow(dayCountry.Flow)
// 金币和工资是 Aslan 接口提供的余额快照,区间国家明细必须使用最后一天快照,不能按天累加成虚高余额。
aggregate.Snapshot = dayCountry.Snapshot
}
}
func maxPositiveInt64(left int64, right int64) int64 {
if left > 0 {
return left
}
return right
}
func aslanCountryRows(countries map[string]*aslanCountryAccumulator) []map[string]any {
rows := make([]map[string]any, 0, len(countries))
for _, country := range countries {
rows = append(rows, aslanCountryRow("", country))
}
sort.Slice(rows, func(i, j int) bool {
left, _ := rows[i]["recharge_usd_minor"].(int64)
right, _ := rows[j]["recharge_usd_minor"].(int64)
if left == right {
return fmt.Sprint(rows[i]["country_code"]) < fmt.Sprint(rows[j]["country_code"])
}
return left > right
})
return rows
}
func aslanDailyCountryRows(statDay string, countries map[string]*aslanCountryAccumulator) []map[string]any {
keys := make([]string, 0, len(countries))
for key := range countries {
keys = append(keys, key)
}
sort.Strings(keys)
rows := make([]map[string]any, 0, len(keys))
for _, key := range keys {
rows = append(rows, aslanCountryRow(statDay, countries[key]))
}
return rows
}
func aslanCountryRow(statDay string, country *aslanCountryAccumulator) map[string]any {
metric := aslanCombinedMetric(country.Flow, country.Snapshot)
row := metric.toMap()
if statDay != "" {
row["label"] = statDay
row["stat_day"] = statDay
}
row["country_id"] = 0
row["country_code"] = country.CountryCode
row["country_name"] = firstExternalDashboardValue(country.CountryName, country.CountryCode)
row["country"] = firstExternalDashboardValue(country.CountryName, country.CountryCode)
if country.RegionID > 0 {
row["region_id"] = country.RegionID
}
if country.RegionCode != "" {
row["region_code"] = country.RegionCode
}
if country.RegionName != "" {
row["region_name"] = country.RegionName
}
applyAslanUnavailableFields(row)
return row
}
func (v *aslanDecimal) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
v.raw = "0"
return nil
}
if strings.HasPrefix(raw, `"`) {
var decoded string
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
raw = decoded
}
v.raw = strings.TrimSpace(raw)
if v.raw == "" {
v.raw = "0"
}
return nil
}
func (v aslanDecimal) Minor() (int64, error) {
return decimalTextToMinor(v.raw)
}
func (v *aslanInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
v.value = 0
return nil
}
if strings.HasPrefix(raw, `"`) {
var decoded string
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
raw = decoded
}
parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return fmt.Errorf("parse int64 %q: %w", raw, err)
}
v.value = parsed
return nil
}
func (v aslanInt64) Int64() int64 {
return v.value
}
func nullableMetricInt64(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
var aslanUnavailableMetricFields = []string{
"paid_users",
"recharge_users",
"new_user_recharge_usd_minor",
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"coin_seller_transfer_coin",
"google_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"game_turnover",
"game_payout",
"game_players",
"game_profit",
"game_profit_rate",
"lucky_gift_turnover",
"lucky_gift_payout",
"lucky_gift_payers",
"lucky_gift_profit",
"lucky_gift_profit_rate",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"super_lucky_gift_profit",
"super_lucky_gift_profit_rate",
"gift_coin_spent",
"consumed_coin",
"output_coin",
"consume_output_ratio",
"platform_grant_coin",
"manual_grant_coin",
"salary_transfer_coin",
"mic_online_ms",
"avg_mic_online_ms",
"arppu_usd_minor",
"payer_rate",
"paid_conversion_rate",
"recharge_conversion_rate",
"arppu_delta_rate",
"paid_users_delta_rate",
"payer_rate_delta_pp",
"paid_conversion_rate_delta_pp",
"recharge_conversion_rate_delta_pp",
"gift_coin_spent_delta_rate",
"game_turnover_delta_rate",
"game_profit_delta_rate",
"lucky_gift_turnover_delta_rate",
"lucky_gift_profit_delta_rate",
"d1_retention_rate",
"d7_retention_rate",
"d30_retention_rate",
}
func applyAslanUnavailableFields(row map[string]any) {
for _, field := range aslanUnavailableMetricFields {
row[field] = nil
}
}
func aslanRetentionMap(metric externalDashboardMetric) map[string]any {
return map[string]any{
"registered_users": metric.NewUsers,
"day1_base_users": nil,
"day1_rate": nil,
"day1_users": nil,
"day7_base_users": nil,
"day7_rate": nil,
"day7_users": nil,
"day30_base_users": nil,
"day30_rate": nil,
"day30_users": nil,
}
}
func aslanDashboardMetricSources() []map[string]any {
return []map[string]any{
{"field": "new_users", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics dailyRegister"},
{"field": "active_users", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics dailyActive"},
{"field": "paid_users", "available": false, "missing_reason": "Aslan 当前接口没有付费用户数"},
{"field": "recharge_users", "available": false, "missing_reason": "Aslan 当前接口没有充值用户数"},
{"field": "recharge_usd_minor", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics dailyRecharge"},
{"field": "new_user_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有新用户充值字段"},
{"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有币商充值金额字段"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "Aslan 当前接口没有币商充值金币字段"},
{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "Aslan 当前接口没有币商出货金币字段"},
{"field": "google_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有 Google 充值拆分字段"},
{"field": "mifapay_recharge_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有三方充值拆分字段"},
{"field": "game_turnover", "available": false, "missing_reason": "Aslan 当前接口没有游戏流水和返奖字段"},
{"field": "lucky_gift_turnover", "available": false, "missing_reason": "Aslan 当前接口没有幸运礼物流水和返奖字段"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "Aslan 当前接口没有超级幸运礼物字段"},
{"field": "gift_coin_spent", "available": false, "missing_reason": "Aslan 当前接口没有礼物流水字段"},
{"field": "coin_total", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics goldBalance"},
{"field": "consumed_coin", "available": false, "missing_reason": "Aslan 当前接口没有消耗金币总口径"},
{"field": "output_coin", "available": false, "missing_reason": "Aslan 当前接口没有产出金币总口径"},
{"field": "consume_output_ratio", "available": false, "missing_reason": "缺少消耗金币和产出金币总口径"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "Aslan 当前接口没有平台发放金币字段"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "Aslan 当前接口没有人工发放金币字段"},
{"field": "salary_usd_minor", "available": true, "source": "Aslan /console/datav/aslan/region-country/statistics hostSalaryBalance"},
{"field": "salary_transfer_coin", "available": false, "missing_reason": "Aslan 当前接口没有工资兑换金币字段"},
{"field": "avg_mic_online_ms", "available": false, "missing_reason": "Aslan 当前接口没有麦上时长字段"},
{"field": "mic_online_ms", "available": false, "missing_reason": "Aslan 当前接口没有麦上时长字段"},
{"field": "arpu_usd_minor", "available": true, "source": "dailyRecharge / dailyActive"},
{"field": "arppu_usd_minor", "available": false, "missing_reason": "Aslan 当前接口没有付费用户数,不能计算 ARPPU"},
{"field": "paid_conversion_rate", "available": false, "missing_reason": "Aslan 当前接口没有付费用户数"},
{"field": "recharge_conversion_rate", "available": false, "missing_reason": "Aslan 当前接口没有充值用户数"},
{"field": "retention.day1_rate", "available": false, "missing_reason": "Aslan 当前接口没有次日留存 cohort 字段"},
{"field": "retention.day7_rate", "available": false, "missing_reason": "Aslan 当前接口没有 7 日留存 cohort 字段"},
{"field": "retention.day30_rate", "available": false, "missing_reason": "Aslan 当前接口没有 30 日留存 cohort 字段"},
}
}

View File

@ -0,0 +1,125 @@
package dashboard
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"hyapp-admin-server/internal/config"
)
func TestAslanHTTPDashboardSourceStatisticsOverview(t *testing.T) {
requestedDates := map[string]bool{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
t.Fatalf("aslan dashboard request should not send authorization header")
}
if r.URL.Path != "/console/datav/aslan/region-country/statistics" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
statDay := r.URL.Query().Get("date")
requestedDates[statDay] = true
w.Header().Set("Content-Type", "application/json")
switch statDay {
case "2026-06-30":
_ = json.NewEncoder(w).Encode([]map[string]any{{
"date": "2026-06-30",
"regionId": "legacy-middle-east",
"regionCode": "MIDDLE_EAST",
"regionName": "中东区",
"dailyRecharge": "111.25",
"dailyActive": 14,
"dailyRegister": 4,
"goldBalance": 1300,
"hostSalaryBalance": "190.50",
"countries": []map[string]any{
{"countryCode": "SA", "countryName": "Saudi Arabia", "dailyRecharge": "100.25", "dailyActive": 12, "dailyRegister": 3, "goldBalance": 1000, "hostSalaryBalance": "150.50"},
{"countryCode": "AE", "countryName": "United Arab Emirates", "dailyRecharge": "11.00", "dailyActive": 2, "dailyRegister": 1, "goldBalance": 300, "hostSalaryBalance": "40.00"},
},
}})
case "2026-06-29":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{
"date": "2026-06-29",
"regionId": "legacy-middle-east",
"regionCode": "MIDDLE_EAST",
"regionName": "中东区",
"dailyRecharge": "50.00",
"dailyActive": 10,
"dailyRegister": 2,
"goldBalance": 800,
"hostSalaryBalance": "100.00",
"countries": []map[string]any{
{"countryCode": "SA", "countryName": "Saudi Arabia", "dailyRecharge": "50.00", "dailyActive": 10, "dailyRegister": 2, "goldBalance": 800, "hostSalaryBalance": "100.00"},
},
}}})
default:
t.Fatalf("unexpected date: %s", statDay)
}
}))
defer server.Close()
source, err := NewAslanHTTPDashboardSource(config.DashboardExternalSourceConfig{
Enabled: true,
SourceType: "aslan_http",
AppCode: "aslan",
AppName: "Aslan",
BaseURL: server.URL,
OverviewPath: "/console/datav/aslan/region-country/statistics",
StatTimezone: "Asia/Shanghai",
RequestTimeout: time.Second,
}, staticDashboardRegionResolver{appCode: "aslan", regionID: 1001, countryCodes: []string{"SA"}})
if err != nil {
t.Fatalf("build source: %v", err)
}
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai)
overview, err := source.StatisticsOverview(context.Background(), StatisticsQuery{
AppCode: "aslan",
StatTZ: "Asia/Shanghai",
StartMS: start.UnixMilli(),
EndMS: start.AddDate(0, 0, 1).UnixMilli(),
RegionID: 1001,
})
if err != nil {
t.Fatalf("StatisticsOverview: %v", err)
}
if !requestedDates["2026-06-30"] || !requestedDates["2026-06-29"] {
t.Fatalf("date requests mismatch: %#v", requestedDates)
}
assertEqual(t, overview["app_code"], "aslan")
assertEqual(t, overview["stat_tz"], "Asia/Shanghai")
assertInt64(t, overview["recharge_usd_minor"], 10_025)
assertInt64(t, overview["active_users"], 12)
assertInt64(t, overview["new_users"], 3)
assertInt64(t, overview["registered_users"], 3)
assertInt64(t, overview["coin_total"], 1_000)
assertInt64(t, overview["salary_usd_minor"], 15_050)
assertInt64(t, overview["arpu_usd_minor"], 835)
if overview["paid_users"] != nil || overview["recharge_users"] != nil || overview["arppu_usd_minor"] != nil {
t.Fatalf("aslan payer fields should be nil when the remote API does not return payer counts: %#v", overview)
}
countries, ok := overview["country_breakdown"].([]map[string]any)
if !ok || len(countries) != 1 {
t.Fatalf("country_breakdown mismatch: %#v", overview["country_breakdown"])
}
assertEqual(t, countries[0]["country_code"], "SA")
assertEqual(t, countries[0]["region_id"], int64(1001))
assertInt64(t, countries[0]["recharge_usd_minor"], 10_025)
sources, ok := overview["report_metric_sources"].([]map[string]any)
if !ok {
t.Fatalf("report_metric_sources mismatch: %#v", overview["report_metric_sources"])
}
assertMetricSourceUnavailable(t, sources, "arppu_usd_minor")
assertMetricSourceUnavailable(t, sources, "paid_users")
assertMetricSourceUnavailable(t, sources, "recharge_users")
assertMetricSourceUnavailable(t, sources, "game_turnover")
assertMetricSourceUnavailable(t, sources, "retention.day1_rate")
}

View File

@ -0,0 +1,823 @@
package dashboard
import (
"context"
"database/sql"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/config"
)
const externalPeriodMetricSelect = `
COALESCE(SUM(t.country_new_user), 0),
COALESCE(SUM(t.daily_active_user), 0),
COALESCE(SUM(t.daily_recharge_user), 0),
CAST(COALESCE(SUM(t.new_user_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.official_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.mifapay_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.google_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.dealer_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.user_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.new_dealer_user_recharge), 0) AS CHAR),
CAST(COALESCE(SUM(t.salary_exchange), 0) AS CHAR),
CAST(COALESCE(SUM(t.salary_transfer), 0) AS CHAR),
CAST(COALESCE(SUM(t.gift_consume), 0) AS CHAR),
CAST(COALESCE(SUM(t.lucky_gift_total_flow), 0) AS CHAR),
COALESCE(SUM(t.lucky_gift_user), 0),
CAST(COALESCE(SUM(t.lucky_gift_payout), 0) AS CHAR),
CAST(COALESCE(SUM(t.lucky_gift_anchor_share), 0) AS CHAR),
CAST(COALESCE(SUM(t.game_total_flow), 0) AS CHAR),
COALESCE(SUM(t.game_user), 0),
CAST(COALESCE(SUM(t.game_payout), 0) AS CHAR),
COALESCE(SUM(t.d1_retention_user), 0),
COALESCE(SUM(t.d1_retention_base_user), 0),
COALESCE(SUM(t.d7_retention_user), 0),
COALESCE(SUM(t.d7_retention_base_user), 0),
COALESCE(SUM(t.d30_retention_user), 0),
COALESCE(SUM(t.d30_retention_base_user), 0),
MAX(t.refreshed_at)`
type MySQLExternalDashboardSource struct {
db *sql.DB
appCode string
appName string
sysOrigin string
statTimezone string
requestTimeout time.Duration
regionResolvers []ExternalDashboardRegionResolver
}
type externalDashboardMetric struct {
NewUsers int64
ActiveUsers int64
PaidUsers int64
RechargeUsers int64
NewUserRechargeUSDMinor int64
OfficialRechargeUSDMinor int64
MifaPayRechargeUSDMinor int64
GoogleRechargeUSDMinor int64
CoinSellerRechargeUSDMinor int64
UserRechargeUSDMinor int64
NewDealerRechargeUSDMinor int64
SalaryExchangeUSDMinor int64
SalaryTransferUSDMinor int64
SalaryUSDMinor *int64
GiftCoinSpent int64
LuckyGiftTurnover int64
LuckyGiftPayers int64
LuckyGiftPayout int64
LuckyGiftAnchorShare int64
GameTurnover int64
GamePlayers int64
GamePayout int64
D1RetentionUsers int64
D1RetentionBaseUsers int64
D7RetentionUsers int64
D7RetentionBaseUsers int64
D30RetentionUsers int64
D30RetentionBaseUsers int64
RechargeUSDMinor int64
ARPUUSDMinor int64
ARPPUUSDMinor int64
PayerRate float64
PaidConversionRate float64
RechargeConversionRate float64
GameProfit int64
GameProfitRate float64
LuckyGiftProfit int64
LuckyGiftPayoutRate float64
LuckyGiftProfitRate float64
D1RetentionRate float64
D7RetentionRate float64
D30RetentionRate float64
CoinTotal *int64
RefreshedAt sql.NullTime
}
type externalMetricScanner interface {
Scan(dest ...any) error
}
// NewMySQLExternalDashboardSource 把外接 App 的大屏查询绑定到 dashboard-cdc-worker 产出的只读聚合库;
// 返回 nil 代表配置未启用,调用方可以统一把 nil 过滤掉。
func NewMySQLExternalDashboardSource(db *sql.DB, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) ExternalDashboardSource {
if db == nil || !sourceConfig.Enabled {
return nil
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
statTimezone := strings.TrimSpace(sourceConfig.StatTimezone)
if statTimezone == "" {
statTimezone = "Asia/Riyadh"
}
timeout := sourceConfig.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
if appCode == "" || sysOrigin == "" {
return nil
}
return &MySQLExternalDashboardSource{
db: db,
appCode: appCode,
appName: strings.TrimSpace(sourceConfig.AppName),
sysOrigin: sysOrigin,
statTimezone: statTimezone,
requestTimeout: timeout,
regionResolvers: compactExternalDashboardRegionResolvers(regionResolvers),
}
}
func (s *MySQLExternalDashboardSource) AppCode() string {
if s == nil {
return ""
}
return s.appCode
}
func (s *MySQLExternalDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("external dashboard source is not configured")
}
countryFilter, err := s.countryFilter(ctx, query)
if err != nil {
return nil, err
}
requestTimezone := strings.TrimSpace(query.StatTZ)
if requestTimezone == "" {
requestTimezone = s.statTimezone
}
requestLocation, err := time.LoadLocation(requestTimezone)
if err != nil {
return nil, fmt.Errorf("load external dashboard request timezone %s: %w", requestTimezone, err)
}
storageTimezone := s.statTimezone
if _, err := time.LoadLocation(storageTimezone); err != nil {
return nil, fmt.Errorf("load external dashboard storage timezone %s: %w", storageTimezone, err)
}
// 前端的 stat_tz 表示筛选日期的解释方式dashboard-cdc-worker 表里的 stat_timezone 是外部项目固定存储口径,两者不能混用。
startDate, endDate, startMS, endMS := externalDashboardDateRange(query, requestLocation)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
total, err := s.loadMetricSummary(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
days := countCalendarDays(startDate, endDate)
previousStart := startDate.AddDate(0, 0, -days)
previous, err := s.loadMetricSummary(queryCtx, storageTimezone, previousStart, startDate, countryFilter)
if err != nil {
return nil, err
}
dailySeries, err := s.loadDailySeries(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
countryBreakdown, err := s.loadCountryBreakdown(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
dailyCountryBreakdown, err := s.loadDailyCountryBreakdown(queryCtx, storageTimezone, startDate, endDate, countryFilter)
if err != nil {
return nil, err
}
response := total.toMap()
response["app_code"] = s.appCode
response["app_name"] = firstExternalDashboardValue(s.appName, s.appCode)
response["sys_origin"] = s.sysOrigin
response["stat_tz"] = storageTimezone
response["request_stat_tz"] = requestTimezone
response["start_ms"] = startMS
response["end_ms"] = endMS
response["daily_series"] = dailySeries
response["country_breakdown"] = countryBreakdown
response["daily_country_breakdown"] = dailyCountryBreakdown
response["retention"] = total.retentionMap()
response["report_metric_sources"] = externalDashboardMetricSources()
response["updated_at_ms"] = total.updatedAtMS()
applyExternalDashboardDeltas(response, total, previous)
return response, nil
}
type externalDashboardCountryFilter struct {
Applied bool
RegionID int64
Codes []string
}
func (s *MySQLExternalDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) {
if query.CountryID > 0 {
return externalDashboardCountryFilter{}, fmt.Errorf("external dashboard source %s does not support numeric country_id filter", s.appCode)
}
if query.RegionID <= 0 {
return externalDashboardCountryFilter{}, nil
}
for _, resolver := range s.regionResolvers {
codes, handled, err := resolver.CountryCodesForRegion(ctx, s.appCode, query.RegionID)
if err != nil {
return externalDashboardCountryFilter{}, err
}
if !handled {
continue
}
return externalDashboardCountryFilter{
Applied: true,
RegionID: query.RegionID,
Codes: normalizeExternalCountryCodes(codes),
}, nil
}
// dashboard-cdc-worker 没有 hyapp 的数字 region_id只能通过 legacy 区域目录映射到 country_code 后过滤;缺映射时直接失败,避免返回未筛选全量数据。
return externalDashboardCountryFilter{}, fmt.Errorf("external dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}
func (s *MySQLExternalDashboardSource) loadMetricSummary(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (externalDashboardMetric, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
row := s.db.QueryRowContext(ctx, `
SELECT `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL, args...)
metric, err := scanExternalDashboardMetric(row)
if err != nil {
return externalDashboardMetric{}, fmt.Errorf("query external dashboard summary for %s: %w", s.appCode, err)
}
return metric, nil
}
func (s *MySQLExternalDashboardSource) loadDailySeries(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) ([]map[string]any, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
rows, err := s.db.QueryContext(ctx, `
SELECT t.period_key, `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.period_key
ORDER BY t.period_key`, args...)
if err != nil {
return nil, fmt.Errorf("query external dashboard daily series for %s: %w", s.appCode, err)
}
defer rows.Close()
series := []map[string]any{}
for rows.Next() {
var statDay string
metric, err := scanExternalDashboardMetricWithPrefix(rows, &statDay)
if err != nil {
return nil, fmt.Errorf("scan external dashboard daily series for %s: %w", s.appCode, err)
}
row := metric.toMap()
row["label"] = statDay
row["stat_day"] = statDay
series = append(series, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external dashboard daily series for %s: %w", s.appCode, err)
}
return series, nil
}
func (s *MySQLExternalDashboardSource) loadCountryBreakdown(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) ([]map[string]any, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
rows, err := s.db.QueryContext(ctx, `
SELECT t.country_code, MAX(t.country_name), `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.country_code
ORDER BY SUM(t.official_recharge + t.mifapay_recharge + t.dealer_recharge) DESC, t.country_code`, args...)
if err != nil {
return nil, fmt.Errorf("query external dashboard country breakdown for %s: %w", s.appCode, err)
}
defer rows.Close()
countries := []map[string]any{}
for rows.Next() {
var countryCode string
var countryName sql.NullString
metric, err := scanExternalDashboardMetricWithPrefix(rows, &countryCode, &countryName)
if err != nil {
return nil, fmt.Errorf("scan external dashboard country breakdown for %s: %w", s.appCode, err)
}
row := metric.toMap()
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = firstExternalDashboardValue(countryName.String, countryCode)
row["country"] = firstExternalDashboardValue(countryName.String, countryCode)
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
countries = append(countries, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external dashboard country breakdown for %s: %w", s.appCode, err)
}
return countries, nil
}
func (s *MySQLExternalDashboardSource) loadDailyCountryBreakdown(ctx context.Context, statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) ([]map[string]any, error) {
whereSQL, args := s.periodMetricWhere(statTimezone, startDate, endDate, countryFilter)
rows, err := s.db.QueryContext(ctx, `
SELECT t.period_key, t.country_code, MAX(t.country_name), `+externalPeriodMetricSelect+`
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.period_key, t.country_code
ORDER BY t.period_key, t.country_code`, args...)
if err != nil {
return nil, fmt.Errorf("query external dashboard daily country breakdown for %s: %w", s.appCode, err)
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var statDay string
var countryCode string
var countryName sql.NullString
metric, err := scanExternalDashboardMetricWithPrefix(rows, &statDay, &countryCode, &countryName)
if err != nil {
return nil, fmt.Errorf("scan external dashboard daily country breakdown for %s: %w", s.appCode, err)
}
row := metric.toMap()
row["label"] = statDay
row["stat_day"] = statDay
row["country_id"] = 0
row["country_code"] = countryCode
row["country_name"] = firstExternalDashboardValue(countryName.String, countryCode)
row["country"] = firstExternalDashboardValue(countryName.String, countryCode)
if countryFilter.RegionID > 0 {
row["region_id"] = countryFilter.RegionID
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate external dashboard daily country breakdown for %s: %w", s.appCode, err)
}
return out, nil
}
func (s *MySQLExternalDashboardSource) periodMetricWhere(statTimezone string, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (string, []any) {
whereSQL := `t.period_type = 'DAY'
AND t.stat_timezone = ?
AND t.sys_origin = ?
AND t.period_start_date >= ?
AND t.period_start_date < ?`
args := []any{statTimezone, s.sysOrigin, dashboardSQLDate(startDate), dashboardSQLDate(endDate)}
if !countryFilter.Applied {
return whereSQL, args
}
if len(countryFilter.Codes) == 0 {
return whereSQL + `
AND 1 = 0`, args
}
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
placeholders = append(placeholders, "?")
args = append(args, code)
}
return whereSQL + `
AND t.country_code IN (` + strings.Join(placeholders, ",") + `)`, args
}
func scanExternalDashboardMetric(scanner externalMetricScanner) (externalDashboardMetric, error) {
metric := externalDashboardMetric{}
if err := scanner.Scan(externalDashboardScanDest(&metric)...); err != nil {
return externalDashboardMetric{}, err
}
metric.finalize()
return metric, nil
}
func scanExternalDashboardMetricWithPrefix(scanner externalMetricScanner, prefix ...any) (externalDashboardMetric, error) {
metric := externalDashboardMetric{}
dest := append(prefix, externalDashboardScanDest(&metric)...)
if err := scanner.Scan(dest...); err != nil {
return externalDashboardMetric{}, err
}
metric.finalize()
return metric, nil
}
func externalDashboardScanDest(metric *externalDashboardMetric) []any {
var newUserRecharge string
var officialRecharge string
var mifaPayRecharge string
var googleRecharge string
var dealerRecharge string
var userRecharge string
var newDealerRecharge string
var salaryExchange string
var salaryTransfer string
var giftConsume string
var luckyGiftTurnover string
var luckyGiftPayout string
var luckyGiftAnchorShare string
var gameTurnover string
var gamePayout string
return []any{
&metric.NewUsers,
&metric.ActiveUsers,
&metric.PaidUsers,
decimalMinorScanner(func(value int64) { metric.NewUserRechargeUSDMinor = value }, &newUserRecharge),
decimalMinorScanner(func(value int64) { metric.OfficialRechargeUSDMinor = value }, &officialRecharge),
decimalMinorScanner(func(value int64) { metric.MifaPayRechargeUSDMinor = value }, &mifaPayRecharge),
decimalMinorScanner(func(value int64) { metric.GoogleRechargeUSDMinor = value }, &googleRecharge),
decimalMinorScanner(func(value int64) { metric.CoinSellerRechargeUSDMinor = value }, &dealerRecharge),
decimalMinorScanner(func(value int64) { metric.UserRechargeUSDMinor = value }, &userRecharge),
decimalMinorScanner(func(value int64) { metric.NewDealerRechargeUSDMinor = value }, &newDealerRecharge),
decimalMinorScanner(func(value int64) { metric.SalaryExchangeUSDMinor = value }, &salaryExchange),
decimalMinorScanner(func(value int64) { metric.SalaryTransferUSDMinor = value }, &salaryTransfer),
decimalWholeScanner(func(value int64) { metric.GiftCoinSpent = value }, &giftConsume),
decimalWholeScanner(func(value int64) { metric.LuckyGiftTurnover = value }, &luckyGiftTurnover),
&metric.LuckyGiftPayers,
decimalWholeScanner(func(value int64) { metric.LuckyGiftPayout = value }, &luckyGiftPayout),
decimalWholeScanner(func(value int64) { metric.LuckyGiftAnchorShare = value }, &luckyGiftAnchorShare),
decimalWholeScanner(func(value int64) { metric.GameTurnover = value }, &gameTurnover),
&metric.GamePlayers,
decimalWholeScanner(func(value int64) { metric.GamePayout = value }, &gamePayout),
&metric.D1RetentionUsers,
&metric.D1RetentionBaseUsers,
&metric.D7RetentionUsers,
&metric.D7RetentionBaseUsers,
&metric.D30RetentionUsers,
&metric.D30RetentionBaseUsers,
&metric.RefreshedAt,
}
}
func (m *externalDashboardMetric) finalize() {
// worker 同时沉淀了官方、MifaPay、Google、用户充值和币商充值这里取能覆盖拆分口径的最大无重复总额避免 Google/MifaPay 被重复相加。
m.UserRechargeUSDMinor = maxInt64(m.UserRechargeUSDMinor, m.GoogleRechargeUSDMinor+m.MifaPayRechargeUSDMinor)
m.RechargeUSDMinor = maxInt64(
m.OfficialRechargeUSDMinor+m.MifaPayRechargeUSDMinor+m.CoinSellerRechargeUSDMinor,
maxInt64(m.UserRechargeUSDMinor+m.CoinSellerRechargeUSDMinor, m.GoogleRechargeUSDMinor+m.MifaPayRechargeUSDMinor+m.CoinSellerRechargeUSDMinor),
)
// dashboard-cdc-worker 的 daily_recharge_user 是充值去重用户;为兼容 Databi 旧字段名,同时输出 paid_users/recharge_users 两个同口径字段。
m.RechargeUsers = m.PaidUsers
m.ARPUUSDMinor = divideInt64(m.RechargeUSDMinor, m.ActiveUsers)
m.ARPPUUSDMinor = divideInt64(m.RechargeUSDMinor, m.PaidUsers)
m.PayerRate = ratioFloat64(m.PaidUsers, m.ActiveUsers)
m.PaidConversionRate = m.PayerRate
m.RechargeConversionRate = ratioFloat64(m.RechargeUsers, m.ActiveUsers)
m.GameProfit = m.GameTurnover - m.GamePayout
m.GameProfitRate = ratioFloat64(m.GameProfit, m.GameTurnover)
m.LuckyGiftProfit = m.LuckyGiftTurnover - m.LuckyGiftPayout
m.LuckyGiftPayoutRate = ratioFloat64(m.LuckyGiftPayout, m.LuckyGiftTurnover)
m.LuckyGiftProfitRate = ratioFloat64(m.LuckyGiftProfit, m.LuckyGiftTurnover)
m.D1RetentionRate = ratioFloat64(m.D1RetentionUsers, m.D1RetentionBaseUsers)
m.D7RetentionRate = ratioFloat64(m.D7RetentionUsers, m.D7RetentionBaseUsers)
m.D30RetentionRate = ratioFloat64(m.D30RetentionUsers, m.D30RetentionBaseUsers)
}
func (m externalDashboardMetric) toMap() map[string]any {
return map[string]any{
"active_users": m.ActiveUsers,
"arpu_usd_minor": m.ARPUUSDMinor,
"arppu_usd_minor": m.ARPPUUSDMinor,
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
"coin_seller_stock_coin": nil,
"coin_seller_transfer_coin": nil,
"coin_total": nullableInt64(m.CoinTotal),
"consumed_coin": nil,
"consume_output_ratio": nil,
"daily_active_user": m.ActiveUsers,
"d1_retention_base_users": m.D1RetentionBaseUsers,
"d1_retention_rate": m.D1RetentionRate,
"d1_retention_users": m.D1RetentionUsers,
"d7_retention_base_users": m.D7RetentionBaseUsers,
"d7_retention_rate": m.D7RetentionRate,
"d7_retention_users": m.D7RetentionUsers,
"d30_retention_base_users": m.D30RetentionBaseUsers,
"d30_retention_rate": m.D30RetentionRate,
"d30_retention_users": m.D30RetentionUsers,
"game_payout": m.GamePayout,
"game_players": m.GamePlayers,
"game_profit": m.GameProfit,
"game_profit_rate": m.GameProfitRate,
"game_turnover": m.GameTurnover,
"gift_coin_spent": m.GiftCoinSpent,
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
"lucky_gift_payers": m.LuckyGiftPayers,
"lucky_gift_payout": m.LuckyGiftPayout,
"lucky_gift_payout_rate": m.LuckyGiftPayoutRate,
"lucky_gift_profit": m.LuckyGiftProfit,
"lucky_gift_profit_rate": m.LuckyGiftProfitRate,
"lucky_gift_turnover": m.LuckyGiftTurnover,
"mifapay_recharge_usd_minor": m.MifaPayRechargeUSDMinor,
"mic_online_ms": nil,
"avg_mic_online_ms": nil,
"manual_grant_coin": nil,
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
"new_users": m.NewUsers,
"output_coin": nil,
"paid_users": m.PaidUsers,
"paid_conversion_rate": m.PaidConversionRate,
"payer_rate": m.PayerRate,
"platform_grant_coin": nil,
"recharge_usd_minor": m.RechargeUSDMinor,
"recharge_users": m.RechargeUsers,
"recharge_conversion_rate": m.RechargeConversionRate,
"registered_users": m.NewUsers,
"registrations": m.NewUsers,
"room_lucky_gift_turnover": m.LuckyGiftTurnover,
"salary_exchange_usd_minor": m.SalaryExchangeUSDMinor,
"salary_transfer_coin": nil,
"salary_transfer_usd_minor": m.SalaryTransferUSDMinor,
"salary_usd_minor": nullableInt64(m.SalaryUSDMinor),
"super_lucky_gift_payout": nil,
"super_lucky_gift_profit": nil,
"super_lucky_gift_profit_rate": nil,
"super_lucky_gift_turnover": nil,
"total_recharge_usd_minor": m.RechargeUSDMinor,
"user_recharge_usd_minor": m.UserRechargeUSDMinor,
}
}
func (m externalDashboardMetric) retentionMap() map[string]any {
return map[string]any{
"day1_base_users": m.D1RetentionBaseUsers,
"day1_rate": m.D1RetentionRate,
"day1_users": m.D1RetentionUsers,
"day7_base_users": m.D7RetentionBaseUsers,
"day7_rate": m.D7RetentionRate,
"day7_users": m.D7RetentionUsers,
"day30_base_users": m.D30RetentionBaseUsers,
"day30_rate": m.D30RetentionRate,
"day30_users": m.D30RetentionUsers,
"registered_users": m.NewUsers,
}
}
func (m externalDashboardMetric) updatedAtMS() int64 {
if m.RefreshedAt.Valid {
return m.RefreshedAt.Time.UTC().UnixMilli()
}
return time.Now().UTC().UnixMilli()
}
func applyExternalDashboardDeltas(out map[string]any, current externalDashboardMetric, previous externalDashboardMetric) {
out["active_users_delta_rate"] = deltaRate(current.ActiveUsers, previous.ActiveUsers)
out["arpu_delta_rate"] = deltaRate(current.ARPUUSDMinor, previous.ARPUUSDMinor)
out["arppu_delta_rate"] = deltaRate(current.ARPPUUSDMinor, previous.ARPPUUSDMinor)
out["game_payout_delta_rate"] = deltaRate(current.GamePayout, previous.GamePayout)
out["game_profit_delta_rate"] = deltaRate(current.GameProfit, previous.GameProfit)
out["game_turnover_delta_rate"] = deltaRate(current.GameTurnover, previous.GameTurnover)
out["gift_coin_spent_delta_rate"] = deltaRate(current.GiftCoinSpent, previous.GiftCoinSpent)
out["lucky_gift_payout_delta_rate"] = deltaRate(current.LuckyGiftPayout, previous.LuckyGiftPayout)
out["lucky_gift_profit_delta_rate"] = deltaRate(current.LuckyGiftProfit, previous.LuckyGiftProfit)
out["lucky_gift_turnover_delta_rate"] = deltaRate(current.LuckyGiftTurnover, previous.LuckyGiftTurnover)
out["new_users_delta_rate"] = deltaRate(current.NewUsers, previous.NewUsers)
out["paid_users_delta_rate"] = deltaRate(current.PaidUsers, previous.PaidUsers)
out["payer_rate_delta_pp"] = percentagePointDelta(current.PayerRate, previous.PayerRate)
out["paid_conversion_rate_delta_pp"] = percentagePointDelta(current.PaidConversionRate, previous.PaidConversionRate)
out["recharge_delta_rate"] = deltaRate(current.RechargeUSDMinor, previous.RechargeUSDMinor)
out["recharge_conversion_rate_delta_pp"] = percentagePointDelta(current.RechargeConversionRate, previous.RechargeConversionRate)
out["user_recharge_delta_rate"] = deltaRate(current.UserRechargeUSDMinor, previous.UserRechargeUSDMinor)
}
func externalDashboardMetricSources() []map[string]any {
return []map[string]any{
{"field": "new_users", "available": true, "source": "country_dashboard_period_metric.country_new_user"},
{"field": "active_users", "available": true, "source": "country_dashboard_period_metric.daily_active_user"},
{"field": "paid_users", "available": true, "source": "country_dashboard_period_metric.daily_recharge_user"},
{"field": "recharge_users", "available": true, "source": "country_dashboard_period_metric.daily_recharge_user"},
{"field": "recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric official/mifapay/google/dealer/user recharge"},
{"field": "new_user_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.new_user_recharge"},
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.dealer_recharge"},
{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有币商库存进货金币字段"},
{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有币商出货金币字段"},
{"field": "google_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.google_recharge"},
{"field": "mifapay_recharge_usd_minor", "available": true, "source": "country_dashboard_period_metric.mifapay_recharge"},
{"field": "game_turnover", "available": true, "source": "country_dashboard_period_metric.game_total_flow/game_payout"},
{"field": "lucky_gift_turnover", "available": true, "source": "country_dashboard_period_metric.lucky_gift_total_flow/lucky_gift_payout"},
{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "dashboard-cdc-worker 没有超级幸运礼物独立奖池字段"},
{"field": "gift_coin_spent", "available": true, "source": "country_dashboard_period_metric.gift_consume"},
{"field": "coin_total", "available": false, "missing_reason": "dashboard-cdc-worker 没有金币余额快照"},
{"field": "consumed_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有消耗金币总口径,不能把局部流水硬拼成总数"},
{"field": "output_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有产出金币总口径"},
{"field": "consume_output_ratio", "available": false, "missing_reason": "缺少消耗金币和产出金币总口径"},
{"field": "platform_grant_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有平台发放金币字段"},
{"field": "manual_grant_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有人工发放金币字段"},
{"field": "salary_usd_minor", "available": false, "missing_reason": "dashboard-cdc-worker 只有 salary_exchange/salary_transfer 流水,没有存量工资余额快照"},
{"field": "salary_transfer_coin", "available": false, "missing_reason": "dashboard-cdc-worker 没有工资兑换金币字段"},
{"field": "avg_mic_online_ms", "available": false, "missing_reason": "dashboard-cdc-worker 没有麦上时长字段"},
{"field": "mic_online_ms", "available": false, "missing_reason": "dashboard-cdc-worker 没有麦上时长字段"},
{"field": "arpu_usd_minor", "available": true, "source": "recharge_usd_minor / daily_active_user"},
{"field": "arppu_usd_minor", "available": true, "source": "recharge_usd_minor / daily_recharge_user"},
{"field": "paid_conversion_rate", "available": true, "source": "daily_recharge_user / daily_active_user"},
{"field": "recharge_conversion_rate", "available": true, "source": "daily_recharge_user / daily_active_user"},
{"field": "retention.day1_rate", "available": true, "source": "country_dashboard_period_metric.d1_retention_user/base_user"},
{"field": "retention.day7_rate", "available": true, "source": "country_dashboard_period_metric.d7_retention_user/base_user"},
{"field": "retention.day30_rate", "available": true, "source": "country_dashboard_period_metric.d30_retention_user/base_user"},
}
}
func externalDashboardDateRange(query StatisticsQuery, location *time.Location) (time.Time, time.Time, int64, int64) {
now := time.Now().In(location)
startMS := query.StartMS
if startMS <= 0 {
startMS = localMidnight(now, location).UnixMilli()
}
endMS := query.EndMS
if endMS <= startMS {
endMS = time.UnixMilli(startMS).In(location).AddDate(0, 0, 1).UnixMilli()
}
startDate := localMidnight(time.UnixMilli(startMS).In(location), location)
endMoment := time.UnixMilli(endMS - 1).In(location)
endDate := localMidnight(endMoment, location).AddDate(0, 0, 1)
return startDate, endDate, startDate.UnixMilli(), endDate.UnixMilli()
}
func localMidnight(t time.Time, location *time.Location) time.Time {
local := t.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}
func dashboardSQLDate(t time.Time) string {
return t.Format("2006-01-02")
}
func countCalendarDays(start time.Time, end time.Time) int {
days := 0
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
days++
}
if days <= 0 {
return 1
}
return days
}
type externalDecimalScanner struct {
raw *string
assign func(int64)
whole bool
}
func decimalMinorScanner(assign func(int64), raw *string) sql.Scanner {
return externalDecimalScanner{raw: raw, assign: assign}
}
func decimalWholeScanner(assign func(int64), raw *string) sql.Scanner {
return externalDecimalScanner{raw: raw, assign: assign, whole: true}
}
func (s externalDecimalScanner) Scan(value any) error {
var raw string
switch typed := value.(type) {
case nil:
raw = "0"
case []byte:
raw = string(typed)
case string:
raw = typed
default:
raw = fmt.Sprint(typed)
}
raw = strings.TrimSpace(raw)
if raw == "" || raw == "<nil>" {
raw = "0"
}
if s.raw != nil {
*s.raw = raw
}
minor, err := decimalTextToMinor(raw)
if err != nil {
return err
}
if s.whole {
minor = roundMinorToWhole(minor)
}
s.assign(minor)
return nil
}
func decimalTextToMinor(raw string) (int64, error) {
text := strings.TrimSpace(raw)
if text == "" {
return 0, nil
}
sign := int64(1)
if strings.HasPrefix(text, "-") {
sign = -1
text = strings.TrimPrefix(text, "-")
} else {
text = strings.TrimPrefix(text, "+")
}
wholeText, fractionText, _ := strings.Cut(text, ".")
if wholeText == "" {
wholeText = "0"
}
for len(fractionText) < 2 {
fractionText += "0"
}
if len(fractionText) > 2 {
fractionText = fractionText[:2]
}
whole, err := strconv.ParseInt(wholeText, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse decimal whole %q: %w", raw, err)
}
fraction, err := strconv.ParseInt(fractionText, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse decimal fraction %q: %w", raw, err)
}
return sign * (whole*100 + fraction), nil
}
func roundMinorToWhole(minor int64) int64 {
if minor < 0 {
return -roundMinorToWhole(-minor)
}
return (minor + 50) / 100
}
func divideInt64(numerator int64, denominator int64) int64 {
if denominator == 0 {
return 0
}
return int64(math.Round(float64(numerator) / float64(denominator)))
}
func ratioFloat64(numerator int64, denominator int64) float64 {
if denominator == 0 {
return 0
}
return float64(numerator) / float64(denominator)
}
func deltaRate(current int64, previous int64) float64 {
if previous == 0 {
if current == 0 {
return 0
}
return 1
}
return float64(current-previous) / float64(previous)
}
func percentagePointDelta(current float64, previous float64) float64 {
return (current - previous) * 100
}
func maxInt64(left int64, right int64) int64 {
if left > right {
return left
}
return right
}
func int64Ptr(value int64) *int64 {
out := value
return &out
}
func nullableInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func firstExternalDashboardValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func compactExternalDashboardRegionResolvers(resolvers []ExternalDashboardRegionResolver) []ExternalDashboardRegionResolver {
out := make([]ExternalDashboardRegionResolver, 0, len(resolvers))
for _, resolver := range resolvers {
if resolver != nil {
out = append(out, resolver)
}
}
return out
}
func normalizeExternalCountryCodes(codes []string) []string {
out := make([]string, 0, len(codes))
seen := map[string]struct{}{}
for _, rawCode := range codes {
code := strings.ToUpper(strings.TrimSpace(rawCode))
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
sort.Strings(out)
return out
}

View File

@ -0,0 +1,336 @@
package dashboard
import (
"context"
"database/sql/driver"
"math"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"hyapp-admin-server/internal/config"
)
func TestMySQLExternalDashboardSourceStatisticsOverview(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := NewMySQLExternalDashboardSource(db, config.DashboardExternalSourceConfig{
Enabled: true,
AppCode: "Yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
StatTimezone: "Asia/Riyadh",
RequestTimeout: time.Second,
})
if source == nil {
t.Fatal("source is nil")
}
refreshedAt := time.Date(2026, 6, 30, 23, 0, 0, 0, time.UTC)
current := externalMetricRow(refreshedAt,
7, 40, 5,
"25.00", "100.00", "20.00", "0.00", "5.00", "120.00", "2.00",
"30.00", "11.00", "900.00", "300.00", 4, "100.00", "10.00",
"300.00", 8, "110.00",
3, 6, 2, 10, 1, 20,
)
previous := externalMetricRow(refreshedAt.Add(-24*time.Hour),
5, 20, 4,
"10.00", "50.00", "10.00", "0.00", "5.00", "60.00", "0.00",
"0.00", "0.00", "400.00", "100.00", 2, "40.00", "0.00",
"180.00", 5, "100.00",
1, 5, 1, 8, 0, 10,
)
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(current...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-29", "2026-06-30").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(previous...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key")).AddRow(append([]driver.Value{"2026-06-30"}, current...)...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("country_code", "country_name")).AddRow(append([]driver.Value{"SA", "Saudi Arabia"}, current...)...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key", "country_code", "country_name")).AddRow(append([]driver.Value{"2026-06-30", "SA", "Saudi Arabia"}, current...)...))
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai)
overview, err := source.StatisticsOverview(context.Background(), StatisticsQuery{
AppCode: "yumi",
StatTZ: "Asia/Shanghai",
StartMS: start.UnixMilli(),
EndMS: start.AddDate(0, 0, 1).UnixMilli(),
})
if err != nil {
t.Fatalf("StatisticsOverview: %v", err)
}
assertEqual(t, overview["app_code"], "yumi")
assertEqual(t, overview["stat_tz"], "Asia/Riyadh")
assertEqual(t, overview["request_stat_tz"], "Asia/Shanghai")
assertInt64(t, overview["recharge_usd_minor"], 12_500)
assertInt64(t, overview["arppu_usd_minor"], 2_500)
assertInt64(t, overview["registered_users"], 7)
assertInt64(t, overview["recharge_users"], 5)
assertFloat(t, overview["paid_conversion_rate"], 0.125)
assertFloat(t, overview["recharge_conversion_rate"], 0.125)
assertFloat(t, overview["payer_rate_delta_pp"], -7.5)
assertInt64(t, overview["game_turnover"], 300)
assertInt64(t, overview["game_profit"], 190)
assertFloat(t, overview["d1_retention_rate"], 0.5)
if overview["coin_total"] != nil {
t.Fatalf("coin_total should be nil, got %#v", overview["coin_total"])
}
if overview["salary_usd_minor"] != nil {
t.Fatalf("salary_usd_minor should be nil, got %#v", overview["salary_usd_minor"])
}
series, ok := overview["daily_series"].([]map[string]any)
if !ok || len(series) != 1 {
t.Fatalf("daily_series mismatch: %#v", overview["daily_series"])
}
assertEqual(t, series[0]["stat_day"], "2026-06-30")
assertInt64(t, series[0]["recharge_usd_minor"], 12_500)
countries, ok := overview["country_breakdown"].([]map[string]any)
if !ok || len(countries) != 1 {
t.Fatalf("country_breakdown mismatch: %#v", overview["country_breakdown"])
}
assertEqual(t, countries[0]["country_code"], "SA")
assertEqual(t, countries[0]["country_name"], "Saudi Arabia")
sources, ok := overview["report_metric_sources"].([]map[string]any)
if !ok {
t.Fatalf("report_metric_sources mismatch: %#v", overview["report_metric_sources"])
}
assertMetricSourceUnavailable(t, sources, "coin_total")
assertMetricSourceUnavailable(t, sources, "salary_usd_minor")
assertMetricSourceUnavailable(t, sources, "coin_seller_stock_coin")
assertMetricSourceUnavailable(t, sources, "avg_mic_online_ms")
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLExternalDashboardSourceFiltersLegacyRegionCountries(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := NewMySQLExternalDashboardSource(db, config.DashboardExternalSourceConfig{
Enabled: true,
AppCode: "Yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
StatTimezone: "Asia/Riyadh",
RequestTimeout: time.Second,
}, staticDashboardRegionResolver{appCode: "yumi", regionID: 1001, countryCodes: []string{"SA", "AE"}})
if source == nil {
t.Fatal("source is nil")
}
refreshedAt := time.Date(2026, 6, 30, 23, 0, 0, 0, time.UTC)
current := externalMetricRow(refreshedAt,
7, 40, 5,
"25.00", "100.00", "20.00", "0.00", "5.00", "120.00", "2.00",
"30.00", "11.00", "900.00", "300.00", 4, "100.00", "10.00",
"300.00", 8, "110.00",
3, 6, 2, 10, 1, 20,
)
previous := externalMetricRow(refreshedAt.Add(-24*time.Hour),
5, 20, 4,
"10.00", "50.00", "10.00", "0.00", "5.00", "60.00", "0.00",
"0.00", "0.00", "400.00", "100.00", 2, "40.00", "0.00",
"180.00", 5, "100.00",
1, 5, 1, 8, 0, 10,
)
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(current...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-29", "2026-06-30", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(previous...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key")).AddRow(append([]driver.Value{"2026-06-30"}, current...)...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("country_code", "country_name")).AddRow(append([]driver.Value{"SA", "Saudi Arabia"}, current...)...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key", "country_code", "country_name")).AddRow(append([]driver.Value{"2026-06-30", "SA", "Saudi Arabia"}, current...)...))
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai)
overview, err := source.StatisticsOverview(context.Background(), StatisticsQuery{
AppCode: "yumi",
StatTZ: "Asia/Shanghai",
StartMS: start.UnixMilli(),
EndMS: start.AddDate(0, 0, 1).UnixMilli(),
RegionID: 1001,
})
if err != nil {
t.Fatalf("StatisticsOverview: %v", err)
}
countries, ok := overview["country_breakdown"].([]map[string]any)
if !ok || len(countries) != 1 {
t.Fatalf("country_breakdown mismatch: %#v", overview["country_breakdown"])
}
assertEqual(t, countries[0]["region_id"], int64(1001))
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func expectExternalMetricQuery(mock sqlmock.Sqlmock, statTimezone string, sysOrigin string, startDate string, endDate string) *sqlmock.ExpectedQuery {
return mock.ExpectQuery("FROM country_dashboard_period_metric").
WithArgs(statTimezone, sysOrigin, startDate, endDate)
}
func expectExternalMetricQueryWithCountries(mock sqlmock.Sqlmock, statTimezone string, sysOrigin string, startDate string, endDate string, countryCodes ...string) *sqlmock.ExpectedQuery {
args := []driver.Value{statTimezone, sysOrigin, startDate, endDate}
for _, countryCode := range countryCodes {
args = append(args, countryCode)
}
return mock.ExpectQuery("FROM country_dashboard_period_metric").
WithArgs(args...)
}
type staticDashboardRegionResolver struct {
appCode string
regionID int64
countryCodes []string
}
func (r staticDashboardRegionResolver) CountryCodesForRegion(_ context.Context, appCode string, regionID int64) ([]string, bool, error) {
if r.appCode != appCode || r.regionID != regionID {
return nil, false, nil
}
return r.countryCodes, true, nil
}
func externalMetricColumns(prefix ...string) []string {
columns := []string{
"country_new_user",
"daily_active_user",
"daily_recharge_user",
"new_user_recharge",
"official_recharge",
"mifapay_recharge",
"google_recharge",
"dealer_recharge",
"user_recharge",
"new_dealer_user_recharge",
"salary_exchange",
"salary_transfer",
"gift_consume",
"lucky_gift_total_flow",
"lucky_gift_user",
"lucky_gift_payout",
"lucky_gift_anchor_share",
"game_total_flow",
"game_user",
"game_payout",
"d1_retention_user",
"d1_retention_base_user",
"d7_retention_user",
"d7_retention_base_user",
"d30_retention_user",
"d30_retention_base_user",
"refreshed_at",
}
return append(prefix, columns...)
}
func externalMetricRow(refreshedAt time.Time,
newUsers int64, activeUsers int64, paidUsers int64,
newUserRecharge string, officialRecharge string, mifaPayRecharge string, googleRecharge string, dealerRecharge string, userRecharge string, newDealerRecharge string,
salaryExchange string, salaryTransfer string, giftConsume string, luckyGiftTurnover string, luckyGiftPayers int64, luckyGiftPayout string, luckyGiftAnchorShare string,
gameTurnover string, gamePlayers int64, gamePayout string,
d1Users int64, d1Base int64, d7Users int64, d7Base int64, d30Users int64, d30Base int64,
) []driver.Value {
return []driver.Value{
newUsers,
activeUsers,
paidUsers,
newUserRecharge,
officialRecharge,
mifaPayRecharge,
googleRecharge,
dealerRecharge,
userRecharge,
newDealerRecharge,
salaryExchange,
salaryTransfer,
giftConsume,
luckyGiftTurnover,
luckyGiftPayers,
luckyGiftPayout,
luckyGiftAnchorShare,
gameTurnover,
gamePlayers,
gamePayout,
d1Users,
d1Base,
d7Users,
d7Base,
d30Users,
d30Base,
refreshedAt,
}
}
func assertEqual(t *testing.T, got any, want any) {
t.Helper()
if got != want {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func assertInt64(t *testing.T, got any, want int64) {
t.Helper()
value, ok := got.(int64)
if !ok {
t.Fatalf("got %T(%#v), want int64(%d)", got, got, want)
}
if value != want {
t.Fatalf("got %d, want %d", value, want)
}
}
func assertFloat(t *testing.T, got any, want float64) {
t.Helper()
value, ok := got.(float64)
if !ok {
t.Fatalf("got %T(%#v), want float64(%f)", got, got, want)
}
if math.Abs(value-want) > 0.000001 {
t.Fatalf("got %f, want %f", value, want)
}
}
func assertMetricSourceUnavailable(t *testing.T, sources []map[string]any, field string) {
t.Helper()
for _, source := range sources {
if source["field"] != field {
continue
}
if source["available"] != false {
t.Fatalf("%s should be unavailable: %#v", field, source)
}
if source["missing_reason"] == "" {
t.Fatalf("%s missing reason is empty: %#v", field, source)
}
return
}
t.Fatalf("metric source %s not found: %#v", field, sources)
}

View File

@ -15,8 +15,8 @@ type Handler struct {
service *DashboardService
}
func New(store *repository.Store, cfg config.Config, user userclient.Client) *Handler {
return &Handler{service: NewService(store, cfg, user)}
func New(store *repository.Store, cfg config.Config, user userclient.Client, options ...Option) *Handler {
return &Handler{service: NewService(store, cfg, user, options...)}
}
func (h *Handler) DashboardOverview(c *gin.Context) {
@ -64,7 +64,63 @@ func (h *Handler) SelfGameStatisticsOverview(c *gin.Context) {
response.OK(c, overview)
}
func (h *Handler) PlatformGrantUsers(c *gin.Context) {
page, err := h.service.PlatformGrantUsers(c.Request.Context(), PlatformGrantStatisticsQuery{
AppCode: c.DefaultQuery("app_code", "lalu"),
StatTZ: c.Query("stat_tz"),
RequestID: middleware.CurrentRequestID(c),
StartMS: parseInt64(c.Query("start_ms")),
EndMS: parseInt64(c.Query("end_ms")),
StatDay: c.Query("stat_day"),
RegionID: parseInt64(c.Query("region_id")),
CountryID: parseInt64(c.Query("country_id")),
Source: firstNonEmpty(c.Query("source"), c.Query("event_type")),
Page: parseInt(c.Query("page")),
PageSize: parseInt(firstNonEmpty(c.Query("page_size"), c.Query("pageSize"))),
})
if err != nil {
response.ServerError(c, "获取平台发放金币用户明细失败")
return
}
response.OK(c, page)
}
func (h *Handler) PlatformGrantRecords(c *gin.Context) {
page, err := h.service.PlatformGrantRecords(c.Request.Context(), PlatformGrantStatisticsQuery{
AppCode: c.DefaultQuery("app_code", "lalu"),
StatTZ: c.Query("stat_tz"),
StartMS: parseInt64(c.Query("start_ms")),
EndMS: parseInt64(c.Query("end_ms")),
StatDay: c.Query("stat_day"),
RegionID: parseInt64(c.Query("region_id")),
CountryID: parseInt64(c.Query("country_id")),
UserID: parseInt64(c.Query("user_id")),
Source: firstNonEmpty(c.Query("source"), c.Query("event_type")),
Page: parseInt(c.Query("page")),
PageSize: parseInt(firstNonEmpty(c.Query("page_size"), c.Query("pageSize"))),
})
if err != nil {
response.ServerError(c, "获取平台发放金币来源明细失败")
return
}
response.OK(c, page)
}
func parseInt64(value string) int64 {
parsed, _ := strconv.ParseInt(value, 10, 64)
return parsed
}
func parseInt(value string) int {
parsed, _ := strconv.Atoi(value)
return parsed
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@ -9,5 +9,7 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
protected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
protected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)
protected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
}

View File

@ -15,10 +15,11 @@ import (
)
type DashboardService struct {
store *repository.Store
cfg config.Config
httpClient *http.Client
user userclient.Client
store *repository.Store
cfg config.Config
httpClient *http.Client
user userclient.Client
externalSources map[string]ExternalDashboardSource
}
type StatisticsQuery struct {
@ -43,20 +44,72 @@ type SelfGameStatisticsQuery struct {
GameID string
}
func NewService(store *repository.Store, cfg config.Config, user userclient.Client) *DashboardService {
return &DashboardService{
store: store,
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout},
user: user,
type PlatformGrantStatisticsQuery struct {
AppCode string
StatTZ string
RequestID string
StartMS int64
EndMS int64
StatDay string
RegionID int64
CountryID int64
UserID int64
Source string
Page int
PageSize int
}
type ExternalDashboardSource interface {
AppCode() string
StatisticsOverview(context.Context, StatisticsQuery) (map[string]any, error)
}
type ExternalDashboardRegionResolver interface {
CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error)
}
type Option func(*DashboardService)
func WithExternalDashboardSources(sources ...ExternalDashboardSource) Option {
return func(s *DashboardService) {
for _, source := range sources {
if source == nil {
continue
}
appCode := strings.ToLower(strings.TrimSpace(source.AppCode()))
if appCode == "" {
continue
}
s.externalSources[appCode] = source
}
}
}
func NewService(store *repository.Store, cfg config.Config, user userclient.Client, options ...Option) *DashboardService {
service := &DashboardService{
store: store,
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout},
user: user,
externalSources: map[string]ExternalDashboardSource{},
}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *DashboardService) Overview() (*repository.DashboardOverview, error) {
return s.store.DashboardOverview()
}
func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
if source := s.externalSources[strings.ToLower(strings.TrimSpace(query.AppCode))]; source != nil {
// Yumi 这类外接 App 不属于 hyapp statistics-service 的事实域;命中外部源时直接读 dashboard-cdc-worker 的预聚合库。
return source.StatisticsOverview(ctx, query)
}
values := url.Values{}
values.Set("app_code", query.AppCode)
if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" {
@ -114,6 +167,28 @@ func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query
return overview, nil
}
func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
if err != nil {
return nil, err
}
// 统计服务只返回用户 ID 和金币聚合admin 层用 user-service 批量补展示资料,避免统计库复制用户主数据。
_ = s.enrichPlatformGrantUsers(ctx, query, page)
return page, nil
}
func (s *DashboardService) PlatformGrantRecords(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
if query.UserID <= 0 {
return nil, fmt.Errorf("user_id is required")
}
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/records", platformGrantValues(query, true))
if err != nil {
return nil, err
}
stringifyPageUserIDs(page)
return page, nil
}
func (s *DashboardService) statisticsGET(ctx context.Context, path string, values url.Values) (map[string]any, error) {
baseURL := s.cfg.StatisticsService.BaseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+values.Encode(), nil)
@ -137,6 +212,122 @@ func (s *DashboardService) statisticsGET(ctx context.Context, path string, value
return out, nil
}
func platformGrantValues(query PlatformGrantStatisticsQuery, includeUser bool) url.Values {
values := url.Values{}
values.Set("app_code", query.AppCode)
if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" {
values.Set("stat_tz", statTZ)
}
if query.StartMS > 0 {
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
}
if query.EndMS > 0 {
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
}
if statDay := strings.TrimSpace(query.StatDay); statDay != "" {
values.Set("stat_day", statDay)
}
if query.RegionID > 0 {
values.Set("region_id", strconv.FormatInt(query.RegionID, 10))
}
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
if includeUser && query.UserID > 0 {
values.Set("user_id", strconv.FormatInt(query.UserID, 10))
}
if source := strings.TrimSpace(query.Source); source != "" && source != "all" {
values.Set("source", source)
}
if query.Page > 0 {
values.Set("page", strconv.Itoa(query.Page))
}
if query.PageSize > 0 {
values.Set("page_size", strconv.Itoa(query.PageSize))
}
return values
}
func (s *DashboardService) enrichPlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery, page map[string]any) error {
if s.user == nil || page == nil {
return nil
}
rows, ok := page["items"].([]any)
if !ok || len(rows) == 0 {
return nil
}
userIDs := make([]int64, 0, len(rows))
seen := map[int64]struct{}{}
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
userID := anyInt64(row["user_id"])
if userID <= 0 {
continue
}
// user_id 可能超过 JS 安全整数admin 统一转字符串输出,前端分页和后续来源查询都使用字符串透传。
row["user_id"] = strconv.FormatInt(userID, 10)
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
userIDs = append(userIDs, userID)
}
if len(userIDs) == 0 {
return nil
}
users, err := s.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{
RequestID: query.RequestID,
Caller: "admin-server",
UserIDs: userIDs,
})
if err != nil {
return err
}
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
userID := anyInt64(row["user_id"])
user := users[userID]
if user == nil {
continue
}
userPayload := map[string]any{
"user_id": strconv.FormatInt(userID, 10),
"avatar": user.Avatar,
"username": user.Username,
"display_user_id": user.DisplayUserID,
}
row["user"] = userPayload
row["avatar"] = user.Avatar
row["nickname"] = user.Username
row["username"] = user.Username
row["display_user_id"] = user.DisplayUserID
row["short_id"] = user.DisplayUserID
}
return nil
}
func stringifyPageUserIDs(page map[string]any) {
rows, ok := page["items"].([]any)
if !ok {
return
}
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
if userID := anyInt64(row["user_id"]); userID > 0 {
row["user_id"] = strconv.FormatInt(userID, 10)
}
}
}
func (s *DashboardService) enrichSelfGameRiskUsers(ctx context.Context, query SelfGameStatisticsQuery, overview map[string]any) error {
if s.user == nil || overview == nil {
return nil

View File

@ -0,0 +1,119 @@
package financeapplication
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/integration/dingtalk"
)
type dingTalkNotifier struct {
client *dingtalk.Client
}
func NewDingTalkNotifier(client *dingtalk.Client) ApplicationNotifier {
if client == nil {
return nil
}
return &dingTalkNotifier{client: client}
}
func (n *dingTalkNotifier) NotifyApplicationCreated(ctx context.Context, application applicationDTO) error {
if n == nil || n.client == nil {
return nil
}
return n.client.SendMarkdown(ctx, dingtalk.MarkdownMessage{
Title: "财务申请待审核",
Text: financeApplicationCreatedMarkdown(application),
})
}
func financeApplicationCreatedMarkdown(application applicationDTO) string {
lines := []string{
"### 财务申请待审核",
"",
fmt.Sprintf("- 申请ID%d", application.ID),
fmt.Sprintf("- APP%s", markdownValue(application.AppCode)),
fmt.Sprintf("- 操作:%s", markdownValue(operationLabel(application.Operation))),
fmt.Sprintf("- 目标用户ID%s", markdownValue(application.TargetUserID)),
fmt.Sprintf("- 金币数量:%d", application.CoinAmount),
fmt.Sprintf("- 充值金额 $ %s", markdownValue(application.RechargeAmount)),
fmt.Sprintf("- 申请人:%s", markdownValue(actorLabel(application.ApplicantName, application.ApplicantUserID))),
fmt.Sprintf("- 发起时间:%s", markdownValue(formatNotifyMillis(application.CreatedAtMS))),
"- 后台地址https://admin-acc.global-interaction.com/finance/",
}
if application.WalletIdentity != "" {
lines = append(lines, fmt.Sprintf("- 钱包身份:%s", markdownValue(walletIdentityLabel(application.WalletIdentity))))
}
if application.CredentialText != "" {
lines = append(lines, fmt.Sprintf("- 凭证文字:%s", markdownValue(application.CredentialText)))
}
if application.CredentialImageURL != "" {
imageURL := strings.TrimSpace(application.CredentialImageURL)
lines = append(lines, "- 凭证图片:", fmt.Sprintf("![凭证图片](%s)", imageURL))
}
return strings.Join(lines, "\n")
}
func operationLabel(value string) string {
switch value {
case "user_coin_credit":
return "增加用户金币"
case "user_coin_debit":
return "减少用户金币"
case "user_wallet_debit":
return "用户钱包扣减"
case "user_wallet_credit":
return "用户钱包增加"
case "coin_seller_coin_credit":
return "增加币商金币"
case "coin_seller_coin_debit":
return "减少币商金币"
default:
return value
}
}
func walletIdentityLabel(value string) string {
switch value {
case "host":
return "host"
case "agency":
return "agency"
case "bd":
return "bd"
default:
return value
}
}
func actorLabel(name string, userID uint) string {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Sprintf("%d", userID)
}
return fmt.Sprintf("%s(%d)", name, userID)
}
func formatNotifyMillis(value int64) string {
if value <= 0 {
return "-"
}
// 财务审批群主要按北京时间协作;数据库仍保存 Unix 毫秒,这里只在通知文本中转换展示。
return time.UnixMilli(value).In(time.FixedZone("UTC+8", 8*60*60)).Format("2006-01-02 15:04:05")
}
func markdownValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "-"
}
if len([]rune(value)) > 240 {
runes := []rune(value)
value = string(runes[:240]) + "..."
}
replacer := strings.NewReplacer("\r", " ", "\n", " ", "\t", " ")
return replacer.Replace(value)
}

View File

@ -0,0 +1,42 @@
package financeapplication
import (
"strings"
"testing"
"time"
)
func TestFinanceApplicationCreatedMarkdown(t *testing.T) {
text := financeApplicationCreatedMarkdown(applicationDTO{
ID: 12,
AppCode: "lalu",
Operation: "coin_seller_coin_credit",
TargetUserID: "10001",
CoinAmount: 1000,
RechargeAmount: "10.50",
CredentialImageURL: "https://example.com/receipt.png",
CredentialText: "bank order\nabc",
ApplicantUserID: 7,
ApplicantName: "ops",
CreatedAtMS: time.Date(2026, 7, 1, 1, 2, 3, 0, time.UTC).UnixMilli(),
})
for _, want := range []string{
"### 财务申请待审核",
"- APPlalu",
"- 操作:增加币商金币",
"- 目标用户ID10001",
"- 金币数量1000",
"- 充值金额 $ 10.50",
"- 申请人ops(7)",
"- 发起时间2026-07-01 09:02:03",
"- 后台地址https://admin-acc.global-interaction.com/finance/",
"- 凭证文字bank order abc",
"- 凭证图片:",
"![凭证图片](https://example.com/receipt.png)",
} {
if !strings.Contains(text, want) {
t.Fatalf("markdown missing %q:\n%s", want, text)
}
}
}

View File

@ -0,0 +1,180 @@
package financeapplication
import (
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
func New(store *repository.Store, audit shared.OperationLogger, options ...Option) *Handler {
return &Handler{service: NewService(store, options...), audit: audit}
}
func (h *Handler) ListApplications(c *gin.Context) {
options := shared.ListOptions(c)
items, total, err := h.service.ListApplications(repository.FinanceApplicationListOptions{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"),
Operation: firstQuery(c, "operation"),
Status: options.Status,
Keyword: options.Keyword,
})
if err != nil {
response.ServerError(c, "获取财务申请列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) ListMyApplications(c *gin.Context) {
options := shared.ListOptions(c)
items, total, err := h.service.ListMyApplications(shared.ActorFromContext(c), repository.FinanceApplicationListOptions{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"),
Operation: firstQuery(c, "operation"),
Status: options.Status,
Keyword: options.Keyword,
})
if err != nil {
writeServiceError(c, err, "获取我的财务申请列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) CreateApplication(c *gin.Context) {
var req createApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "财务申请参数不正确")
return
}
item, err := h.service.CreateApplication(c.Request.Context(), shared.ActorFromContext(c), req)
if err != nil {
writeServiceError(c, err, "发起财务申请失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-finance-application", "admin_finance_applications", idString(item.ID), "success", item.Operation)
response.Created(c, item)
}
func (h *Handler) ApproveApplication(c *gin.Context) {
h.auditApplication(c, model.FinanceApplicationStatusApproved)
}
func (h *Handler) RejectApplication(c *gin.Context) {
h.auditApplication(c, model.FinanceApplicationStatusRejected)
}
func (h *Handler) AuditApplication(c *gin.Context) {
var req struct {
Decision string `json:"decision"`
AuditRemark string `json:"auditRemark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "审核参数不正确")
return
}
switch strings.TrimSpace(req.Decision) {
case model.FinanceApplicationStatusApproved:
h.auditApplicationWithRemark(c, model.FinanceApplicationStatusApproved, req.AuditRemark)
case model.FinanceApplicationStatusRejected:
h.auditApplicationWithRemark(c, model.FinanceApplicationStatusRejected, req.AuditRemark)
default:
response.BadRequest(c, "审核结果不正确")
}
}
func (h *Handler) auditApplication(c *gin.Context, decision string) {
var req auditApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "审核参数不正确")
return
}
h.auditApplicationWithRemark(c, decision, req.AuditRemark)
}
func (h *Handler) auditApplicationWithRemark(c *gin.Context, decision string, remark string) {
id, ok := shared.ParseID(c, "application_id")
if !ok {
return
}
var (
item *applicationDTO
err error
)
if decision == model.FinanceApplicationStatusApproved {
item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, remark, middleware.CurrentRequestID(c))
} else {
item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, remark, middleware.CurrentRequestID(c))
}
if err != nil {
writeServiceError(c, err, "审核财务申请失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "audit-finance-application", "admin_finance_applications", idString(item.ID), "success", decision)
response.OK(c, item)
}
func writeServiceError(c *gin.Context, err error, fallback string) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "财务申请不存在")
return
}
if errors.Is(err, repository.ErrFinanceApplicationAlreadyAudited) {
response.BadRequest(c, "申请已审核")
return
}
switch err.Error() {
case "没有操作权限":
response.Forbidden(c, err.Error())
case "admin store is not configured", "finance wallet executor is not configured":
response.ServerError(c, fallback)
default:
if status.Code(err) == codes.NotFound {
response.NotFound(c, err.Error())
return
}
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {
switch st.Code() {
case codes.PermissionDenied:
response.Forbidden(c, st.Message())
return
case codes.InvalidArgument, codes.FailedPrecondition, codes.AlreadyExists, codes.Aborted, codes.ResourceExhausted:
response.BadRequest(c, st.Message())
return
}
}
response.BadRequest(c, err.Error())
}
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
func idString(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}

View File

@ -0,0 +1,16 @@
package financeapplication
type createApplicationRequest struct {
AppCode string `json:"appCode"`
Operation string `json:"operation"`
WalletIdentity string `json:"walletIdentity"`
TargetUserID string `json:"targetUserId"`
CoinAmount int64 `json:"coinAmount"`
RechargeAmount float64 `json:"rechargeAmount"`
CredentialImageURL string `json:"credentialImageUrl"`
CredentialText string `json:"credentialText"`
}
type auditApplicationRequest struct {
AuditRemark string `json:"auditRemark"`
}

View File

@ -0,0 +1,68 @@
package financeapplication
import "hyapp-admin-server/internal/model"
type applicationDTO struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
Operation string `json:"operation"`
WalletIdentity string `json:"walletIdentity"`
TargetUserID string `json:"targetUserId"`
CoinAmount int64 `json:"coinAmount"`
RechargeAmount string `json:"rechargeAmount"`
CredentialImageURL string `json:"credentialImageUrl"`
CredentialText string `json:"credentialText"`
ApplicantUserID uint `json:"applicantUserId"`
ApplicantName string `json:"applicantName"`
AuditorUserID *uint `json:"auditorUserId"`
AuditorName string `json:"auditorName"`
Status string `json:"status"`
AuditRemark string `json:"auditRemark"`
AuditedAtMS *int64 `json:"auditedAtMs"`
WalletCommandID string `json:"walletCommandId"`
WalletTransactionID string `json:"walletTransactionId"`
WalletAssetType string `json:"walletAssetType"`
WalletAmountDelta int64 `json:"walletAmountDelta"`
WalletBalanceAfter int64 `json:"walletBalanceAfter"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
DingTalkNotified *bool `json:"dingTalkNotified,omitempty"`
}
func applicationDTOFromModel(item model.FinanceApplication) applicationDTO {
return applicationDTO{
ID: item.ID,
AppCode: item.AppCode,
AppName: item.AppCode,
Operation: item.Operation,
WalletIdentity: item.WalletIdentity,
TargetUserID: item.TargetUserID,
CoinAmount: item.CoinAmount,
RechargeAmount: item.RechargeAmount,
CredentialImageURL: item.CredentialImageURL,
CredentialText: item.CredentialText,
ApplicantUserID: item.ApplicantUserID,
ApplicantName: item.ApplicantName,
AuditorUserID: item.AuditorUserID,
AuditorName: item.AuditorName,
Status: item.Status,
AuditRemark: item.AuditRemark,
AuditedAtMS: item.AuditedAtMS,
WalletCommandID: item.WalletCommandID,
WalletTransactionID: item.WalletTransactionID,
WalletAssetType: item.WalletAssetType,
WalletAmountDelta: item.WalletAmountDelta,
WalletBalanceAfter: item.WalletBalanceAfter,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
}
func applicationDTOsFromModel(items []model.FinanceApplication) []applicationDTO {
out := make([]applicationDTO, 0, len(items))
for _, item := range items {
out = append(out, applicationDTOFromModel(item))
}
return out
}

View File

@ -0,0 +1,19 @@
package financeapplication
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/finance/applications", middleware.RequirePermission(permissionAuditApplication), h.ListApplications)
protected.GET("/admin/finance/applications/mine", middleware.RequirePermission(permissionCreateApplication), h.ListMyApplications)
protected.POST("/admin/finance/applications", middleware.RequirePermission(permissionCreateApplication), h.CreateApplication)
protected.POST("/admin/finance/applications/:application_id/approve", middleware.RequirePermission(permissionAuditApplication), h.ApproveApplication)
protected.POST("/admin/finance/applications/:application_id/reject", middleware.RequirePermission(permissionAuditApplication), h.RejectApplication)
protected.POST("/admin/finance/applications/:application_id/audit", middleware.RequirePermission(permissionAuditApplication), h.AuditApplication)
}

View File

@ -0,0 +1,623 @@
package financeapplication
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
walletv1 "hyapp.local/api/proto/wallet/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
permissionCreateApplication = "finance-application:create"
permissionAuditApplication = "finance-application:audit"
financeInternalUserIDMinDigits = 15
financeAssetCoin = "COIN"
financeAssetCoinSellerCoin = "COIN_SELLER_COIN"
financeAssetHostSalaryUSD = "HOST_SALARY_USD"
financeAssetAgencySalaryUSD = "AGENCY_SALARY_USD"
financeAssetBDSalaryUSD = "BD_SALARY_USD"
financeStockTypeUSDTPurchase = "usdt_purchase"
financeStockTypeUSDTDeduction = "usdt_deduction"
)
var (
errInvalidCreateInput = errors.New("财务申请参数不正确")
errInvalidAuditInput = errors.New("审核参数不正确")
)
type operationDefinition struct {
Value string
PermissionCode string
RequiresWalletIdentity bool
AllowsZeroRechargeAmount bool
}
type Service struct {
store *repository.Store
wallet walletclient.Client
user userclient.Client
now func() time.Time
notifier ApplicationNotifier
}
type Option func(*Service)
type ApplicationNotifier interface {
NotifyApplicationCreated(ctx context.Context, application applicationDTO) error
}
func WithWalletClient(wallet walletclient.Client) Option {
return func(service *Service) {
service.wallet = wallet
}
}
func WithUserClient(user userclient.Client) Option {
return func(service *Service) {
service.user = user
}
}
func WithApplicationNotifier(notifier ApplicationNotifier) Option {
return func(service *Service) {
service.notifier = notifier
}
}
func NewService(store *repository.Store, options ...Option) *Service {
service := &Service{store: store, now: func() time.Time { return time.Now().UTC() }}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *Service) CreateApplication(ctx context.Context, actor shared.Actor, req createApplicationRequest) (*applicationDTO, error) {
if s == nil || s.store == nil {
return nil, errors.New("admin store is not configured")
}
input, err := s.normalizeCreateInput(actor, req)
if err != nil {
return nil, err
}
if err := s.store.CreateFinanceApplication(&input); err != nil {
return nil, err
}
item, err := s.store.GetFinanceApplication(input.ID)
if err != nil {
return nil, err
}
dto := applicationDTOFromModel(*item)
notified := false
if s.notifier != nil {
// 申请落库后再通知财务群;通知失败只影响提醒,不回滚申请,避免外部机器人抖动造成业务申请丢失。
if err := s.notifier.NotifyApplicationCreated(ctx, dto); err != nil {
slog.Warn("finance_application_dingtalk_notify_failed", "application_id", dto.ID, "app_code", dto.AppCode, "target_user_id", dto.TargetUserID, "error", err)
} else {
notified = true
}
}
dto.DingTalkNotified = &notified
return &dto, nil
}
func (s *Service) ListApplications(options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
items, total, err := s.store.ListFinanceApplications(options)
if err != nil {
return nil, 0, err
}
return applicationDTOsFromModel(items), total, nil
}
func (s *Service) ListMyApplications(actor shared.Actor, options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCreateApplication) {
return nil, 0, errors.New("没有操作权限")
}
// 申请人范围必须在服务端写入查询条件;前端只传列表筛选项,不能决定 applicant_user_id。
options.ApplicantUserID = actor.UserID
items, total, err := s.store.ListFinanceApplications(options)
if err != nil {
return nil, 0, err
}
return applicationDTOsFromModel(items), total, nil
}
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusApproved, remark, requestID)
}
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusRejected, remark, requestID)
}
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, requestID string) (*applicationDTO, error) {
if s == nil || s.store == nil {
return nil, errors.New("admin store is not configured")
}
if id == 0 || !hasPermission(actor.Permissions, permissionAuditApplication) {
return nil, errInvalidAuditInput
}
execution := walletExecutionResult{}
if decision == model.FinanceApplicationStatusApproved {
application, err := s.store.GetFinanceApplication(id)
if err != nil {
return nil, err
}
if application.Status != model.FinanceApplicationStatusPending {
return nil, repository.ErrFinanceApplicationAlreadyAudited
}
execution, err = s.executeApprovedApplication(ctx, actor, *application, requestID)
if err != nil {
return nil, err
}
}
item, err := s.store.AuditFinanceApplication(id, repository.FinanceApplicationAuditInput{
Decision: decision,
AuditorUserID: actor.UserID,
AuditorName: actor.Username,
AuditRemark: strings.TrimSpace(remark),
AuditedAtMS: s.now().UnixMilli(),
WalletCommandID: execution.CommandID,
WalletTransactionID: execution.TransactionID,
WalletAssetType: execution.AssetType,
WalletAmountDelta: execution.AmountDelta,
WalletBalanceAfter: execution.BalanceAfter,
})
if err != nil {
return nil, err
}
dto := applicationDTOFromModel(*item)
return &dto, nil
}
type walletExecutionResult struct {
CommandID string
TransactionID string
AssetType string
AmountDelta int64
BalanceAfter int64
}
func (s *Service) executeApprovedApplication(ctx context.Context, actor shared.Actor, application model.FinanceApplication, requestID string) (walletExecutionResult, error) {
if s.wallet == nil || s.user == nil {
return walletExecutionResult{}, errors.New("finance wallet executor is not configured")
}
if actor.UserID == 0 {
return walletExecutionResult{}, errors.New("没有操作权限")
}
appCode := appctx.Normalize(application.AppCode)
ctx = appctx.WithContext(ctx, appCode)
target, err := s.resolveTargetUser(ctx, strings.TrimSpace(requestID), application.TargetUserID)
if err != nil {
return walletExecutionResult{}, err
}
commandID := financeWalletCommandID(application.ID, application.Operation)
reason := financeWalletReason(application)
evidenceRef := financeWalletEvidenceRef(application.ID)
switch application.Operation {
case "user_coin_credit", "user_coin_debit":
// 普通用户金币只落 COIN 资产;扣减用带符号金额交给 wallet-service 做余额不透支校验。
amount := signedAmount(application.CoinAmount, application.Operation == "user_coin_debit")
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, financeAssetCoin, amount, int64(actor.UserID), reason, evidenceRef)
case "user_wallet_credit", "user_wallet_debit":
assetType, err := financeSalaryAssetType(application.WalletIdentity)
if err != nil {
return walletExecutionResult{}, err
}
if err := s.requireWalletIdentity(ctx, strings.TrimSpace(requestID), target.UserID, application.WalletIdentity); err != nil {
return walletExecutionResult{}, err
}
amountMinor, err := decimalAmountMinor(application.RechargeAmount)
if err != nil {
return walletExecutionResult{}, err
}
// 身份钱包是美元工资资产,单位使用 wallet-service 现有的 minor 口径;金币数量不参与这类调账。
amount := signedAmount(amountMinor, application.Operation == "user_wallet_debit")
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, assetType, amount, int64(actor.UserID), reason, evidenceRef)
case "coin_seller_coin_credit", "coin_seller_coin_debit":
if err := s.requireActiveCoinSeller(ctx, strings.TrimSpace(requestID), target); err != nil {
return walletExecutionResult{}, err
}
paidAmountMicro, err := decimalAmountMicro(application.RechargeAmount)
if err != nil {
return walletExecutionResult{}, err
}
stockType := financeStockType(application.Operation)
resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: target.UserID,
StockType: stockType,
CoinAmount: application.CoinAmount,
PaidCurrencyCode: "USDT",
PaidAmountMicro: paidAmountMicro,
PaymentRef: evidenceRef,
EvidenceRef: evidenceRef,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
SellerCountryId: target.CountryID,
SellerRegionId: target.RegionID,
})
if err != nil {
return walletExecutionResult{}, err
}
return walletExecutionResult{
CommandID: commandID,
TransactionID: resp.GetTransactionId(),
AssetType: financeAssetCoinSellerCoin,
AmountDelta: resp.GetCoinAmount(),
BalanceAfter: resp.GetBalanceAfter(),
}, nil
default:
return walletExecutionResult{}, errors.New("财务操作不正确")
}
}
func (s *Service) adminAdjustAsset(ctx context.Context, commandID string, appCode string, targetUserID int64, assetType string, amount int64, operatorUserID int64, reason string, evidenceRef string) (walletExecutionResult, error) {
resp, err := s.wallet.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
CommandId: commandID,
TargetUserId: targetUserID,
AssetType: assetType,
Amount: amount,
OperatorUserId: operatorUserID,
Reason: reason,
EvidenceRef: evidenceRef,
AppCode: appCode,
})
if err != nil {
return walletExecutionResult{}, err
}
balanceAfter := int64(0)
if balance := resp.GetBalance(); balance != nil {
balanceAfter = balance.GetAvailableAmount()
}
return walletExecutionResult{
CommandID: commandID,
TransactionID: resp.GetTransactionId(),
AssetType: assetType,
AmountDelta: amount,
BalanceAfter: balanceAfter,
}, nil
}
func (s *Service) resolveTargetUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
keyword := strings.TrimSpace(rawTarget)
if keyword == "" {
return nil, errors.New("目标用户ID不正确")
}
numericID, numericOK := parsePositiveInt64(keyword)
userIDChecked := false
if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeInternalUserIDMinDigits {
userIDChecked = true
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
return user, err
}
}
if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
RequestID: requestID,
Caller: "admin-server",
DisplayUserID: keyword,
}); err != nil {
if !isGRPCNotFound(err) {
return nil, err
}
} else if identity != nil && identity.UserID > 0 {
user, found, err := s.getUserByID(ctx, requestID, identity.UserID)
if err != nil {
return nil, err
}
if found {
return user, nil
}
}
if numericOK && !userIDChecked {
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
return user, err
}
}
return nil, errors.New("目标用户不存在")
}
func (s *Service) getUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) {
user, err := s.user.GetUser(ctx, userclient.GetUserRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: userID,
})
if err != nil {
if isGRPCNotFound(err) {
return nil, false, nil
}
return nil, false, err
}
if user == nil || user.UserID <= 0 {
return nil, false, nil
}
return user, true, nil
}
func (s *Service) requireWalletIdentity(ctx context.Context, requestID string, userID int64, walletIdentity string) error {
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: userID,
})
if err != nil {
return err
}
switch strings.TrimSpace(walletIdentity) {
case "host":
if summary != nil && summary.IsHost {
return nil
}
case "agency":
if summary != nil && summary.IsAgency {
return nil
}
case "bd":
if summary != nil && summary.IsBD {
return nil
}
}
return errors.New("目标用户没有对应钱包身份")
}
func (s *Service) requireActiveCoinSeller(ctx context.Context, requestID string, user *userclient.User) error {
if user == nil || user.UserID <= 0 {
return errors.New("目标用户不存在")
}
if user.CountryID <= 0 || user.RegionID <= 0 {
return errors.New("币商国家或区域不完整")
}
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: user.UserID,
})
if err != nil {
return err
}
if summary == nil || !summary.IsCoinSeller {
return errors.New("目标用户不是启用中的币商")
}
return nil
}
func (s *Service) normalizeCreateInput(actor shared.Actor, req createApplicationRequest) (model.FinanceApplication, error) {
appCode := strings.TrimSpace(req.AppCode)
operation := strings.TrimSpace(req.Operation)
targetUserID := strings.TrimSpace(req.TargetUserID)
credentialImageURL := strings.TrimSpace(req.CredentialImageURL)
credentialText := strings.TrimSpace(req.CredentialText)
definition, ok := financeOperationDefinitions()[operation]
if !ok || appCode == "" || targetUserID == "" || req.CoinAmount <= 0 || actor.UserID == 0 {
return model.FinanceApplication{}, errInvalidCreateInput
}
if !hasPermission(actor.Permissions, permissionCreateApplication) || !hasPermission(actor.Permissions, definition.PermissionCode) {
return model.FinanceApplication{}, errors.New("没有操作权限")
}
rechargeAmount, err := normalizeRechargeAmount(req.RechargeAmount, definition.AllowsZeroRechargeAmount)
if err != nil {
return model.FinanceApplication{}, err
}
walletIdentity := strings.TrimSpace(req.WalletIdentity)
if definition.RequiresWalletIdentity {
if !validWalletIdentity(walletIdentity) {
return model.FinanceApplication{}, errors.New("钱包身份不正确")
}
} else {
// 非钱包类申请不保存身份字段,避免后续审核或执行阶段把历史残留身份误当成业务参数。
walletIdentity = ""
}
if credentialImageURL == "" && credentialText == "" {
return model.FinanceApplication{}, errors.New("请上传凭证图片或填写凭证文字")
}
return model.FinanceApplication{
AppCode: appCode,
Operation: operation,
WalletIdentity: walletIdentity,
TargetUserID: targetUserID,
CoinAmount: req.CoinAmount,
RechargeAmount: rechargeAmount,
CredentialImageURL: credentialImageURL,
CredentialText: credentialText,
ApplicantUserID: actor.UserID,
ApplicantName: actor.Username,
Status: model.FinanceApplicationStatusPending,
}, nil
}
func financeOperationDefinitions() map[string]operationDefinition {
return map[string]operationDefinition{
"user_coin_credit": {
Value: "user_coin_credit",
PermissionCode: "finance-operation:user-coin-credit",
AllowsZeroRechargeAmount: true,
},
"user_coin_debit": {
Value: "user_coin_debit",
PermissionCode: "finance-operation:user-coin-debit",
AllowsZeroRechargeAmount: true,
},
"user_wallet_debit": {
Value: "user_wallet_debit",
PermissionCode: "finance-operation:user-wallet-debit",
RequiresWalletIdentity: true,
},
"user_wallet_credit": {
Value: "user_wallet_credit",
PermissionCode: "finance-operation:user-wallet-credit",
RequiresWalletIdentity: true,
},
"coin_seller_coin_credit": {
Value: "coin_seller_coin_credit",
PermissionCode: "finance-operation:coin-seller-coin-credit",
},
"coin_seller_coin_debit": {
Value: "coin_seller_coin_debit",
PermissionCode: "finance-operation:coin-seller-coin-debit",
},
}
}
func normalizeRechargeAmount(amount float64, allowZero bool) (string, error) {
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
return "", errors.New("充值金额不正确")
}
cents := int64(math.Round(amount * 100))
// 普通用户金币调账的执行金额来自 coin_amountrecharge_amount 只是凭证口径,允许记录 0身份钱包和币商进货会把它转换成真实 USD/USDT 金额,必须保持正数。
if cents < 0 || (!allowZero && cents == 0) {
return "", errors.New("充值金额不正确")
}
return fmt.Sprintf("%d.%02d", cents/100, cents%100), nil
}
func validWalletIdentity(value string) bool {
switch value {
case "host", "agency", "bd":
return true
default:
return false
}
}
func financeSalaryAssetType(identity string) (string, error) {
switch strings.TrimSpace(identity) {
case "host":
return financeAssetHostSalaryUSD, nil
case "agency":
return financeAssetAgencySalaryUSD, nil
case "bd":
return financeAssetBDSalaryUSD, nil
default:
return "", errors.New("钱包身份不正确")
}
}
func financeStockType(operation string) string {
if operation == "coin_seller_coin_debit" {
return financeStockTypeUSDTDeduction
}
return financeStockTypeUSDTPurchase
}
func signedAmount(amount int64, debit bool) int64 {
if debit {
return -amount
}
return amount
}
func decimalAmountMinor(value string) (int64, error) {
return decimalAmountWithScale(value, 2)
}
func decimalAmountMicro(value string) (int64, error) {
return decimalAmountWithScale(value, 6)
}
func decimalAmountWithScale(value string, scale int) (int64, error) {
text := strings.TrimSpace(value)
if text == "" {
return 0, errors.New("充值金额不正确")
}
parts := strings.Split(text, ".")
if len(parts) > 2 {
return 0, errors.New("充值金额不正确")
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || whole < 0 {
return 0, errors.New("充值金额不正确")
}
fracText := ""
if len(parts) == 2 {
fracText = parts[1]
}
if len(fracText) > scale {
return 0, errors.New("充值金额不正确")
}
for _, r := range fracText {
if r < '0' || r > '9' {
return 0, errors.New("充值金额不正确")
}
}
for len(fracText) < scale {
fracText += "0"
}
frac := int64(0)
if fracText != "" {
frac, err = strconv.ParseInt(fracText, 10, 64)
if err != nil {
return 0, errors.New("充值金额不正确")
}
}
multiplier := int64(1)
for i := 0; i < scale; i++ {
multiplier *= 10
}
const maxInt64 = 1<<63 - 1
if whole > (maxInt64-frac)/multiplier {
return 0, errors.New("充值金额不正确")
}
amount := whole*multiplier + frac
if amount <= 0 {
return 0, errors.New("充值金额不正确")
}
return amount, nil
}
func parsePositiveInt64(value string) (int64, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil && parsed > 0
}
func financeWalletCommandID(id uint, operation string) string {
return fmt.Sprintf("finance-application:%d:%s", id, strings.TrimSpace(operation))
}
func financeWalletEvidenceRef(id uint) string {
return fmt.Sprintf("finance-application:%d", id)
}
func financeWalletReason(application model.FinanceApplication) string {
return fmt.Sprintf("finance application %d %s", application.ID, operationLabel(application.Operation))
}
func isGRPCNotFound(err error) bool {
return status.Code(err) == codes.NotFound
}
func hasPermission(permissions []string, code string) bool {
for _, permission := range permissions {
if permission == code {
return true
}
}
return false
}

View File

@ -0,0 +1,269 @@
package financeapplication
import (
"context"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
walletv1 "hyapp.local/api/proto/wallet/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestNormalizeCreateInputRequiresOperationPermission(t *testing.T) {
service := NewService(nil)
_, err := service.normalizeCreateInput(shared.Actor{
UserID: 7,
Username: "ops",
Permissions: []string{permissionCreateApplication},
}, validCreateRequest())
if err == nil || err.Error() != "没有操作权限" {
t.Fatalf("operation permission must be required, got err=%v", err)
}
}
func TestNormalizeCreateInputRequiresWalletIdentityForWalletOperation(t *testing.T) {
service := NewService(nil)
req := validCreateRequest()
req.Operation = "user_wallet_debit"
_, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req)
if err == nil || err.Error() != "钱包身份不正确" {
t.Fatalf("wallet operation must require wallet identity, got err=%v", err)
}
req.WalletIdentity = "agency"
item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req)
if err != nil {
t.Fatalf("wallet identity should be accepted: %v", err)
}
if item.WalletIdentity != "agency" {
t.Fatalf("wallet identity mismatch: %q", item.WalletIdentity)
}
}
func TestNormalizeCreateInputClearsWalletIdentityForCoinOperation(t *testing.T) {
service := NewService(nil)
req := validCreateRequest()
req.WalletIdentity = "host"
item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:coin-seller-coin-credit"), req)
if err != nil {
t.Fatalf("coin operation should be accepted: %v", err)
}
if item.WalletIdentity != "" {
t.Fatalf("non-wallet operation must clear wallet identity, got %q", item.WalletIdentity)
}
if item.RechargeAmount != "10.50" {
t.Fatalf("recharge amount mismatch: %q", item.RechargeAmount)
}
}
func TestNormalizeCreateInputAllowsZeroRechargeAmountForUserCoinOperation(t *testing.T) {
service := NewService(nil)
req := validCreateRequest()
req.Operation = "user_coin_credit"
req.RechargeAmount = 0
item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-coin-credit"), req)
if err != nil {
t.Fatalf("user coin operation should allow zero recharge amount: %v", err)
}
if item.RechargeAmount != "0.00" {
t.Fatalf("zero recharge amount must be persisted with decimal scale, got %q", item.RechargeAmount)
}
}
func TestNormalizeCreateInputRejectsZeroRechargeAmountForWalletAndCoinSellerOperations(t *testing.T) {
service := NewService(nil)
tests := []struct {
name string
operation string
permission string
identity string
}{
{name: "wallet", operation: "user_wallet_credit", permission: "finance-operation:user-wallet-credit", identity: "agency"},
{name: "coin seller", operation: "coin_seller_coin_credit", permission: "finance-operation:coin-seller-coin-credit"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := validCreateRequest()
req.Operation = tt.operation
req.WalletIdentity = tt.identity
req.RechargeAmount = 0
_, err := service.normalizeCreateInput(actorWithPermissions(tt.permission), req)
if err == nil || err.Error() != "充值金额不正确" {
t.Fatalf("operation %s must still require positive recharge amount, got err=%v", tt.operation, err)
}
})
}
}
func TestExecuteApprovedApplicationAdjustsUserCoinDebit(t *testing.T) {
wallet := &fakeFinanceWallet{}
service := NewService(nil, WithUserClient(&fakeFinanceUser{
displays: map[string]int64{"163001": 312899006709637120},
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7},
},
}), WithWalletClient(wallet))
application := model.FinanceApplication{
ID: 9,
AppCode: "lalu",
Operation: "user_coin_debit",
TargetUserID: "163001",
CoinAmount: 120,
RechargeAmount: "10.00",
}
execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 7}, application, "req-1")
if err != nil {
t.Fatalf("execute approved application failed: %v", err)
}
if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetCoin || wallet.assetReq.GetAmount() != -120 || wallet.assetReq.GetTargetUserId() != 312899006709637120 {
t.Fatalf("wallet asset request mismatch: %+v", wallet.assetReq)
}
if execution.CommandID != "finance-application:9:user_coin_debit" || execution.AmountDelta != -120 || execution.BalanceAfter != 880 {
t.Fatalf("execution mismatch: %+v", execution)
}
}
func TestExecuteApprovedApplicationUsesWalletIdentityAmountMinor(t *testing.T) {
wallet := &fakeFinanceWallet{}
service := NewService(nil, WithUserClient(&fakeFinanceUser{
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7},
},
summaries: map[int64]*userclient.UserRoleSummary{
312899006709637120: {UserID: 312899006709637120, IsAgency: true},
},
}), WithWalletClient(wallet))
application := model.FinanceApplication{
ID: 10,
AppCode: "lalu",
Operation: "user_wallet_credit",
WalletIdentity: "agency",
TargetUserID: "312899006709637120",
CoinAmount: 999,
RechargeAmount: "12.34",
}
_, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 8}, application, "req-2")
if err != nil {
t.Fatalf("execute wallet application failed: %v", err)
}
if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetAgencySalaryUSD || wallet.assetReq.GetAmount() != 1234 {
t.Fatalf("identity wallet request must use salary minor amount, got %+v", wallet.assetReq)
}
}
func TestExecuteApprovedApplicationAdjustsCoinSellerStockDeduction(t *testing.T) {
wallet := &fakeFinanceWallet{}
service := NewService(nil, WithUserClient(&fakeFinanceUser{
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7},
},
summaries: map[int64]*userclient.UserRoleSummary{
312899006709637120: {UserID: 312899006709637120, IsCoinSeller: true, CoinSellerStatus: "active"},
},
}), WithWalletClient(wallet))
application := model.FinanceApplication{
ID: 11,
AppCode: "lalu",
Operation: "coin_seller_coin_debit",
TargetUserID: "312899006709637120",
CoinAmount: 3000000,
RechargeAmount: "37.50",
}
execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 9}, application, "req-3")
if err != nil {
t.Fatalf("execute coin seller application failed: %v", err)
}
if wallet.stockReq == nil || wallet.stockReq.GetStockType() != financeStockTypeUSDTDeduction || wallet.stockReq.GetCoinAmount() != 3000000 || wallet.stockReq.GetPaidAmountMicro() != 37500000 {
t.Fatalf("coin seller stock request mismatch: %+v", wallet.stockReq)
}
if wallet.stockReq.GetSellerCountryId() != 63 || wallet.stockReq.GetSellerRegionId() != 7 {
t.Fatalf("coin seller region snapshot mismatch: %+v", wallet.stockReq)
}
if execution.AssetType != financeAssetCoinSellerCoin || execution.AmountDelta != -3000000 || execution.BalanceAfter != 7000000 {
t.Fatalf("coin seller execution mismatch: %+v", execution)
}
}
func validCreateRequest() createApplicationRequest {
return createApplicationRequest{
AppCode: "lalu",
Operation: "coin_seller_coin_credit",
TargetUserID: "10001",
CoinAmount: 100,
RechargeAmount: 10.5,
CredentialImageURL: "https://example.com/receipt.png",
}
}
type fakeFinanceUser struct {
userclient.Client
users map[int64]*userclient.User
displays map[string]int64
summaries map[int64]*userclient.UserRoleSummary
}
func (f *fakeFinanceUser) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) {
if user := f.users[req.UserID]; user != nil {
return user, nil
}
return nil, status.Error(codes.NotFound, "user not found")
}
func (f *fakeFinanceUser) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) {
if userID := f.displays[req.DisplayUserID]; userID > 0 {
return &userclient.UserIdentity{UserID: userID, DisplayUserID: req.DisplayUserID}, nil
}
return nil, status.Error(codes.NotFound, "display user id not found")
}
func (f *fakeFinanceUser) GetUserRoleSummary(_ context.Context, req userclient.GetUserRoleSummaryRequest) (*userclient.UserRoleSummary, error) {
if summary := f.summaries[req.UserID]; summary != nil {
return summary, nil
}
return &userclient.UserRoleSummary{UserID: req.UserID}, nil
}
type fakeFinanceWallet struct {
walletclient.Client
assetReq *walletv1.AdminCreditAssetRequest
stockReq *walletv1.AdminCreditCoinSellerStockRequest
}
func (f *fakeFinanceWallet) AdminCreditAsset(_ context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
f.assetReq = req
return &walletv1.AdminCreditAssetResponse{
TransactionId: "tx-" + req.GetCommandId(),
Balance: &walletv1.AssetBalance{AvailableAmount: 880},
}, nil
}
func (f *fakeFinanceWallet) AdminCreditCoinSellerStock(_ context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
f.stockReq = req
coinAmount := req.GetCoinAmount()
if req.GetStockType() == financeStockTypeUSDTDeduction {
coinAmount = -coinAmount
}
return &walletv1.AdminCreditCoinSellerStockResponse{
TransactionId: "tx-" + req.GetCommandId(),
CoinAmount: coinAmount,
BalanceAfter: 7000000,
}, nil
}
func actorWithPermissions(operationPermission string) shared.Actor {
return shared.Actor{
UserID: 7,
Username: "ops",
Permissions: []string{permissionCreateApplication, operationPermission},
}
}

View File

@ -0,0 +1,117 @@
package financewithdrawal
import (
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(store, wallet, activity), audit: audit}
}
func (h *Handler) ListApplications(c *gin.Context) {
options := shared.ListOptions(c)
items, total, err := h.service.ListApplications(repository.WithdrawalApplicationListOptions{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"),
Keyword: options.Keyword,
})
if err != nil {
response.ServerError(c, "获取用户提现申请列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) ApproveApplication(c *gin.Context) {
h.auditApplication(c, "approved")
}
func (h *Handler) RejectApplication(c *gin.Context) {
h.auditApplication(c, "rejected")
}
func (h *Handler) auditApplication(c *gin.Context, decision string) {
id, ok := shared.ParseID(c, "application_id")
if !ok {
return
}
var req struct {
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
AuditImageURLSnake string `json:"audit_image_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "审核参数不正确")
return
}
var (
item *withdrawalApplicationDTO
err error
)
if decision == "approved" {
item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, firstNonEmpty(req.AuditImageURL, req.AuditImageURLSnake), middleware.CurrentRequestID(c))
} else {
item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c))
}
if err != nil {
writeWithdrawalServiceError(c, err)
return
}
shared.OperationLogWithResourceID(c, h.audit, "audit-user-withdrawal-application", "admin_user_withdrawal_applications", strconv.FormatUint(uint64(item.ID), 10), "success", decision)
response.OK(c, item)
}
func writeWithdrawalServiceError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "用户提现申请不存在")
return
}
if errors.Is(err, repository.ErrWithdrawalApplicationAlreadyAudited) {
response.BadRequest(c, "提现申请已审核")
return
}
switch strings.TrimSpace(err.Error()) {
case "没有操作权限":
response.Forbidden(c, err.Error())
case "admin finance withdrawal service is not configured":
response.ServerError(c, "审核用户提现申请失败")
default:
response.BadRequest(c, err.Error())
}
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@ -0,0 +1,57 @@
package financewithdrawal
import "hyapp-admin-server/internal/model"
type withdrawalApplicationDTO struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
UserID string `json:"userId"`
SalaryAssetType string `json:"salaryAssetType"`
WithdrawAmount string `json:"withdrawAmount"`
WithdrawAmountMinor int64 `json:"withdrawAmountMinor"`
WithdrawMethod string `json:"withdrawMethod"`
WithdrawAddress string `json:"withdrawAddress"`
FreezeTransactionID string `json:"freezeTransactionId"`
AuditTransactionID string `json:"auditTransactionId"`
Status string `json:"status"`
ApproverUserID *uint `json:"approverUserId"`
ApproverName string `json:"approverName"`
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
ApprovedAtMS *int64 `json:"approvedAtMs"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) withdrawalApplicationDTO {
return withdrawalApplicationDTO{
ID: item.ID,
AppCode: item.AppCode,
AppName: item.AppCode,
UserID: item.UserID,
SalaryAssetType: item.SalaryAssetType,
WithdrawAmount: item.WithdrawAmount,
WithdrawAmountMinor: item.WithdrawAmountMinor,
WithdrawMethod: item.WithdrawMethod,
WithdrawAddress: item.WithdrawAddress,
FreezeTransactionID: item.FreezeTransactionID,
AuditTransactionID: item.AuditTransactionID,
Status: item.Status,
ApproverUserID: item.ApproverUserID,
ApproverName: item.ApproverName,
AuditRemark: item.AuditRemark,
AuditImageURL: item.AuditImageURL,
ApprovedAtMS: item.ApprovedAtMS,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
}
func withdrawalApplicationDTOsFromModel(items []model.UserWithdrawalApplication) []withdrawalApplicationDTO {
out := make([]withdrawalApplicationDTO, 0, len(items))
for _, item := range items {
out = append(out, withdrawalApplicationDTOFromModel(item))
}
return out
}

View File

@ -0,0 +1,16 @@
package financewithdrawal
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ListApplications)
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ApproveApplication)
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication)
}

View File

@ -0,0 +1,250 @@
package financewithdrawal
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
permissionViewWithdrawalApplications = "finance-withdrawal:view"
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
permissionAuditFinanceApplications = "finance-application:audit"
)
type Service struct {
store *repository.Store
wallet walletclient.Client
activity activityclient.Client
now func() time.Time
}
func NewService(store *repository.Store, wallet walletclient.Client, activity activityclient.Client) *Service {
return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) ListApplications(options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
items, total, err := s.store.ListWithdrawalApplications(options)
if err != nil {
return nil, 0, err
}
return withdrawalApplicationDTOsFromModel(items), total, nil
}
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID)
}
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
}
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
if s == nil || s.store == nil || s.wallet == nil || s.activity == nil {
return nil, errors.New("admin finance withdrawal service is not configured")
}
if id == 0 || actor.UserID == 0 || !canAuditWithdrawal(actor.Permissions) {
return nil, errors.New("没有操作权限")
}
remark = strings.TrimSpace(remark)
auditImageURL = strings.TrimSpace(auditImageURL)
if decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
return nil, errors.New("提现通过凭证图片不能为空")
}
if decision == model.WithdrawalApplicationStatusRejected && remark == "" {
return nil, errors.New("拒绝原因不能为空")
}
item, err := s.store.GetWithdrawalApplication(id)
if err != nil {
return nil, err
}
if item.Status != model.WithdrawalApplicationStatusPending {
return nil, repository.ErrWithdrawalApplicationAlreadyAudited
}
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
if err != nil || userID <= 0 || item.SalaryAssetType == "" || item.WithdrawAmountMinor <= 0 || item.FreezeTransactionID == "" {
return nil, errors.New("提现单冻结信息不完整")
}
commandID := withdrawalAuditCommandID(id, decision)
amountMinor := item.WithdrawAmountMinor
appCode := appctx.FromContext(ctx)
if strings.TrimSpace(item.AppCode) != "" {
appCode = item.AppCode
}
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, actor, strings.TrimSpace(remark), id)
if err != nil {
return nil, err
}
nowMS := s.now().UnixMilli()
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
}); err != nil {
// 钱包同意/释放已经用 command id 做幂等;通知失败时不写后台终态,财务重试会复用同一钱包命令和消息事件,避免重复扣款、返还或重复通知。
return nil, err
}
updated, err := s.store.AuditWithdrawalApplication(id, repository.WithdrawalApplicationAuditInput{
Decision: decision,
ApproverUserID: actor.UserID,
ApproverName: actor.Username,
AuditRemark: remark,
AuditImageURL: auditImageURL,
AuditCommandID: commandID,
AuditTransactionID: transactionID,
ApprovedAtMS: nowMS,
})
if err != nil {
return nil, err
}
dto := withdrawalApplicationDTOFromModel(*updated)
return &dto, nil
}
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, actor shared.Actor, remark string, applicationID uint) (string, error) {
reason := strings.TrimSpace(remark)
if reason == "" {
reason = "salary withdrawal " + decision
}
applicationIDText := strconv.FormatUint(uint64(applicationID), 10)
switch decision {
case model.WithdrawalApplicationStatusApproved:
resp, err := s.wallet.SettleSalaryWithdrawal(ctx, &walletv1.SettleSalaryWithdrawalRequest{
CommandId: commandID,
UserId: userID,
SalaryAssetType: assetType,
SalaryUsdMinor: amountMinor,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
WithdrawalApplicationId: applicationIDText,
})
if err != nil {
return "", err
}
return resp.GetTransactionId(), nil
case model.WithdrawalApplicationStatusRejected:
resp, err := s.wallet.ReleaseSalaryWithdrawal(ctx, &walletv1.ReleaseSalaryWithdrawalRequest{
CommandId: commandID,
UserId: userID,
SalaryAssetType: assetType,
SalaryUsdMinor: amountMinor,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
WithdrawalApplicationId: applicationIDText,
})
if err != nil {
return "", err
}
return resp.GetTransactionId(), nil
default:
return "", errors.New("审核结果不正确")
}
}
func withdrawalAuditCommandID(id uint, decision string) string {
return fmt.Sprintf("salary-withdrawal:%d:%s", id, decision)
}
type auditNoticeInput struct {
ApplicationID uint
AppCode string
UserID int64
Decision string
Remark string
AuditImageURL string
AuditTransactionID string
WithdrawAmount string
WithdrawMethod string
RequestID string
SentAtMS int64
}
func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoticeInput) error {
body := "Your withdrawal request has been approved."
title := "Withdrawal request approved"
imageURL := ""
eventType := "finance_withdrawal_approved"
if input.Decision == model.WithdrawalApplicationStatusRejected {
body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark)
title = "Withdrawal request rejected"
eventType = "finance_withdrawal_rejected"
} else {
imageURL = strings.TrimSpace(input.AuditImageURL)
}
metadata, err := json.Marshal(map[string]any{
"application_id": input.ApplicationID,
"app_code": strings.TrimSpace(input.AppCode),
"audit_transaction_id": strings.TrimSpace(input.AuditTransactionID),
"decision": strings.TrimSpace(input.Decision),
"withdraw_amount": strings.TrimSpace(input.WithdrawAmount),
"withdraw_method": strings.TrimSpace(input.WithdrawMethod),
})
if err != nil {
return err
}
_, err = s.activity.CreateInboxMessage(ctx, &activityv1.CreateInboxMessageRequest{
Meta: &activityv1.RequestMeta{
RequestId: strings.TrimSpace(input.RequestID),
Caller: "admin-server",
AppCode: strings.TrimSpace(input.AppCode),
SentAtMs: input.SentAtMS,
},
TargetUserId: input.UserID,
Producer: "admin-server",
ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Decision),
ProducerEventType: eventType,
MessageType: "system",
AggregateType: "user_withdrawal_application",
AggregateId: strconv.FormatUint(uint64(input.ApplicationID), 10),
Title: title,
Summary: body,
Body: body,
ImageUrl: imageURL,
SentAtMs: input.SentAtMS,
MetadataJson: string(metadata),
})
if err != nil {
return fmt.Errorf("发送提现审核通知失败: %w", err)
}
return nil
}
func withdrawalAuditNotificationEventID(id uint, decision string) string {
return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision))
}
func canAuditWithdrawal(permissions []string) bool {
for _, permission := range permissions {
switch strings.TrimSpace(permission) {
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications:
return true
}
}
return false
}

View File

@ -396,6 +396,27 @@ func (h *Handler) CreateAgency(c *gin.Context) {
response.Created(c, result)
}
func (h *Handler) AdminAddAgencyHost(c *gin.Context) {
agencyID, ok := parseInt64ID(c, "agency_id")
if !ok {
return
}
var req agencyHostAddRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "添加 Host 参数不正确")
return
}
result, err := h.service.AdminAddAgencyHost(c.Request.Context(), adminActorID(c), agencyID, middleware.CurrentRequestID(c), req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "add-agency-host", "agency_memberships", result.Membership.MembershipID,
fmt.Sprintf("command_id=%s agency_id=%d host_user_id=%d reason=%q",
strings.TrimSpace(req.CommandID), agencyID, result.Membership.HostUserID, strings.TrimSpace(req.Reason)))
response.Created(c, result)
}
func (h *Handler) CloseAgency(c *gin.Context) {
agencyID, ok := parseInt64ID(c, "agency_id")
if !ok {

View File

@ -218,6 +218,12 @@ type createAgencyRequest struct {
Reason string `json:"reason"`
}
type agencyHostAddRequest struct {
CommandID string `json:"commandId" binding:"required"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
Reason string `json:"reason"`
}
type agencyJoinEnabledRequest struct {
CommandID string `json:"commandId" binding:"required"`
JoinEnabled *bool `json:"joinEnabled" binding:"required"`

View File

@ -20,6 +20,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PUT("/admin/managers/:user_id", middleware.RequirePermission("bd:update"), h.UpdateManager)
protected.GET("/admin/agencies", middleware.RequirePermission("agency:view"), h.ListAgencies)
protected.POST("/admin/agencies", middleware.RequirePermission("agency:create"), h.CreateAgency)
protected.POST("/admin/agencies/:agency_id/hosts", middleware.RequirePermission("agency:create"), h.AdminAddAgencyHost)
protected.POST("/admin/agencies/:agency_id/close", middleware.RequirePermission("agency:status"), h.CloseAgency)
protected.PATCH("/admin/agencies/:agency_id/status", middleware.RequirePermission("agency:status"), h.SetAgencyStatus)
protected.POST("/admin/agencies/:agency_id/delete", middleware.RequirePermission("agency:delete"), h.DeleteAgency)

View File

@ -447,6 +447,23 @@ func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID str
})
}
func (s *Service) AdminAddAgencyHost(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyHostAddRequest) (*userclient.CreateAgencyResult, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil {
return nil, err
}
// admin-server 只负责短号解析和审计Agency 状态、区域、唯一有效归属等关系规则必须在 user-service 事务内判断。
return s.userClient.AdminAddAgencyHost(ctx, userclient.AdminAddAgencyHostRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID,
AgencyID: agencyID,
TargetUserID: targetUserID,
Reason: strings.TrimSpace(req.Reason),
})
}
func (s *Service) CloseAgency(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyCloseRequest) (*userclient.Agency, error) {
return s.userClient.CloseAgency(ctx, userclient.CloseAgencyRequest{
RequestID: requestID,

View File

@ -0,0 +1,34 @@
package hostwithdrawal
type userDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type itemDTO struct {
ID string `json:"id"`
RecordType string `json:"recordType"`
Identity string `json:"identity"`
SalaryAssetType string `json:"salaryAssetType"`
SourceUserID string `json:"sourceUserId"`
SourceUser userDTO `json:"sourceUser"`
SellerUserID string `json:"sellerUserId"`
SellerUser userDTO `json:"sellerUser"`
USDMinorAmount int64 `json:"usdMinorAmount"`
CoinAmount int64 `json:"coinAmount"`
Status string `json:"status"`
TransactionID string `json:"transactionId"`
CommandID string `json:"commandId"`
ApplicationID string `json:"applicationId"`
FreezeTransactionID string `json:"freezeTransactionId"`
AuditTransactionID string `json:"auditTransactionId"`
WithdrawMethod string `json:"withdrawMethod"`
WithdrawAddress string `json:"withdrawAddress"`
AuditRemark string `json:"auditRemark"`
AuditImageURL string `json:"auditImageUrl"`
Metadata map[string]any `json:"metadata"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}

View File

@ -0,0 +1,86 @@
package hostwithdrawal
import (
"database/sql"
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Handler {
return &Handler{service: NewService(adminDB, walletDB, userDB)}
}
func (h *Handler) List(c *gin.Context) {
query, ok := parseListQuery(c, appctx.FromContext(c.Request.Context()))
if !ok {
return
}
items, total, err := h.service.List(c.Request.Context(), query)
if err != nil {
if errors.Is(err, errInvalidArgument) {
response.BadRequest(c, "主播提现筛选参数不正确")
return
}
response.ServerError(c, "获取主播提现列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func parseListQuery(c *gin.Context, defaultAppCode string) (listQuery, bool) {
options := shared.ListOptions(c)
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return listQuery{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return listQuery{}, false
}
query, err := normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstNonEmpty(c.Query("app_code"), c.Query("appCode"), defaultAppCode),
RecordType: firstNonEmpty(c.Query("record_type"), c.Query("recordType")),
Identity: firstNonEmpty(c.Query("identity"), c.Query("salary_asset_type"), c.Query("salaryAssetType")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMS: startAtMS,
EndAtMS: endAtMS,
})
if err != nil {
response.BadRequest(c, "主播提现筛选参数不正确")
return listQuery{}, false
}
return query, true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstNonEmpty(queryValues(c, keys...)...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
return parsed, err == nil && parsed >= 0
}
func queryValues(c *gin.Context, keys ...string) []string {
values := make([]string, 0, len(keys))
for _, key := range keys {
values = append(values, c.Query(key))
}
return values
}

View File

@ -0,0 +1,119 @@
package hostwithdrawal
import (
"errors"
"strings"
)
const (
recordTypeSalaryTransfer = "salary_transfer"
recordTypeWithdrawal = "withdrawal"
statusCompleted = "completed"
statusPending = "pending"
statusApproved = "approved"
statusRejected = "rejected"
assetCoinSellerCoin = "COIN_SELLER_COIN"
bizSalaryTransfer = "salary_transfer_to_coin_seller"
transactionSucceeded = "succeeded"
assetHostSalary = "HOST_SALARY_USD"
assetAgencySalary = "AGENCY_SALARY_USD"
assetBDSalary = "BD_SALARY_USD"
assetAdminSalary = "ADMIN_SALARY_USD"
)
var errInvalidArgument = errors.New("invalid argument")
type listQuery struct {
Page int
PageSize int
AppCode string
RecordType string
Identity string
SalaryAssetType string
Status string
Keyword string
StartAtMS int64
EndAtMS int64
}
func normalizeListQuery(query listQuery) (listQuery, error) {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
query.RecordType = strings.TrimSpace(query.RecordType)
query.Identity = strings.TrimSpace(query.Identity)
query.Status = strings.TrimSpace(query.Status)
query.Keyword = strings.TrimSpace(query.Keyword)
if query.StartAtMS > 0 && query.EndAtMS > 0 && query.StartAtMS >= query.EndAtMS {
return listQuery{}, errInvalidArgument
}
switch strings.ToLower(query.RecordType) {
case "", "all":
query.RecordType = ""
case recordTypeSalaryTransfer:
query.RecordType = recordTypeSalaryTransfer
case recordTypeWithdrawal:
query.RecordType = recordTypeWithdrawal
default:
return listQuery{}, errInvalidArgument
}
salaryAssetType, identity, err := normalizeIdentity(query.Identity)
if err != nil {
return listQuery{}, err
}
query.SalaryAssetType = salaryAssetType
query.Identity = identity
switch strings.ToLower(query.Status) {
case "", "all":
query.Status = ""
case statusCompleted:
query.Status = statusCompleted
case statusPending:
query.Status = statusPending
case statusApproved:
query.Status = statusApproved
case statusRejected:
query.Status = statusRejected
default:
return listQuery{}, errInvalidArgument
}
return query, nil
}
func normalizeIdentity(value string) (string, string, error) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "ALL":
return "", "", nil
case "HOST", assetHostSalary:
return assetHostSalary, "host", nil
case "AGENCY", assetAgencySalary:
return assetAgencySalary, "agency", nil
case "BD", assetBDSalary:
return assetBDSalary, "bd", nil
case "ADMIN", "BD_LEADER", "BD_LEADER_ADMIN", assetAdminSalary:
return assetAdminSalary, "bd_leader_admin", nil
default:
return "", "", errInvalidArgument
}
}
func identityFromAsset(assetType string) string {
_, identity, err := normalizeIdentity(assetType)
if err != nil {
return ""
}
return identity
}

View File

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

View File

@ -0,0 +1,603 @@
package hostwithdrawal
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/modules/shared"
)
type Service struct {
adminDB *sql.DB
walletDB *sql.DB
userDB *sql.DB
}
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
}
func (s *Service) List(ctx context.Context, query listQuery) ([]itemDTO, int64, error) {
var err error
query, err = normalizeListQuery(query)
if err != nil {
return nil, 0, err
}
if s == nil || s.adminDB == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("host withdrawal storage is not configured")
}
keywordUserIDs, err := s.resolveKeywordUserIDs(ctx, query.AppCode, query.Keyword)
if err != nil {
return nil, 0, err
}
fetchLimit := offset(query.Page, query.PageSize) + query.PageSize
items := make([]itemDTO, 0, fetchLimit)
total := int64(0)
if query.RecordType == "" || query.RecordType == recordTypeSalaryTransfer {
// 工资转币商只展示已完成账本事实pending/approved/rejected 属于提现申请状态,不能误筛到钱包转账。
if query.Status == "" || query.Status == statusCompleted {
count, err := s.countSalaryTransfers(ctx, query, keywordUserIDs)
if err != nil {
return nil, 0, err
}
total += count
records, err := s.listSalaryTransfers(ctx, query, keywordUserIDs, fetchLimit)
if err != nil {
return nil, 0, err
}
items = append(items, records...)
}
}
if query.RecordType == "" || query.RecordType == recordTypeWithdrawal {
// 提现申请状态来自后台申请表completed 只代表钱包转币商完成态,所以提现分支直接跳过。
if query.Status != statusCompleted {
count, err := s.countWithdrawalApplications(ctx, query, keywordUserIDs)
if err != nil {
return nil, 0, err
}
total += count
records, err := s.listWithdrawalApplications(ctx, query, keywordUserIDs, fetchLimit)
if err != nil {
return nil, 0, err
}
items = append(items, records...)
}
}
enriched, err := s.enrichUsers(ctx, query.AppCode, items)
if err != nil {
return nil, 0, err
}
return pageMergedItems(enriched, query.Page, query.PageSize), total, nil
}
func (s *Service) countSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
var total int64
err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total)
return total, err
}
func (s *Service) listSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
if limit <= 0 {
return nil, nil
}
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
rows, err := s.walletDB.QueryContext(ctx, `
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, e.counterparty_user_id,
e.available_delta, e.available_after, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL+`
ORDER BY e.created_at_ms DESC, e.entry_id DESC
LIMIT ?`,
append(args, limit)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]itemDTO, 0, limit)
for rows.Next() {
var (
entryID int64
transactionID string
commandID string
sellerUserID int64
sourceUserID int64
availableDelta int64
sellerBalanceAfter int64
metadataJSON string
createdAtMS int64
)
if err := rows.Scan(&entryID, &transactionID, &commandID, &sellerUserID, &sourceUserID, &availableDelta, &sellerBalanceAfter, &metadataJSON, &createdAtMS); err != nil {
return nil, err
}
metadata, err := parseMetadataJSON(metadataJSON)
if err != nil {
return nil, err
}
if value := metadataInt64(metadata, "source_user_id", "sourceUserId"); value > 0 {
sourceUserID = value
}
if value := metadataInt64(metadata, "seller_user_id", "sellerUserId"); value > 0 {
sellerUserID = value
}
assetType := firstNonEmpty(metadataString(metadata, "salary_asset_type"), query.SalaryAssetType)
coinAmount := metadataInt64(metadata, "coin_amount", "coinAmount")
if coinAmount == 0 {
coinAmount = absInt64(availableDelta)
}
if metadata == nil {
metadata = map[string]any{}
}
metadata["seller_balance_after"] = sellerBalanceAfter
items = append(items, itemDTO{
ID: fmt.Sprintf("%s:%d", recordTypeSalaryTransfer, entryID),
RecordType: recordTypeSalaryTransfer,
Identity: identityFromAsset(assetType),
SalaryAssetType: assetType,
SourceUserID: formatOptionalID(sourceUserID),
SourceUser: userDTO{UserID: formatOptionalID(sourceUserID)},
SellerUserID: formatOptionalID(sellerUserID),
SellerUser: userDTO{UserID: formatOptionalID(sellerUserID)},
USDMinorAmount: metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor"),
CoinAmount: coinAmount,
Status: statusCompleted,
TransactionID: transactionID,
CommandID: commandID,
Metadata: metadata,
CreatedAtMS: createdAtMS,
UpdatedAtMS: createdAtMS,
})
}
return items, rows.Err()
}
func (s *Service) countWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
var total int64
err := s.adminDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM admin_user_withdrawal_applications a
`+whereSQL,
args...,
).Scan(&total)
return total, err
}
func (s *Service) listWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
if limit <= 0 {
return nil, nil
}
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
rows, err := s.adminDB.QueryContext(ctx, `
SELECT id, user_id, salary_asset_type, withdraw_amount_minor, withdraw_method, withdraw_address,
freeze_command_id, freeze_transaction_id, audit_command_id, audit_transaction_id,
status, COALESCE(audit_remark, ''), audit_image_url, created_at_ms, updated_at_ms
FROM admin_user_withdrawal_applications a
`+whereSQL+`
ORDER BY a.created_at_ms DESC, a.id DESC
LIMIT ?`,
append(args, limit)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]itemDTO, 0, limit)
for rows.Next() {
var (
id int64
userIDText string
assetType string
amountMinor int64
withdrawMethod string
withdrawAddress string
freezeCommandID string
freezeTransactionID string
auditCommandID string
auditTransactionID string
status string
auditRemark string
auditImageURL string
createdAtMS int64
updatedAtMS int64
)
if err := rows.Scan(&id, &userIDText, &assetType, &amountMinor, &withdrawMethod, &withdrawAddress, &freezeCommandID, &freezeTransactionID, &auditCommandID, &auditTransactionID, &status, &auditRemark, &auditImageURL, &createdAtMS, &updatedAtMS); err != nil {
return nil, err
}
sourceUserID := parseInt64OrZero(userIDText)
items = append(items, itemDTO{
ID: fmt.Sprintf("%s:%d", recordTypeWithdrawal, id),
RecordType: recordTypeWithdrawal,
Identity: identityFromAsset(assetType),
SalaryAssetType: assetType,
SourceUserID: formatOptionalID(sourceUserID),
SourceUser: userDTO{UserID: firstNonEmpty(formatOptionalID(sourceUserID), userIDText)},
USDMinorAmount: amountMinor,
Status: status,
CommandID: firstNonEmpty(auditCommandID, freezeCommandID),
ApplicationID: strconv.FormatInt(id, 10),
FreezeTransactionID: freezeTransactionID,
AuditTransactionID: auditTransactionID,
WithdrawMethod: withdrawMethod,
WithdrawAddress: withdrawAddress,
AuditRemark: auditRemark,
AuditImageURL: auditImageURL,
Metadata: map[string]any{},
CreatedAtMS: createdAtMS,
UpdatedAtMS: updatedAtMS,
})
}
return items, rows.Err()
}
func salaryTransferWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ?"
args := []any{query.AppCode, assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded}
if query.SalaryAssetType != "" {
// 身份维度是工资资产类型,来源写在交易 metadata 中;只筛工资转币商事实,不触碰普通币商出货或后台进货。
where += " AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ?"
args = append(args, query.SalaryAssetType)
}
if query.StartAtMS > 0 {
where += " AND e.created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND e.created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if keywordSQL, keywordArgs := salaryTransferKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
where += " AND (" + keywordSQL + ")"
args = append(args, keywordArgs...)
}
return where, args
}
func salaryTransferKeywordSQL(keyword string, userIDs []int64) (string, []any) {
clauses := make([]string, 0, 4)
args := make([]any, 0)
if len(userIDs) > 0 {
clauses = append(clauses, "e.user_id IN ("+placeholders(len(userIDs))+")")
for _, id := range userIDs {
args = append(args, id)
}
clauses = append(clauses, "e.counterparty_user_id IN ("+placeholders(len(userIDs))+")")
for _, id := range userIDs {
args = append(args, id)
}
}
if keyword = strings.TrimSpace(keyword); keyword != "" {
like := "%" + keyword + "%"
clauses = append(clauses, "e.transaction_id LIKE ?", "wt.command_id LIKE ?", "CAST(e.user_id AS CHAR) LIKE ?", "CAST(e.counterparty_user_id AS CHAR) LIKE ?")
args = append(args, like, like, like, like)
}
return strings.Join(clauses, " OR "), args
}
func withdrawalWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
where := "WHERE a.app_code = ?"
args := []any{query.AppCode}
if query.SalaryAssetType != "" {
where += " AND a.salary_asset_type = ?"
args = append(args, query.SalaryAssetType)
}
if query.Status != "" {
where += " AND a.status = ?"
args = append(args, query.Status)
}
if query.StartAtMS > 0 {
where += " AND a.created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND a.created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if keywordSQL, keywordArgs := withdrawalKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
where += " AND (" + keywordSQL + ")"
args = append(args, keywordArgs...)
}
return where, args
}
func withdrawalKeywordSQL(keyword string, userIDs []int64) (string, []any) {
clauses := make([]string, 0, 8)
args := make([]any, 0)
if len(userIDs) > 0 {
clauses = append(clauses, "a.user_id IN ("+placeholders(len(userIDs))+")")
for _, id := range userIDs {
args = append(args, strconv.FormatInt(id, 10))
}
}
if keyword = strings.TrimSpace(keyword); keyword != "" {
like := "%" + keyword + "%"
clauses = append(clauses,
"CAST(a.id AS CHAR) LIKE ?",
"a.user_id LIKE ?",
"a.withdraw_method LIKE ?",
"a.withdraw_address LIKE ?",
"a.freeze_transaction_id LIKE ?",
"a.audit_transaction_id LIKE ?",
"a.approver_name LIKE ?",
)
args = append(args, like, like, like, like, like, like, like)
}
return strings.Join(clauses, " OR "), args
}
func (s *Service) resolveKeywordUserIDs(ctx context.Context, appCode string, keyword string) ([]int64, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil, nil
}
userIDs := make([]int64, 0, 4)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
// 账务事实可能早于用户资料补全;数字关键字直接加入候选,避免资料缺失吞掉真实流水。
userIDs = append(userIDs, numeric)
}
if s == nil || s.userDB == nil {
return positiveUniqueInt64s(userIDs), nil
}
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli())
args := append([]any{appCode}, matchArgs...)
rows, err := s.userDB.QueryContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY u.updated_at_ms DESC, u.user_id DESC
LIMIT 1000`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, err
}
userIDs = append(userIDs, userID)
}
return positiveUniqueInt64s(userIDs), rows.Err()
}
func (s *Service) enrichUsers(ctx context.Context, appCode string, items []itemDTO) ([]itemDTO, error) {
userIDs := make([]int64, 0, len(items)*2)
for _, item := range items {
if id := parseInt64OrZero(item.SourceUserID); id > 0 {
userIDs = append(userIDs, id)
}
if id := parseInt64OrZero(item.SellerUserID); id > 0 {
userIDs = append(userIDs, id)
}
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, err
}
out := append([]itemDTO(nil), items...)
for i := range out {
if id := parseInt64OrZero(out[i].SourceUserID); id > 0 {
if profile, ok := profiles[id]; ok {
out[i].SourceUser = userDTOFromProfile(profile)
}
}
if id := parseInt64OrZero(out[i].SellerUserID); id > 0 {
if profile, ok := profiles[id]; ok {
out[i].SellerUser = userDTOFromProfile(profile)
}
}
}
return out, nil
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
result := make(map[int64]userProfile, len(userIDs))
if s == nil || s.userDB == nil || len(userIDs) == 0 {
return result, nil
}
userIDs = positiveUniqueInt64s(userIDs)
if len(userIDs) == 0 {
return result, nil
}
args := make([]any, 0, len(userIDs)+1)
args = append(args, appCode)
for _, id := range userIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (%s)`,
placeholders(len(userIDs)),
), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var profile userProfile
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
func userDTOFromProfile(profile userProfile) userDTO {
return userDTO{
UserID: strconv.FormatInt(profile.UserID, 10),
DisplayUserID: profile.DisplayUserID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
func pageMergedItems(items []itemDTO, page int, pageSize int) []itemDTO {
sort.SliceStable(items, func(i int, j int) bool {
if items[i].CreatedAtMS != items[j].CreatedAtMS {
return items[i].CreatedAtMS > items[j].CreatedAtMS
}
if items[i].RecordType != items[j].RecordType {
return items[i].RecordType < items[j].RecordType
}
return recordNumericID(items[i]) > recordNumericID(items[j])
})
start := offset(page, pageSize)
if start >= len(items) {
return []itemDTO{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func recordNumericID(item itemDTO) int64 {
if item.RecordType == recordTypeWithdrawal {
return parseInt64OrZero(item.ApplicationID)
}
parts := strings.Split(item.ID, ":")
if len(parts) == 2 {
return parseInt64OrZero(parts[1])
}
return 0
}
func parseMetadataJSON(value string) (map[string]any, error) {
value = strings.TrimSpace(value)
if value == "" || value == "null" {
return map[string]any{}, nil
}
var metadata map[string]any
if err := json.Unmarshal([]byte(value), &metadata); err != nil {
return nil, err
}
if metadata == nil {
return map[string]any{}, nil
}
return metadata, nil
}
func metadataString(metadata map[string]any, key string) string {
value, ok := metadata[key]
if !ok || value == nil {
return ""
}
if typed, ok := value.(string); ok {
return strings.TrimSpace(typed)
}
return strings.TrimSpace(fmt.Sprint(value))
}
func metadataInt64(metadata map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := metadata[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case string:
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil {
return parsed
}
}
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func formatOptionalID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func parseInt64OrZero(value string) int64 {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed
}
func absInt64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
func offset(page int, pageSize int) int {
if page < 1 {
page = 1
}
return (page - 1) * pageSize
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func positiveUniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@ -0,0 +1,141 @@
package hostwithdrawal
import (
"os"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestNormalizeListQueryMapsIdentityAndRejectsBadInput(t *testing.T) {
query, err := normalizeListQuery(listQuery{
Page: -1,
PageSize: 1000,
AppCode: " LaLu ",
RecordType: "all",
Identity: "BD_LEADER",
Status: "all",
Keyword: " 168557 ",
StartAtMS: 100,
EndAtMS: 200,
})
if err != nil {
t.Fatalf("normalize query failed: %v", err)
}
if query.Page != 1 || query.PageSize != 100 || query.AppCode != "lalu" || query.RecordType != "" || query.Status != "" || query.Keyword != "168557" {
t.Fatalf("normalized basic fields mismatch: %+v", query)
}
if query.SalaryAssetType != assetAdminSalary || query.Identity != "bd_leader_admin" {
t.Fatalf("identity mapping mismatch: %+v", query)
}
if _, err := normalizeListQuery(listQuery{Status: "done"}); err == nil {
t.Fatal("expected invalid status to fail")
}
if _, err := normalizeListQuery(listQuery{StartAtMS: 200, EndAtMS: 200}); err == nil {
t.Fatal("expected closed time range to fail")
}
}
func TestSalaryTransferWhereUsesIdentityStatusTimeAndKeywordUsers(t *testing.T) {
where, args := salaryTransferWhere(listQuery{
AppCode: "lalu",
SalaryAssetType: assetHostSalary,
Keyword: "cmd",
StartAtMS: 100,
EndAtMS: 200,
}, []int64{11, 22})
wantWhere := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ? AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND (e.user_id IN (?,?) OR e.counterparty_user_id IN (?,?) OR e.transaction_id LIKE ? OR wt.command_id LIKE ? OR CAST(e.user_id AS CHAR) LIKE ? OR CAST(e.counterparty_user_id AS CHAR) LIKE ?)"
if where != wantWhere {
t.Fatalf("where mismatch:\nwant %s\n got %s", wantWhere, where)
}
wantArgs := []any{"lalu", assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded, assetHostSalary, int64(100), int64(200), int64(11), int64(22), int64(11), int64(22), "%cmd%", "%cmd%", "%cmd%", "%cmd%"}
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("args mismatch:\nwant %#v\n got %#v", wantArgs, args)
}
}
func TestWithdrawalWhereUsesStatusIdentityTimeAndKeywordUsers(t *testing.T) {
where, args := withdrawalWhere(listQuery{
AppCode: "lalu",
SalaryAssetType: assetBDSalary,
Status: statusPending,
Keyword: "upi",
StartAtMS: 100,
EndAtMS: 200,
}, []int64{77})
wantWhere := "WHERE a.app_code = ? AND a.salary_asset_type = ? AND a.status = ? AND a.created_at_ms >= ? AND a.created_at_ms < ? AND (a.user_id IN (?) OR CAST(a.id AS CHAR) LIKE ? OR a.user_id LIKE ? OR a.withdraw_method LIKE ? OR a.withdraw_address LIKE ? OR a.freeze_transaction_id LIKE ? OR a.audit_transaction_id LIKE ? OR a.approver_name LIKE ?)"
if where != wantWhere {
t.Fatalf("where mismatch:\nwant %s\n got %s", wantWhere, where)
}
wantArgs := []any{"lalu", assetBDSalary, statusPending, int64(100), int64(200), "77", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%"}
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("args mismatch:\nwant %#v\n got %#v", wantArgs, args)
}
}
func TestPageMergedItemsSortsAndSlicesStableMixedSources(t *testing.T) {
items := []itemDTO{
{ID: "salary_transfer:8", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 100},
{ID: "withdrawal:7", RecordType: recordTypeWithdrawal, ApplicationID: "7", CreatedAtMS: 100},
{ID: "salary_transfer:9", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 100},
{ID: "withdrawal:11", RecordType: recordTypeWithdrawal, ApplicationID: "11", CreatedAtMS: 90},
{ID: "salary_transfer:1", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 80},
}
page := pageMergedItems(items, 1, 4)
gotIDs := []string{page[0].ID, page[1].ID, page[2].ID, page[3].ID}
wantIDs := []string{"salary_transfer:9", "salary_transfer:8", "withdrawal:7", "withdrawal:11"}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("page sort mismatch:\nwant %#v\n got %#v", wantIDs, gotIDs)
}
secondPage := pageMergedItems(items, 2, 2)
gotIDs = []string{secondPage[0].ID, secondPage[1].ID}
wantIDs = []string{"withdrawal:7", "withdrawal:11"}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("second page mismatch:\nwant %#v\n got %#v", wantIDs, gotIDs)
}
}
func TestRegisterRoutesExposesHostWithdrawalsEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
group := engine.Group("/api/v1")
RegisterRoutes(group, &Handler{})
for _, route := range engine.Routes() {
if route.Method == "GET" && route.Path == "/api/v1/admin/host-withdrawals" {
return
}
}
t.Fatal("host withdrawals route was not registered")
}
func TestHostWithdrawalIndexSQLKeepsRequiredReadPaths(t *testing.T) {
adminSQL, err := os.ReadFile("../../../migrations/072_host_withdrawal_navigation.sql")
if err != nil {
t.Fatalf("read admin migration failed: %v", err)
}
walletSQL, err := os.ReadFile("../../../../../scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql")
if err != nil {
t.Fatalf("read wallet migration failed: %v", err)
}
for _, snippet := range []string{
"idx_admin_withdrawal_app_time (app_code, created_at_ms, id)",
"idx_admin_withdrawal_app_asset_time (app_code, salary_asset_type, created_at_ms, id)",
"idx_admin_withdrawal_app_status_asset_time (app_code, status, salary_asset_type, created_at_ms, id)",
"idx_admin_withdrawal_app_user_time (app_code, user_id, created_at_ms, id)",
} {
if !strings.Contains(string(adminSQL), snippet) {
t.Fatalf("admin migration missing index snippet %q", snippet)
}
}
if !strings.Contains(string(walletSQL), "idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)") {
t.Fatal("wallet migration missing counterparty index")
}
}

View File

@ -26,6 +26,7 @@ type rechargeBillDTO struct {
USDMinorAmount int64 `json:"usdMinorAmount"`
ExchangeCoinAmount int64 `json:"exchangeCoinAmount"`
ExchangeUSDMinorAmount int64 `json:"exchangeUsdMinorAmount"`
ProviderAmountMinor int64 `json:"providerAmountMinor,omitempty"`
CreatedAtMS int64 `json:"createdAtMs"`
User rechargeBillUserDTO `json:"user"`
Seller rechargeBillUserDTO `json:"seller"`
@ -165,6 +166,7 @@ func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
USDMinorAmount: item.GetUsdMinorAmount(),
ExchangeCoinAmount: item.GetExchangeCoinAmount(),
ExchangeUSDMinorAmount: item.GetExchangeUsdMinorAmount(),
ProviderAmountMinor: item.GetProviderAmountMinor(),
CreatedAtMS: item.GetCreatedAtMs(),
}
}

View File

@ -25,17 +25,37 @@ import (
const usdtMicroUnit int64 = 1_000_000
type Handler struct {
wallet walletclient.Client
userDB *sql.DB
walletDB *sql.DB
store *repository.Store
audit shared.OperationLogger
exchangeRates exchangeRateClient
wallet walletclient.Client
userDB *sql.DB
walletDB *sql.DB
store *repository.Store
audit shared.OperationLogger
exchangeRates exchangeRateClient
moneyRegionSources []MoneyRegionSource
}
type HandlerOption func(*Handler)
func WithMoneyRegionSources(sources ...MoneyRegionSource) HandlerOption {
return func(h *Handler) {
for _, source := range sources {
if source != nil {
h.moneyRegionSources = append(h.moneyRegionSources, source)
}
}
}
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger, options ...HandlerOption) *Handler {
handler := &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
// Handler option 让 legacy likei 区域源保持可选;本地/测试未配置线上 Mongo 时payment 仍只读本地 hyapp_user 主数据。
for _, option := range options {
if option != nil {
option(handler)
}
}
return handler
}
func (h *Handler) ListRechargeBills(c *gin.Context) {

View File

@ -52,6 +52,41 @@ func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) {
}
}
func TestListRechargeBillsReturnsProviderAmountMinor(t *testing.T) {
wallet := &mockPaymentWallet{rechargeBillsResp: &walletv1.ListRechargeBillsResponse{
Bills: []*walletv1.RechargeBill{{
AppCode: "lalu",
TransactionId: "tx-h5",
RechargeType: "mifapay_recharge",
Status: "succeeded",
CurrencyCode: "SAR",
CoinAmount: 120000,
UsdMinorAmount: 1500,
ProviderAmountMinor: 5625,
CreatedAtMs: 1760000000000,
}},
Total: 1,
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills?status=succeeded", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items := response.Data["items"].([]any)
item := items[0].(map[string]any)
if item["providerAmountMinor"] != float64(5625) || item["currencyCode"] != "SAR" {
t.Fatalf("provider amount should be returned for finance recharge detail: %+v", item)
}
}
func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{

View File

@ -21,9 +21,10 @@ import (
)
const (
moneyPaymentLinkCreatePermission = "money:payment-link:create"
moneyViewPermission = "money:view"
temporaryRechargeAudience = "temporary"
financeViewPermission = "finance:view"
legacyMoneyViewPermission = "money:view"
temporaryPaymentLinkCreatePermission = "payment-temporary-link:create"
temporaryRechargeAudience = "temporary"
)
type temporaryPaymentLinkCreateRequest struct {
@ -397,13 +398,24 @@ func (h *Handler) findTemporaryPaymentMethod(ctx context.Context, appCode string
}
func (h *Handler) countryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, error) {
if h.userDB == nil {
return false, errors.New("user mysql is not configured")
}
appCode = appctx.Normalize(appCode)
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" || countryCode == "GLOBAL" {
return false, nil
}
for _, source := range h.moneyRegionSources {
// legacy App 的区域-国家关系和区域目录同源source 明确命中时直接返回,避免 Yumi/Aslan 回落到 hyapp_user 本地表。
ok, handled, err := source.CountryBelongsToRegion(ctx, appCode, regionID, countryCode)
if err != nil {
return false, err
}
if handled {
return ok, nil
}
}
if h.userDB == nil {
return false, errors.New("user mysql is not configured")
}
var count int
err := h.userDB.QueryRowContext(ctx, `
SELECT COUNT(*)
@ -430,6 +442,14 @@ func (h *Handler) loadMoneyMasterData(ctx context.Context, access repository.Mon
if err != nil {
return nil, nil, nil, err
}
// Yumi/Aslan 这类 legacy App 的区域事实来自线上 likei Mongo这里合并目录数据但财务授权仍只保存 app_code + region_id。
sourceApps, sourceRegions, sourceCountries, err := h.listMoneyRegionSources(ctx, access)
if err != nil {
return nil, nil, nil, err
}
apps = appendUniqueMoneyApps(apps, sourceApps)
regions = appendUniqueMoneyRegions(regions, sourceRegions)
countries = appendUniqueMoneyCountries(countries, sourceCountries)
return apps, regions, countries, nil
}

View File

@ -0,0 +1,350 @@
package payment
import (
"context"
"fmt"
"hash/fnv"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/repository"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const (
legacySyntheticRegionIDBase int64 = 9_000_000_000_000_000_000
legacySyntheticRegionIDRange uint64 = 223_372_036_854_775_807
)
type MoneyRegionSource interface {
ListMoneyMasterData(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error)
CountryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, bool, error)
}
type MongoMoneyRegionSource struct {
collection *mongo.Collection
config config.MoneyRegionSourceConfig
}
type legacyRegionDocument struct {
ID any `bson:"_id"`
RegionCode string `bson:"regionCode"`
SysOrigin string `bson:"sysOrigin"`
RegionName string `bson:"regionName"`
CountryCodes string `bson:"countryCodes"`
Del bool `bson:"del"`
}
func NewMongoMoneyRegionSource(database *mongo.Database, sourceConfig config.MoneyRegionSourceConfig) MoneyRegionSource {
if database == nil || !sourceConfig.Enabled {
return nil
}
collectionName := strings.TrimSpace(sourceConfig.MongoCollection)
if collectionName == "" {
collectionName = "sys_region_config"
}
sourceConfig.AppCode = appctx.Normalize(sourceConfig.AppCode)
sourceConfig.AppName = strings.TrimSpace(sourceConfig.AppName)
sourceConfig.SysOrigin = strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
sourceConfig.MongoCollection = collectionName
return &MongoMoneyRegionSource{collection: database.Collection(collectionName), config: sourceConfig}
}
func (s *MongoMoneyRegionSource) ListMoneyMasterData(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
if s == nil || s.collection == nil {
return nil, nil, nil, nil
}
appCode := appctx.Normalize(s.config.AppCode)
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {
return nil, nil, nil, nil
}
filter := bson.M{
"sysOrigin": s.config.SysOrigin,
"del": false,
}
// Yumi/Aslan 的区域事实仍由 legacy likei Mongo 持有admin 只读与 Java 当前查询等价的字段,不复制对方写模型。
cursor, err := s.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createTime", Value: -1}}))
if err != nil {
return nil, nil, nil, fmt.Errorf("query legacy money regions for %s: %w", appCode, err)
}
defer cursor.Close(ctx)
documents := []legacyRegionDocument{}
for cursor.Next(ctx) {
var document legacyRegionDocument
if err := cursor.Decode(&document); err != nil {
return nil, nil, nil, fmt.Errorf("decode legacy money region for %s: %w", appCode, err)
}
documents = append(documents, document)
}
if err := cursor.Err(); err != nil {
return nil, nil, nil, fmt.Errorf("iterate legacy money regions for %s: %w", appCode, err)
}
return legacyRegionDocumentsToMoneyData(s.config, documents, access)
}
func (s *MongoMoneyRegionSource) CountryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, bool, error) {
if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != appctx.Normalize(appCode) {
return false, false, nil
}
filter := bson.M{
"sysOrigin": s.config.SysOrigin,
"del": false,
}
cursor, err := s.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createTime", Value: -1}}))
if err != nil {
return false, false, fmt.Errorf("query legacy region countries for %s: %w", appCode, err)
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var document legacyRegionDocument
if err := cursor.Decode(&document); err != nil {
return false, false, fmt.Errorf("decode legacy region countries for %s: %w", appCode, err)
}
documentRegionID, _, err := legacyMoneyRegionID(s.config, document)
if err != nil {
return false, false, err
}
if documentRegionID != regionID {
continue
}
for _, item := range splitLegacyCountryCodes(document.CountryCodes) {
if item == strings.ToUpper(strings.TrimSpace(countryCode)) {
return true, true, nil
}
}
return false, true, nil
}
if err := cursor.Err(); err != nil {
return false, false, fmt.Errorf("iterate legacy region countries for %s: %w", appCode, err)
}
return false, false, nil
}
func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error) {
if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != appctx.Normalize(appCode) {
return nil, false, nil
}
filter := bson.M{
"sysOrigin": s.config.SysOrigin,
"del": false,
}
cursor, err := s.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createTime", Value: -1}}))
if err != nil {
return nil, false, fmt.Errorf("query legacy region countries for %s: %w", appCode, err)
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var document legacyRegionDocument
if err := cursor.Decode(&document); err != nil {
return nil, false, fmt.Errorf("decode legacy region countries for %s: %w", appCode, err)
}
documentRegionID, _, err := legacyMoneyRegionID(s.config, document)
if err != nil {
return nil, false, err
}
if documentRegionID != regionID {
continue
}
// 大屏外接源只认 legacy country_code这里集中复用财务范围的 Mongo 区域口径,避免每个外接 App 自己维护一套区域映射。
return splitLegacyCountryCodes(document.CountryCodes), true, nil
}
if err := cursor.Err(); err != nil {
return nil, false, fmt.Errorf("iterate legacy region countries for %s: %w", appCode, err)
}
return nil, false, nil
}
func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfig, documents []legacyRegionDocument, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
appCode := appctx.Normalize(sourceConfig.AppCode)
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {
return nil, nil, nil, nil
}
appName := strings.TrimSpace(sourceConfig.AppName)
if appName == "" {
appName = appCode
}
sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
regions := []moneyRegionDTO{}
countries := []moneyCountryDTO{}
seenRegionIDs := map[int64]string{}
for index, document := range documents {
if document.Del || sysOrigin != "" && !strings.EqualFold(strings.TrimSpace(document.SysOrigin), sysOrigin) {
continue
}
regionID, rawRegionID, err := legacyMoneyRegionID(sourceConfig, document)
if err != nil {
return nil, nil, nil, err
}
if previousRawID, ok := seenRegionIDs[regionID]; ok && previousRawID != rawRegionID {
return nil, nil, nil, fmt.Errorf("legacy money region id collision for %s: %s and %s", appCode, previousRawID, rawRegionID)
}
seenRegionIDs[regionID] = rawRegionID
if !access.All && !access.Allows(appCode, regionID) {
continue
}
regionCode := strings.TrimSpace(document.RegionCode)
if regionCode == "" {
regionCode = strconv.FormatInt(regionID, 10)
}
regionName := strings.TrimSpace(document.RegionName)
if regionName == "" {
regionName = regionCode
}
countryCodes := splitLegacyCountryCodes(document.CountryCodes)
regions = append(regions, moneyRegionDTO{
AppCode: appCode,
RegionID: regionID,
RegionCode: regionCode,
Name: regionName,
Status: "active",
SortOrder: index + 1,
Countries: countryCodes,
})
for countryIndex, countryCode := range countryCodes {
countries = append(countries, moneyCountryDTO{
AppCode: appCode,
CountryCode: countryCode,
CountryName: countryCode,
CountryDisplayName: countryCode,
Enabled: true,
RegionID: regionID,
SortOrder: countryIndex + 1,
})
}
}
return []moneyAppDTO{{
AppCode: appCode,
AppName: appName,
Platform: "legacy",
Status: "active",
}}, regions, countries, nil
}
func legacyMoneyRegionID(sourceConfig config.MoneyRegionSourceConfig, document legacyRegionDocument) (int64, string, error) {
appCode := appctx.Normalize(sourceConfig.AppCode)
rawID := legacyRawRegionID(document.ID)
if rawID == "" {
return 0, "", fmt.Errorf("legacy money region %s has empty id", appCode)
}
if numericID, err := strconv.ParseInt(rawID, 10, 64); err == nil && numericID > 0 {
return numericID, rawID, nil
}
// Java legacy 数据里历史区域多数是雪花数字字符串,但线上已存在 Mongo ObjectIdadmin 授权表只能存 int64
// 因此把 appCode + 原始 _id 哈希到 9e18 以上的保留正整数段,保证同一 legacy 区域跨进程、跨部署稳定。
hash := fnv.New64a()
_, _ = hash.Write([]byte(appCode))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(rawID))
return legacySyntheticRegionIDBase + int64(hash.Sum64()%legacySyntheticRegionIDRange), rawID, nil
}
func legacyRawRegionID(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case bson.ObjectID:
return typed.Hex()
case fmt.Stringer:
return strings.TrimSpace(typed.String())
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func (h *Handler) listMoneyRegionSources(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
if h == nil || len(h.moneyRegionSources) == 0 {
return nil, nil, nil, nil
}
apps := []moneyAppDTO{}
regions := []moneyRegionDTO{}
countries := []moneyCountryDTO{}
for _, source := range h.moneyRegionSources {
sourceApps, sourceRegions, sourceCountries, err := source.ListMoneyMasterData(ctx, access)
if err != nil {
return nil, nil, nil, err
}
apps = appendUniqueMoneyApps(apps, sourceApps)
regions = appendUniqueMoneyRegions(regions, sourceRegions)
countries = appendUniqueMoneyCountries(countries, sourceCountries)
}
return apps, regions, countries, nil
}
func splitLegacyCountryCodes(value string) []string {
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' || r == '\r'
})
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, part := range parts {
code := strings.ToUpper(strings.TrimSpace(part))
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
return out
}
func appendUniqueMoneyApps(base []moneyAppDTO, extra []moneyAppDTO) []moneyAppDTO {
seen := map[string]struct{}{}
for _, item := range base {
seen[appctx.Normalize(item.AppCode)] = struct{}{}
}
for _, item := range extra {
key := appctx.Normalize(item.AppCode)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
base = append(base, item)
}
return base
}
func appendUniqueMoneyRegions(base []moneyRegionDTO, extra []moneyRegionDTO) []moneyRegionDTO {
seen := map[string]struct{}{}
for _, item := range base {
seen[fmt.Sprintf("%s:%d", appctx.Normalize(item.AppCode), item.RegionID)] = struct{}{}
}
for _, item := range extra {
key := fmt.Sprintf("%s:%d", appctx.Normalize(item.AppCode), item.RegionID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
base = append(base, item)
}
return base
}
func appendUniqueMoneyCountries(base []moneyCountryDTO, extra []moneyCountryDTO) []moneyCountryDTO {
seen := map[string]struct{}{}
for _, item := range base {
seen[fmt.Sprintf("%s:%d:%s", appctx.Normalize(item.AppCode), item.RegionID, strings.ToUpper(item.CountryCode))] = struct{}{}
}
for _, item := range extra {
key := fmt.Sprintf("%s:%d:%s", appctx.Normalize(item.AppCode), item.RegionID, strings.ToUpper(item.CountryCode))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
base = append(base, item)
}
return base
}

View File

@ -0,0 +1,178 @@
package payment
import (
"context"
"database/sql"
"database/sql/driver"
"strings"
"testing"
"time"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"github.com/DATA-DOG/go-sqlmock"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
objectID, err := bson.ObjectIDFromHex("69d88849bdbcc07ebc3f118c")
if err != nil {
t.Fatalf("build object id failed: %v", err)
}
cfg := config.MoneyRegionSourceConfig{
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
}
apps, regions, countries, err := legacyRegionDocumentsToMoneyData(cfg, []legacyRegionDocument{
{ID: objectID, RegionCode: "OTHER", SysOrigin: "LIKEI", RegionName: "Default", CountryCodes: "", Del: false},
{ID: "1001", RegionCode: "MIDDLE_EAST", SysOrigin: "LIKEI", RegionName: "中东区", CountryCodes: "SA, AE", Del: false},
{ID: "1002", RegionCode: "DELETED", SysOrigin: "LIKEI", RegionName: "已删区域", CountryCodes: "IN", Del: true},
{ID: "1003", RegionCode: "OTHER", SysOrigin: "OTHER", RegionName: "其他系统", CountryCodes: "US", Del: false},
}, repository.MoneyAccess{All: true})
if err != nil {
t.Fatalf("legacy money data failed: %v", err)
}
if len(apps) != 1 || apps[0].AppCode != "yumi" || apps[0].AppName != "Yumi" || apps[0].Platform != "legacy" {
t.Fatalf("apps mismatch: %+v", apps)
}
if len(regions) != 2 || regions[0].RegionID < legacySyntheticRegionIDBase || regions[0].RegionCode != "OTHER" || regions[1].RegionID != 1001 || regions[1].RegionCode != "MIDDLE_EAST" {
t.Fatalf("regions mismatch: %+v", regions)
}
if len(countries) != 2 || countries[0].CountryCode != "SA" || countries[1].CountryCode != "AE" || countries[0].RegionID != 1001 {
t.Fatalf("countries mismatch: %+v", countries)
}
}
func TestLoadMoneyMasterDataIncludesScopedMoneyRegionSource(t *testing.T) {
userDB, sqlMock, cleanup := newMoneyRegionSourceUserSQLMock(t)
defer cleanup()
expectEmptyMoneyApps(sqlMock, "yumi")
handler := New(&mockPaymentWallet{}, userDB, nil, nil, nil, WithMoneyRegionSources(&staticMoneyRegionSource{
apps: []moneyAppDTO{{AppCode: "yumi", AppName: "Yumi", Platform: "legacy", Status: "active"}},
regions: []moneyRegionDTO{
{AppCode: "yumi", RegionID: 1001, RegionCode: "MIDDLE_EAST", Name: "中东区", Status: "active"},
{AppCode: "yumi", RegionID: 1002, RegionCode: "INDIA", Name: "印度区", Status: "active"},
},
}))
_, regions, _, err := handler.loadMoneyMasterData(context.Background(), repository.MoneyAccess{
Scopes: []model.UserMoneyScope{{AppCode: "yumi", RegionID: 1002}},
})
if err != nil {
t.Fatalf("load money master data failed: %v", err)
}
if len(regions) != 1 || regions[0].RegionID != 1002 || regions[0].RegionCode != "INDIA" {
t.Fatalf("scoped regions mismatch: %+v", regions)
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestLegacyRegionDocumentsMapNonNumericRegionIDStably(t *testing.T) {
cfg := config.MoneyRegionSourceConfig{
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
}
document := legacyRegionDocument{ID: "69d88849bdbcc07ebc3f118c", RegionCode: "OTHER", SysOrigin: "LIKEI"}
first, rawFirst, err := legacyMoneyRegionID(cfg, document)
if err != nil {
t.Fatalf("map legacy id failed: %v", err)
}
second, rawSecond, err := legacyMoneyRegionID(cfg, document)
if err != nil {
t.Fatalf("map legacy id second time failed: %v", err)
}
if first != second || rawFirst != rawSecond || first < legacySyntheticRegionIDBase {
t.Fatalf("legacy id mapping is not stable: first=%d/%s second=%d/%s", first, rawFirst, second, rawSecond)
}
}
func TestLegacyRegionDocumentsRejectEmptyRegionID(t *testing.T) {
_, _, _, err := legacyRegionDocumentsToMoneyData(config.MoneyRegionSourceConfig{
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
}, []legacyRegionDocument{{ID: "", RegionCode: "MIDDLE_EAST", SysOrigin: "LIKEI"}}, repository.MoneyAccess{All: true})
if err == nil {
t.Fatalf("expected empty region id error")
}
}
func TestCountryBelongsToRegionUsesMoneyRegionSource(t *testing.T) {
handler := New(&mockPaymentWallet{}, nil, nil, nil, nil, WithMoneyRegionSources(&staticMoneyRegionSource{
regions: []moneyRegionDTO{{AppCode: "yumi", RegionID: 1002, RegionCode: "INDIA", Name: "印度区", Countries: []string{"IN", "PK"}}},
}))
ok, err := handler.countryBelongsToRegion(context.Background(), "yumi", 1002, "in")
if err != nil {
t.Fatalf("country belongs to legacy region failed: %v", err)
}
if !ok {
t.Fatalf("expected legacy source country match")
}
}
type staticMoneyRegionSource struct {
apps []moneyAppDTO
regions []moneyRegionDTO
countries []moneyCountryDTO
}
func (s *staticMoneyRegionSource) ListMoneyMasterData(_ context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
regions := []moneyRegionDTO{}
for _, region := range s.regions {
if access.All || access.Allows(region.AppCode, region.RegionID) {
regions = append(regions, region)
}
}
return s.apps, regions, s.countries, nil
}
func (s *staticMoneyRegionSource) CountryBelongsToRegion(_ context.Context, appCode string, regionID int64, countryCode string) (bool, bool, error) {
for _, region := range s.regions {
if region.AppCode != appCode || region.RegionID != regionID {
continue
}
for _, item := range region.Countries {
if item == strings.ToUpper(strings.TrimSpace(countryCode)) {
return true, true, nil
}
}
return false, true, nil
}
return false, false, nil
}
func newMoneyRegionSourceUserSQLMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
return db, mock, func() {
_ = db.Close()
}
}
func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) {
query := mock.ExpectQuery(`(?s)SELECT app_id, app_code, app_name, package_name, platform, status\s+FROM apps\s+WHERE status = 'active'`)
if len(appCodes) > 0 {
args := make([]driver.Value, 0, len(appCodes))
for _, appCode := range appCodes {
args = append(args, appCode)
}
query.WithArgs(args...)
}
query.WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "package_name", "platform", "status"}))
}
func TestMoneyRegionSourceConfigDefaults(t *testing.T) {
cfg := config.Default()
if len(cfg.MoneyRegionSources) != 2 || cfg.MoneyRegionSources[0].RequestTimeout != 5*time.Second || cfg.MoneyRegionSources[1].AppCode != "aslan" {
t.Fatalf("default money region source mismatch: %+v", cfg.MoneyRegionSources)
}
}

View File

@ -12,12 +12,13 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/third-party-channels", middleware.RequireAnyPermission("payment-third-party:view", moneyViewPermission), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/money/scope", middleware.RequirePermission(moneyViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/performance", middleware.RequirePermission(moneyViewPermission), h.GetMoneyPerformance)
protected.GET("/admin/payment/temporary-links", middleware.RequireAnyPermission("payment-temporary-link:view", moneyViewPermission), h.ListTemporaryPaymentLinks)
protected.POST("/admin/payment/temporary-links", middleware.RequirePermission(moneyPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequireAnyPermission("payment-temporary-link:view", moneyViewPermission), h.GetTemporaryPaymentLink)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
protected.POST("/admin/payment/temporary-links", middleware.RequirePermission(temporaryPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-methods/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"math"
"strconv"
"strings"
@ -68,6 +69,10 @@ type statusRequest struct {
Reason string `json:"reason"`
}
type recycleRequest struct {
Reason string `json:"reason"`
}
func (h *Handler) ListPools(c *gin.Context) {
page, pageSize, ok := pageQuery(c)
if !ok {
@ -200,9 +205,55 @@ func (h *Handler) ListIDs(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := attachAssignedUsers(c.Request.Context(), h.user, middleware.CurrentRequestID(c), items); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize})
}
type assignedUserBatchGetter interface {
BatchGetUsers(ctx context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error)
}
func attachAssignedUsers(ctx context.Context, getter assignedUserBatchGetter, requestID string, items []*userclient.PrettyDisplayID) error {
if getter == nil || len(items) == 0 {
return nil
}
userIDs := make([]int64, 0, len(items))
seen := make(map[int64]struct{}, len(items))
for _, item := range items {
if item == nil || item.AssignedUserID <= 0 {
continue
}
if _, ok := seen[item.AssignedUserID]; ok {
continue
}
// pretty_display_ids 用 0 表示未占用;这里只批量补真实正数 user_id避免前端把哨兵值渲染成用户。
seen[item.AssignedUserID] = struct{}{}
userIDs = append(userIDs, item.AssignedUserID)
}
if len(userIDs) == 0 {
return nil
}
users, err := getter.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{
RequestID: requestID,
Caller: "admin-server",
UserIDs: userIDs,
})
if err != nil {
return err
}
for _, item := range items {
if item == nil || item.AssignedUserID <= 0 {
continue
}
// BatchGetUsers 对缺失用户不返回占位对象;保持 nil 让前端显示空态,不用内部 user_id 冒充昵称或短号。
item.AssignedUser = users[item.AssignedUserID]
}
return nil
}
func (h *Handler) Grant(c *gin.Context) {
var req grantRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -256,6 +307,29 @@ func (h *Handler) SetStatus(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) Recycle(c *gin.Context) {
var req recycleRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "回收参数不正确")
return
}
// 回收只能以路径 pretty_id 定位,不能相信前端传用户 ID 或 lease ID真实占用关系由 user-service 事务重新锁表校验。
prettyID := strings.TrimSpace(c.Param("pretty_id"))
item, err := h.user.RecyclePrettyDisplayID(c.Request.Context(), userclient.RecyclePrettyDisplayIDRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PrettyID: prettyID,
Reason: strings.TrimSpace(req.Reason),
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "recycle-pretty-id", "pretty_display_ids", prettyID, "success", "status="+item.Status)
response.OK(c, item)
}
func pageQuery(c *gin.Context) (int32, int32, bool) {
// 分页参数在 admin-server 先收敛到 1..100,避免后台页面传错值造成 user-service 大分页查询。
page := int32(1)

View File

@ -2,6 +2,7 @@ package prettyid
import (
"context"
"errors"
"reflect"
"strconv"
"testing"
@ -18,6 +19,20 @@ type fakeGrantTargetResolver struct {
calls []string
}
type fakeAssignedUserBatchGetter struct {
calls [][]int64
err error
users map[int64]*userclient.User
}
func (f *fakeAssignedUserBatchGetter) BatchGetUsers(_ context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) {
f.calls = append(f.calls, append([]int64(nil), req.UserIDs...))
if f.err != nil {
return nil, f.err
}
return f.users, nil
}
func (f *fakeGrantTargetResolver) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) {
f.calls = append(f.calls, "get:"+strconv.FormatInt(req.UserID, 10))
if user := f.users[req.UserID]; user != nil {
@ -34,6 +49,57 @@ func (f *fakeGrantTargetResolver) ResolveDisplayUserID(_ context.Context, req us
return nil, status.Error(codes.NotFound, "display_user_id not found")
}
func TestAttachAssignedUsersAddsSnapshotsAndSkipsZero(t *testing.T) {
getter := &fakeAssignedUserBatchGetter{
users: map[int64]*userclient.User{
42: {
Avatar: "https://cdn.example/u42.png",
DefaultDisplayUserID: "100042",
DisplayUserID: "VIP42",
PrettyDisplayUserID: "VIP42",
UserID: 42,
Username: "owner42",
},
},
}
items := []*userclient.PrettyDisplayID{
{PrettyID: "available", AssignedUserID: 0},
{PrettyID: "assigned-42", AssignedUserID: 42},
{PrettyID: "assigned-42-dup", AssignedUserID: 42},
{PrettyID: "stale-77", AssignedUserID: 77},
}
if err := attachAssignedUsers(context.Background(), getter, "req", items); err != nil {
t.Fatalf("attachAssignedUsers returned error: %v", err)
}
if want := [][]int64{{42, 77}}; !reflect.DeepEqual(getter.calls, want) {
t.Fatalf("calls mismatch: got %v want %v", getter.calls, want)
}
if items[0].AssignedUser != nil {
t.Fatalf("zero assigned user should stay empty: %+v", items[0].AssignedUser)
}
if items[1].AssignedUser == nil || items[1].AssignedUser.Username != "owner42" || items[1].AssignedUser.DefaultDisplayUserID != "100042" {
t.Fatalf("assigned snapshot mismatch: %+v", items[1].AssignedUser)
}
if items[2].AssignedUser == nil || items[2].AssignedUser.UserID != 42 {
t.Fatalf("duplicate assigned id should reuse snapshot: %+v", items[2].AssignedUser)
}
if items[3].AssignedUser != nil {
t.Fatalf("missing batch user should stay empty: %+v", items[3].AssignedUser)
}
}
func TestAttachAssignedUsersReturnsBatchError(t *testing.T) {
wantErr := errors.New("batch failed")
err := attachAssignedUsers(context.Background(), &fakeAssignedUserBatchGetter{err: wantErr}, "req", []*userclient.PrettyDisplayID{
{PrettyID: "assigned", AssignedUserID: 42},
})
if !errors.Is(err, wantErr) {
t.Fatalf("error mismatch: got %v want %v", err, wantErr)
}
}
func TestResolveGrantTargetUserIDUsesShortDisplayID(t *testing.T) {
resolver := &fakeGrantTargetResolver{
users: map[int64]*userclient.User{},

View File

@ -17,5 +17,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/users/pretty-id-pools/:pool_id/generate", middleware.RequirePermission("pretty-id:generate"), h.Generate)
protected.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:view"), h.ListIDs)
protected.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), h.Grant)
protected.POST("/admin/users/pretty-ids/:pretty_id/recycle", middleware.RequirePermission("pretty-id:update"), h.Recycle)
protected.POST("/admin/users/pretty-ids/:pretty_id/status", middleware.RequirePermission("pretty-id:update"), h.SetStatus)
}

View File

@ -27,6 +27,7 @@ func ActorFromContext(c *gin.Context) Actor {
func ListOptions(c *gin.Context) repository.ListOptions {
page, _ := strconv.Atoi(DefaultString(c.Query("page"), "1"))
pageSize, _ := strconv.Atoi(DefaultString(c.Query("page_size"), DefaultString(c.Query("pageSize"), "10")))
teamID, _ := strconv.ParseUint(DefaultString(c.Query("team_id"), c.Query("teamId")), 10, 64)
if page < 1 {
page = 1
}
@ -39,6 +40,7 @@ func ListOptions(c *gin.Context) repository.ListOptions {
Keyword: c.Query("keyword"),
Role: c.Query("role"),
Status: c.Query("status"),
TeamID: uint(teamID),
Sort: c.Query("sort"),
}
}

View File

@ -0,0 +1,45 @@
package shared
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/model"
)
func TestListOptionsParsesTeamID(t *testing.T) {
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/users?page=2&page_size=20&team_id=2", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
options := ListOptions(ctx)
if options.Page != 2 || options.PageSize != 20 || options.TeamID != model.TeamOperationsID {
t.Fatalf("list options mismatch: %+v", options)
}
}
func TestUserDTOUsesTeamRecordName(t *testing.T) {
teamID := model.TeamOperationsID
dto := UserDTO(model.User{
ID: 7,
Username: "ops",
Name: "Ops",
Team: "平台运营",
TeamID: &teamID,
TeamRecord: model.Team{
ID: model.TeamOperationsID,
Name: "运营团队",
},
Status: model.UserStatusActive,
})
if dto["team"] != "运营团队" || dto["teamName"] != "运营团队" {
t.Fatalf("team display mismatch: %+v", dto)
}
if got, ok := dto["teamId"].(*uint); !ok || got == nil || *got != model.TeamOperationsID {
t.Fatalf("team id mismatch: %#v", dto["teamId"])
}
}

View File

@ -31,13 +31,16 @@ func UserDTO(user model.User) map[string]any {
if user.LastLoginAtMS != nil {
lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("01-02 15:04")
}
teamName := UserTeamName(user)
return map[string]any{
"id": user.ID,
"account": user.Username,
"username": user.Username,
"name": user.Name,
"team": user.Team,
"team": teamName,
"teamId": user.TeamID,
"teamName": teamName,
"status": user.Status,
"mfa": BoolText(user.MFAEnabled),
"mfaEnabled": user.MFAEnabled,
@ -50,6 +53,13 @@ func UserDTO(user model.User) map[string]any {
}
}
func UserTeamName(user model.User) string {
if strings.TrimSpace(user.TeamRecord.Name) != "" {
return user.TeamRecord.Name
}
return strings.TrimSpace(user.Team)
}
func PermissionsForUser(user model.User) []string {
set := map[string]struct{}{}
for _, role := range user.Roles {

View File

@ -0,0 +1,86 @@
package team
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
func New(store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(store), audit: audit}
}
func (h *Handler) ListTeams(c *gin.Context) {
teams, err := h.service.ListTeams(c.Query("keyword"))
if err != nil {
response.ServerError(c, "获取团队失败")
return
}
response.OK(c, teamDTOs(teams))
}
func (h *Handler) CreateTeam(c *gin.Context) {
var req teamRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "团队参数不正确")
return
}
team, err := h.service.CreateTeam(Input{Name: req.Name, ParentID: req.ParentID, Description: req.Description, Sort: req.Sort})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "create-team", "admin_teams", "success", team.Name)
response.Created(c, teamDTO(*team))
}
func (h *Handler) UpdateTeam(c *gin.Context) {
id, ok := shared.ParseID(c, "team_id")
if !ok {
return
}
var req teamRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "团队参数不正确")
return
}
team, err := h.service.UpdateTeam(id, Input{Name: req.Name, ParentID: req.ParentID, Description: req.Description, Sort: req.Sort})
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "团队不存在")
return
}
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-team", "admin_teams", "success", fmt.Sprintf("%d:%s", id, team.Name))
response.OK(c, teamDTO(*team))
}
func (h *Handler) ListTeamUsers(c *gin.Context) {
id, ok := shared.ParseID(c, "team_id")
if !ok {
return
}
options := shared.ListOptions(c)
users, total, err := h.service.ListTeamUsers(shared.ActorFromContext(c), id, options)
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "团队不存在")
return
}
if err != nil {
response.ServerError(c, "获取团队用户失败")
return
}
response.OK(c, response.Page{Items: shared.UserDTOs(users), Page: options.Page, PageSize: options.PageSize, Total: total})
}

View File

@ -0,0 +1,8 @@
package team
type teamRequest struct {
Name string `json:"name" binding:"required"`
ParentID *uint `json:"parentId"`
Description string `json:"description"`
Sort int `json:"sort"`
}

View File

@ -0,0 +1,25 @@
package team
import "hyapp-admin-server/internal/model"
func teamDTOs(teams []model.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, item := range teams {
out = append(out, teamDTO(item))
}
return out
}
func teamDTO(item model.Team) map[string]any {
return map[string]any{
"id": item.ID,
"parentId": item.ParentID,
"parentName": item.ParentName,
"name": item.Name,
"description": item.Description,
"sort": item.Sort,
"userCount": item.UserCount,
"createdAtMs": item.CreatedAtMS,
"updatedAtMs": item.UpdatedAtMS,
}
}

View File

@ -0,0 +1,14 @@
package team
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/teams", middleware.RequirePermission("team:view"), h.ListTeams)
protected.POST("/teams", middleware.RequirePermission("team:create"), h.CreateTeam)
protected.PATCH("/teams/:team_id", middleware.RequirePermission("team:update"), h.UpdateTeam)
protected.GET("/teams/:team_id/users", middleware.RequirePermission("user:view"), h.ListTeamUsers)
}

View File

@ -0,0 +1,66 @@
package team
import (
"strings"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
)
type Service struct {
store *repository.Store
}
type Input struct {
Name string
ParentID *uint
Description string
Sort int
}
func NewService(store *repository.Store) *Service {
return &Service{store: store}
}
func (s *Service) ListTeams(keyword string) ([]model.Team, error) {
return s.store.ListTeams(keyword)
}
func (s *Service) CreateTeam(input Input) (*model.Team, error) {
team := model.Team{
ParentID: input.ParentID,
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Sort: input.Sort,
}
if err := s.store.CreateTeam(&team); err != nil {
return nil, err
}
return s.store.GetTeam(team.ID)
}
func (s *Service) UpdateTeam(id uint, input Input) (*model.Team, error) {
return s.store.UpdateTeam(id, model.Team{
ParentID: input.ParentID,
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Sort: input.Sort,
})
}
func (s *Service) ListTeamUsers(actor shared.Actor, teamID uint, options repository.ListOptions) ([]model.User, int64, error) {
if _, err := s.store.GetTeam(teamID); err != nil {
return nil, 0, err
}
access, err := s.store.DataAccessForUser(actor.UserID)
if err != nil {
return nil, 0, err
}
teamIDs, err := s.store.TeamAndChildIDs(teamID)
if err != nil {
return nil, 0, err
}
options.TeamIDs = teamIDs
return s.store.ListUsersWithAccess(options, access)
}

View File

@ -30,7 +30,7 @@ func (s *Store) UsersByIDs(ids []uint) (map[uint]model.User, error) {
func (s *Store) ListUsersWithAccess(options ListOptions, access DataAccess) ([]model.User, int64, error) {
page, pageSize := normalizePage(options.Page, options.PageSize)
query := s.db.Model(&model.User{}).Preload("Roles")
query := s.db.Model(&model.User{}).Preload("Roles").Preload("TeamRecord")
query = applyUserFilters(query, options)
query = applyUserDataAccess(query, access)
@ -50,7 +50,7 @@ func (s *Store) ExportUsers(options ListOptions) ([]model.User, error) {
func (s *Store) ExportUsersWithAccess(options ListOptions, access DataAccess) ([]model.User, error) {
var users []model.User
query := s.db.Model(&model.User{}).Preload("Roles")
query := s.db.Model(&model.User{}).Preload("Roles").Preload("TeamRecord")
query = applyUserFilters(query, options)
query = applyUserDataAccess(query, access)
err := query.Order(userSort(options.Sort)).Find(&users).Error

View File

@ -9,7 +9,7 @@ import (
func (s *Store) FindUserByUsername(username string) (*model.User, error) {
var user model.User
err := s.db.Preload("Roles.Permissions").Where("username = ?", username).First(&user).Error
err := s.db.Preload("Roles.Permissions").Preload("TeamRecord").Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
@ -19,7 +19,7 @@ func (s *Store) FindUserByUsername(username string) (*model.User, error) {
func (s *Store) FindUserByID(id uint) (*model.User, error) {
var user model.User
err := s.db.Preload("Roles.Permissions").First(&user, id).Error
err := s.db.Preload("Roles.Permissions").Preload("TeamRecord").First(&user, id).Error
if err != nil {
return nil, err
}

View File

@ -0,0 +1,127 @@
package repository
import (
"errors"
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrFinanceApplicationAlreadyAudited = errors.New("finance application already audited")
type FinanceApplicationListOptions struct {
Page int
PageSize int
AppCode string
Operation string
Status string
Keyword string
ApplicantUserID uint
}
type FinanceApplicationAuditInput struct {
Decision string
AuditorUserID uint
AuditorName string
AuditRemark string
AuditedAtMS int64
WalletCommandID string
WalletTransactionID string
WalletAssetType string
WalletAmountDelta int64
WalletBalanceAfter int64
}
func (s *Store) CreateFinanceApplication(application *model.FinanceApplication) error {
if application == nil {
return errors.New("finance application is required")
}
return s.db.Create(application).Error
}
func (s *Store) GetFinanceApplication(id uint) (*model.FinanceApplication, error) {
var application model.FinanceApplication
if err := s.db.First(&application, id).Error; err != nil {
return nil, err
}
return &application, nil
}
func (s *Store) ListFinanceApplications(options FinanceApplicationListOptions) ([]model.FinanceApplication, int64, error) {
page, pageSize := normalizePage(options.Page, options.PageSize)
query := s.db.Model(&model.FinanceApplication{})
query = applyFinanceApplicationFilters(query, options)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var applications []model.FinanceApplication
err := query.
Order("created_at_ms DESC, id DESC").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&applications).Error
return applications, total, err
}
func (s *Store) AuditFinanceApplication(id uint, input FinanceApplicationAuditInput) (*model.FinanceApplication, error) {
if id == 0 {
return nil, errors.New("finance application id is required")
}
if input.Decision != model.FinanceApplicationStatusApproved && input.Decision != model.FinanceApplicationStatusRejected {
return nil, errors.New("finance application decision is invalid")
}
var application model.FinanceApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
// 审批是一次性状态流转:先用行锁读取当前状态,再只允许 pending 进入终态,防止两个财务并发点击导致后写覆盖先写。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&application, id).Error; err != nil {
return err
}
if application.Status != model.FinanceApplicationStatusPending {
return ErrFinanceApplicationAlreadyAudited
}
return tx.Model(&application).Updates(map[string]any{
"status": input.Decision,
"auditor_user_id": input.AuditorUserID,
"auditor_name": strings.TrimSpace(input.AuditorName),
"audit_remark": strings.TrimSpace(input.AuditRemark),
"audited_at_ms": input.AuditedAtMS,
"wallet_command_id": strings.TrimSpace(input.WalletCommandID),
"wallet_transaction_id": strings.TrimSpace(input.WalletTransactionID),
"wallet_asset_type": strings.TrimSpace(input.WalletAssetType),
"wallet_amount_delta": input.WalletAmountDelta,
"wallet_balance_after": input.WalletBalanceAfter,
"updated_at_ms": input.AuditedAtMS,
}).Error
})
if err != nil {
return nil, err
}
return s.GetFinanceApplication(id)
}
func applyFinanceApplicationFilters(query *gorm.DB, options FinanceApplicationListOptions) *gorm.DB {
if options.ApplicantUserID > 0 {
// “我的申请”接口只允许服务端用登录态限定申请人,不能信任前端传申请人 ID避免普通发起人越权查看他人财务申请。
query = query.Where("applicant_user_id = ?", options.ApplicantUserID)
}
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
query = query.Where("app_code = ?", appCode)
}
if operation := strings.TrimSpace(options.Operation); operation != "" {
query = query.Where("operation = ?", operation)
}
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
query = query.Where("status = ?", status)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("target_user_id LIKE ? OR applicant_name LIKE ? OR auditor_name LIKE ? OR CAST(applicant_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like)
}
return query
}

View File

@ -0,0 +1,57 @@
package repository
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestListFinanceApplicationsFiltersByApplicant(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_finance_applications`").
WithArgs(uint(7), "pending").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery("SELECT \\* FROM `admin_finance_applications`").
WithArgs(uint(7), "pending", 50).
WillReturnRows(sqlmock.NewRows([]string{
"id",
"app_code",
"operation",
"wallet_identity",
"target_user_id",
"coin_amount",
"recharge_amount",
"credential_image_url",
"credential_text",
"applicant_user_id",
"applicant_name",
"auditor_user_id",
"auditor_name",
"status",
"audit_remark",
"audited_at_ms",
"created_at_ms",
"updated_at_ms",
}).AddRow(9, "lalu", "user_coin_credit", "", "10001", int64(100), "12.50", "", "receipt", 7, "ops", nil, "", "pending", "", nil, int64(1700000100000), int64(1700000100000)))
items, total, err := store.ListFinanceApplications(FinanceApplicationListOptions{
ApplicantUserID: 7,
Page: 1,
PageSize: 50,
Status: "pending",
})
if err != nil {
t.Fatalf("list finance applications failed: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("finance application page mismatch: total=%d items=%+v", total, items)
}
if items[0].ApplicantUserID != 7 || items[0].TargetUserID != "10001" {
t.Fatalf("finance application row mismatch: %+v", items[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}

View File

@ -37,10 +37,16 @@ func normalizeMenu(menu model.Menu) (model.Menu, error) {
func applyUserFilters(query *gorm.DB, options ListOptions) *gorm.DB {
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("username LIKE ? OR name LIKE ? OR team LIKE ?", like, like, like)
query = query.Joins("LEFT JOIN admin_teams user_team ON user_team.id = admin_users.team_id").
Where("username LIKE ? OR admin_users.name LIKE ? OR admin_users.team LIKE ? OR user_team.name LIKE ?", like, like, like, like)
}
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
query = query.Where("status = ?", status)
query = query.Where("admin_users.status = ?", status)
}
if len(options.TeamIDs) > 0 {
query = query.Where("admin_users.team_id IN ?", options.TeamIDs)
} else if options.TeamID > 0 {
query = query.Where("admin_users.team_id = ?", options.TeamID)
}
if role := strings.TrimSpace(options.Role); role != "" && role != "全部角色" && role != "all" {
query = query.Joins("JOIN admin_user_roles aur ON aur.user_id = admin_users.id").
@ -93,13 +99,13 @@ func normalizePage(page int, pageSize int) (int, int) {
func userSort(sort string) string {
switch sort {
case "username_asc":
return "username ASC"
return "admin_users.username ASC"
case "username_desc":
return "username DESC"
return "admin_users.username DESC"
case "last_login_desc":
return "last_login_at_ms DESC"
return "admin_users.last_login_at_ms DESC"
default:
return "id ASC"
return "admin_users.id ASC"
}
}

View File

@ -16,6 +16,8 @@ type ListOptions struct {
Keyword string
Role string
Status string
TeamID uint
TeamIDs []uint
Sort string
}
@ -58,6 +60,7 @@ func New(db *gorm.DB) *Store {
func (s *Store) AutoMigrate() error {
return s.db.AutoMigrate(
&model.Team{},
&model.User{},
&model.Role{},
&model.Permission{},
@ -78,6 +81,8 @@ func (s *Store) AutoMigrate() error {
&model.DataScope{},
&model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{},
&model.FinanceApplication{},
&model.UserWithdrawalApplication{},
&model.AdminJob{},
)
}
@ -101,6 +106,9 @@ func (s *Store) Seed(cfg config.Config) error {
if err := s.seedMenus(); err != nil {
return err
}
if err := s.seedTeams(); err != nil {
return err
}
if err := s.pruneDeprecatedDefaults(); err != nil {
return err
}

View File

@ -16,6 +16,9 @@ var defaultPermissions = []model.Permission{
{Name: "用户状态", Code: "user:status", Kind: "button"},
{Name: "用户重置密码", Code: "user:reset-password", Kind: "button"},
{Name: "用户导出", Code: "user:export", Kind: "button"},
{Name: "团队查看", Code: "team:view", Kind: "menu"},
{Name: "团队创建", Code: "team:create", Kind: "button"},
{Name: "团队更新", Code: "team:update", Kind: "button"},
{Name: "App 用户查看", Code: "app-user:view", Kind: "menu"},
{Name: "App 用户更新", Code: "app-user:update", Kind: "button"},
{Name: "App 用户状态", Code: "app-user:status", Kind: "button"},
@ -101,6 +104,7 @@ var defaultPermissions = []model.Permission{
{Name: "币商工资兑换比例", Code: "coin-seller:exchange-rate", Kind: "button"},
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
@ -112,8 +116,18 @@ var defaultPermissions = []model.Permission{
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
{Name: "财务系统查看", Code: "money:view", Kind: "menu"},
{Name: "财务支付链接创建", Code: "money:payment-link:create", Kind: "button"},
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
// 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit是否能看到审核列表由 audit 权限单独决定。
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
{Name: "财务申请发起", Code: "finance-application:create", Kind: "button"},
{Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"},
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"},
{Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"},
{Name: "财务增加用户钱包", Code: "finance-operation:user-wallet-credit", Kind: "button"},
{Name: "财务扣减用户钱包", Code: "finance-operation:user-wallet-debit", Kind: "button"},
{Name: "财务增加币商金币", Code: "finance-operation:coin-seller-coin-credit", Kind: "button"},
{Name: "财务减少币商金币", Code: "finance-operation:coin-seller-coin-debit", Kind: "button"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
@ -186,6 +200,7 @@ var defaultPermissions = []model.Permission{
var deprecatedPermissionCodes = []string{
"service:view", "service:create", "service:update", "service:operate",
"notification:view", "notification:read", "notification:read-all", "notification:delete",
"money:view", "money:payment-link:create",
}
var deprecatedMenuCodes = []string{"services", "notifications", "jobs"}
@ -266,6 +281,7 @@ func (s *Store) seedMenus() error {
{ParentID: &systemID, Title: "菜单权限", Code: "system-menus", Path: "/system/menus", Icon: "menu", PermissionCode: "menu:view", Sort: 33, Visible: true},
{ParentID: &systemID, Title: "登录日志", Code: "logs-login", Path: "/logs/login", Icon: "login", PermissionCode: "log:view", Sort: 34, Visible: true},
{ParentID: &systemID, Title: "操作日志", Code: "logs-operations", Path: "/logs/operations", Icon: "receipt", PermissionCode: "log:view", Sort: 35, 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-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},
@ -327,6 +343,7 @@ func (s *Store) seedMenus() error {
{ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true},
{ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true},
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true},
{ParentID: &hostOrgID, Title: "主播提现", Code: "host-withdrawals", Path: "/host/withdrawals", Icon: "receipt", PermissionCode: "host-withdrawal:view", Sort: 88, Visible: true},
}
for _, menu := range children {
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
@ -336,6 +353,75 @@ func (s *Store) seedMenus() error {
return nil
}
func (s *Store) seedTeams() error {
teams := []model.Team{
{ID: model.TeamProductResearchID, Name: "产研团队", Description: "产品和研发后台用户", Sort: 1},
{ID: model.TeamOperationsID, Name: "运营团队", Description: "运营后台用户;运营人员接口固定读取该团队", Sort: 2},
}
for _, definition := range teams {
team := model.Team{}
// 团队 ID 是前端“运营人员=运营团队 ID 2”契约的一部分已存在时同步展示字段不改主键。
result := s.db.Where("id = ?", definition.ID).Limit(1).Find(&team)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
if err := s.db.Create(&definition).Error; err != nil {
return err
}
continue
}
if err := s.db.Model(&team).Updates(map[string]any{
"parent_id": nil,
"name": definition.Name,
"description": definition.Description,
"sort": definition.Sort,
}).Error; err != nil {
return err
}
}
operationsTeamID := model.TeamOperationsID
childTeams := []model.Team{
{ParentID: &operationsTeamID, Name: "运营一部", Description: "运营团队子部门", Sort: 1},
{ParentID: &operationsTeamID, Name: "运营二部", Description: "运营团队子部门", Sort: 2},
{ParentID: &operationsTeamID, Name: "运营三部", Description: "运营团队子部门", Sort: 3},
}
for _, definition := range childTeams {
team := model.Team{}
// 子部门不固定主键,按名称做幂等维护;这样既满足默认部门存在,也不会覆盖线上已自增出来的团队 ID。
result := s.db.Where("name = ?", definition.Name).Limit(1).Find(&team)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
if err := s.db.Create(&definition).Error; err != nil {
return err
}
continue
}
if err := s.db.Model(&team).Updates(map[string]any{
"parent_id": definition.ParentID,
"description": definition.Description,
"sort": definition.Sort,
}).Error; err != nil {
return err
}
}
if err := s.db.Model(&model.User{}).
Where("team_id = ?", model.TeamOperationsID).
Update("team", "运营团队").Error; err != nil {
return err
}
if err := s.db.Model(&model.User{}).
Where("team_id IS NULL AND TRIM(COALESCE(team, '')) IN ?", []string{"运营", "运营团队", "平台运营", "平台运营团队", "平台运维", "平台运维团队", "运维", "运维团队"}).
Updates(map[string]any{"team_id": model.TeamOperationsID, "team": "运营团队"}).Error; err != nil {
return err
}
return nil
}
func (s *Store) seedRoles() error {
var permissions []model.Permission
if err := s.db.Find(&permissions).Error; err != nil {
@ -395,11 +481,13 @@ func (s *Store) seedUsers(cfg config.Config) error {
return err
}
operationsTeamID := model.TeamOperationsID
user := model.User{
Username: cfg.Bootstrap.Username,
Name: cfg.Bootstrap.Name,
PasswordHash: hash,
Team: cfg.Bootstrap.Team,
Team: "运营团队",
TeamID: &operationsTeamID,
Status: model.UserStatusActive,
MFAEnabled: false,
}
@ -535,6 +623,7 @@ func defaultRolePermissionCodes(code string) []string {
return []string{
"overview:view",
"user:view", "user:status",
"team:view", "team:create", "team:update",
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
@ -555,7 +644,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "money:view", "money:payment-link:create", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -572,11 +661,12 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"team:view",
"app-user:view",
"level-config:view",
"pretty-id:view",
@ -601,6 +691,7 @@ func defaultRolePermissionCodes(code string) []string {
"host-agency-policy:view",
"team-salary-policy:view",
"host-salary-settlement:view",
"host-withdrawal:view",
"agency:view",
"bd:view",
"coin-seller:view",
@ -642,6 +733,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
switch code {
case "ops-admin":
return []string{
"team:view", "team:create", "team:update",
"app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-whitelist:view", "room-whitelist:update", "room-robot:view", "room-robot:create", "room-robot:update",
"app-config:view", "app-config:update",
@ -661,7 +753,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "money:view", "money:payment-link:create", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -675,7 +767,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"agency-opening:view", "agency-opening:create", "agency-opening:update",
}
case "auditor", "readonly":
return []string{"level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
default:
return nil
}

View File

@ -0,0 +1,74 @@
package repository
import "testing"
func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
permissions := map[string]struct{}{}
for _, permission := range defaultPermissions {
permissions[permission.Code] = struct{}{}
}
for _, code := range []string{"money:view", "money:payment-link:create"} {
if _, ok := permissions[code]; ok {
t.Fatalf("legacy money permission %s must not be part of default seed", code)
}
}
for _, code := range []string{
"payment-temporary-link:create",
"finance:view",
"finance-application:create",
"finance-application:audit",
"finance-withdrawal:view",
"finance-operation:user-coin-credit",
"finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
"host-withdrawal:view",
} {
if _, ok := permissions[code]; !ok {
t.Fatalf("finance permission %s missing from default seed", code)
}
}
}
func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
opsPermissions := seedTestPermissionSet(defaultRolePermissionCodes("ops-admin"))
opsMigrationPermissions := seedTestPermissionSet(defaultRolePermissionMigrationCodes("ops-admin"))
for _, code := range []string{
"payment-temporary-link:create",
"finance-application:create",
"finance-operation:user-coin-credit",
"finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
"host-withdrawal:view",
} {
if _, ok := opsPermissions[code]; !ok {
t.Fatalf("ops-admin default permissions missing %s", code)
}
if _, ok := opsMigrationPermissions[code]; !ok {
t.Fatalf("ops-admin migration permissions missing %s", code)
}
}
if _, ok := opsPermissions["finance-application:audit"]; ok {
t.Fatal("ops-admin must not receive finance audit permission by default")
}
if _, ok := opsMigrationPermissions["finance-application:audit"]; ok {
t.Fatal("ops-admin migration must not append finance audit permission by default")
}
}
func seedTestPermissionSet(codes []string) map[string]struct{} {
out := make(map[string]struct{}, len(codes))
for _, code := range codes {
out[code] = struct{}{}
}
return out
}

View File

@ -0,0 +1,178 @@
package repository
import (
"errors"
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
)
func (s *Store) ListTeams(keyword string) ([]model.Team, error) {
query := s.db.Model(&model.Team{}).
Select("admin_teams.*, parent_team.name AS parent_name, COUNT(DISTINCT admin_users.id) AS user_count").
Joins("LEFT JOIN admin_teams parent_team ON parent_team.id = admin_teams.parent_id").
Joins("LEFT JOIN admin_teams child_team ON child_team.parent_id = admin_teams.id").
Joins("LEFT JOIN admin_users ON admin_users.team_id = admin_teams.id OR admin_users.team_id = child_team.id")
if keyword := strings.TrimSpace(keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("admin_teams.name LIKE ? OR CAST(admin_teams.id AS CHAR) LIKE ? OR admin_teams.description LIKE ? OR parent_team.name LIKE ?", like, like, like, like)
}
var teams []model.Team
err := query.
Group("admin_teams.id, admin_teams.parent_id, admin_teams.name, admin_teams.description, admin_teams.sort, admin_teams.created_at_ms, admin_teams.updated_at_ms, parent_team.name").
Order("CASE WHEN admin_teams.parent_id IS NULL THEN admin_teams.sort ELSE COALESCE(parent_team.sort, admin_teams.sort) END ASC").
Order("COALESCE(admin_teams.parent_id, admin_teams.id) ASC").
Order("CASE WHEN admin_teams.parent_id IS NULL THEN 0 ELSE 1 END ASC").
Order("admin_teams.sort ASC, admin_teams.id ASC").
Scan(&teams).Error
return teams, err
}
func (s *Store) GetTeam(id uint) (*model.Team, error) {
var team model.Team
if err := s.db.First(&team, id).Error; err != nil {
return nil, err
}
return &team, nil
}
func (s *Store) FindTeamByName(name string) (*model.Team, error) {
var team model.Team
if err := s.db.Where("name = ?", strings.TrimSpace(name)).First(&team).Error; err != nil {
return nil, err
}
return &team, nil
}
func (s *Store) CreateTeam(team *model.Team) error {
if err := normalizeTeam(team); err != nil {
return err
}
if err := s.validateTeamParent(s.db, 0, team.ParentID); err != nil {
return err
}
return s.db.Create(team).Error
}
func (s *Store) UpdateTeam(id uint, updates model.Team) (*model.Team, error) {
if id == 0 {
return nil, errors.New("team id is required")
}
if err := normalizeTeam(&updates); err != nil {
return nil, err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var team model.Team
if err := tx.First(&team, id).Error; err != nil {
return err
}
if err := s.validateTeamParent(tx, id, updates.ParentID); err != nil {
return err
}
if err := tx.Model(&team).Updates(map[string]any{
"parent_id": updates.ParentID,
"name": updates.Name,
"description": updates.Description,
"sort": updates.Sort,
}).Error; err != nil {
return err
}
// admin_users.team 是旧版字符串字段,仍被历史数据权限和导出兜底读取;团队重命名时同步维护,避免新旧字段长期分叉。
return tx.Model(&model.User{}).Where("team_id = ?", id).Update("team", updates.Name).Error
})
if err != nil {
return nil, err
}
return s.GetTeam(id)
}
func (s *Store) TeamAndChildIDs(id uint) ([]uint, error) {
if id == 0 {
return nil, errors.New("team id is required")
}
ids := []uint{id}
var childIDs []uint
if err := s.db.Model(&model.Team{}).Where("parent_id = ?", id).Order("sort ASC, id ASC").Pluck("id", &childIDs).Error; err != nil {
return nil, err
}
ids = append(ids, childIDs...)
return ids, nil
}
func (s *Store) NormalizeUserTeam(teamID *uint, legacyName string) (*uint, string, error) {
name := strings.TrimSpace(legacyName)
if teamID != nil {
if *teamID == 0 {
return nil, "", nil
}
team, err := s.GetTeam(*teamID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", errors.New("团队不存在")
}
if err != nil {
return nil, "", err
}
id := team.ID
return &id, team.Name, nil
}
if name == "" {
return nil, "", nil
}
team, err := s.FindTeamByName(name)
if err == nil {
id := team.ID
return &id, team.Name, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
// 老客户端可能仍提交自由文本团队;无法匹配到团队表时保留文本,不阻断兼容请求。
return nil, name, nil
}
return nil, "", err
}
func normalizeTeam(team *model.Team) error {
team.Name = strings.TrimSpace(team.Name)
team.Description = strings.TrimSpace(team.Description)
if team.ParentID != nil && *team.ParentID == 0 {
team.ParentID = nil
}
if team.Name == "" {
return errors.New("团队名称不能为空")
}
return nil
}
func (s *Store) validateTeamParent(tx *gorm.DB, currentID uint, parentID *uint) error {
if parentID == nil {
return nil
}
if currentID > 0 && *parentID == currentID {
return errors.New("子部门不能挂到自己下面")
}
var parent model.Team
if err := tx.First(&parent, *parentID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("父级团队不存在")
}
return err
}
if parent.ParentID != nil {
return errors.New("暂不支持三级团队")
}
if currentID > 0 {
var childCount int64
if err := tx.Model(&model.Team{}).Where("parent_id = ?", currentID).Count(&childCount).Error; err != nil {
return err
}
// 已有子部门的团队如果再被设为子部门,会形成三级结构;这里提前阻断,保证团队树始终只有团队和子部门两层。
if childCount > 0 {
return errors.New("已有子部门的团队不能设为子部门")
}
}
return nil
}

View File

@ -0,0 +1,70 @@
package repository
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"hyapp-admin-server/internal/model"
)
func TestNormalizeUserTeamUsesTeamID(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT \\* FROM `admin_teams` WHERE `admin_teams`.`id` = \\? ORDER BY `admin_teams`.`id` LIMIT \\?").
WithArgs(model.TeamOperationsID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "description", "sort", "created_at_ms", "updated_at_ms"}).
AddRow(model.TeamOperationsID, "运营团队", "运营后台用户", 2, int64(1700000000000), int64(1700000000000)))
inputTeamID := model.TeamOperationsID
teamID, teamName, err := store.NormalizeUserTeam(&inputTeamID, "平台运营")
if err != nil {
t.Fatalf("normalize team by id failed: %v", err)
}
if teamID == nil || *teamID != model.TeamOperationsID || teamName != "运营团队" {
t.Fatalf("normalized team mismatch: id=%v name=%q", teamID, teamName)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestNormalizeUserTeamKeepsLegacyNameWhenTeamMissing(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT \\* FROM `admin_teams` WHERE name = \\? ORDER BY `admin_teams`.`id` LIMIT \\?").
WithArgs("外包团队", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "description", "sort", "created_at_ms", "updated_at_ms"}))
teamID, teamName, err := store.NormalizeUserTeam(nil, " 外包团队 ")
if err != nil {
t.Fatalf("normalize legacy team failed: %v", err)
}
if teamID != nil || teamName != "外包团队" {
t.Fatalf("legacy team should be preserved, got id=%v name=%q", teamID, teamName)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestTeamAndChildIDsReturnsParentAndChildren(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT `id` FROM `admin_teams` WHERE parent_id = \\? ORDER BY sort ASC, id ASC").
WithArgs(model.TeamOperationsID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(11).AddRow(12))
ids, err := store.TeamAndChildIDs(model.TeamOperationsID)
if err != nil {
t.Fatalf("load team ids failed: %v", err)
}
if len(ids) != 3 || ids[0] != model.TeamOperationsID || ids[1] != 11 || ids[2] != 12 {
t.Fatalf("team ids mismatch: %#v", ids)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}

View File

@ -0,0 +1,105 @@
package repository
import (
"errors"
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrWithdrawalApplicationAlreadyAudited = errors.New("withdrawal application already audited")
type WithdrawalApplicationListOptions struct {
Page int
PageSize int
AppCode string
Keyword string
}
type WithdrawalApplicationAuditInput struct {
Decision string
ApproverUserID uint
ApproverName string
AuditRemark string
AuditImageURL string
AuditCommandID string
AuditTransactionID string
ApprovedAtMS int64
}
func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) {
var application model.UserWithdrawalApplication
if err := s.db.First(&application, id).Error; err != nil {
return nil, err
}
return &application, nil
}
func (s *Store) ListWithdrawalApplications(options WithdrawalApplicationListOptions) ([]model.UserWithdrawalApplication, int64, error) {
page, pageSize := normalizePage(options.Page, options.PageSize)
query := s.db.Model(&model.UserWithdrawalApplication{})
query = applyWithdrawalApplicationFilters(query, options)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var applications []model.UserWithdrawalApplication
// 提现申请是财务审核场景默认按最新申请优先展示id 作为同毫秒写入时的稳定次序,避免分页翻页时记录抖动。
err := query.
Order("created_at_ms DESC, id DESC").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&applications).Error
return applications, total, err
}
func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationAuditInput) (*model.UserWithdrawalApplication, error) {
if id == 0 {
return nil, errors.New("withdrawal application id is required")
}
if input.Decision != model.WithdrawalApplicationStatusApproved && input.Decision != model.WithdrawalApplicationStatusRejected {
return nil, errors.New("withdrawal application decision is invalid")
}
var application model.UserWithdrawalApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
// 审核只能从 pending 进入终态;行锁阻止两个财务同时覆盖同一张提现单的执行结果。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&application, id).Error; err != nil {
return err
}
if application.Status != model.WithdrawalApplicationStatusPending {
return ErrWithdrawalApplicationAlreadyAudited
}
return tx.Model(&application).Updates(map[string]any{
"status": input.Decision,
"approver_user_id": input.ApproverUserID,
"approver_name": strings.TrimSpace(input.ApproverName),
"audit_remark": strings.TrimSpace(input.AuditRemark),
"audit_image_url": strings.TrimSpace(input.AuditImageURL),
"audit_command_id": strings.TrimSpace(input.AuditCommandID),
"audit_transaction_id": strings.TrimSpace(input.AuditTransactionID),
"approved_at_ms": input.ApprovedAtMS,
"updated_at_ms": input.ApprovedAtMS,
}).Error
})
if err != nil {
return nil, err
}
return s.GetWithdrawalApplication(id)
}
func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB {
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
query = query.Where("app_code = ?", appCode)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
// 关键词面向财务排查,覆盖用户、收款方式、收款地址和审批人;不做金额模糊,避免 decimal 隐式转换拖慢常规查询。
query = query.Where("user_id LIKE ? OR withdraw_method LIKE ? OR withdraw_address LIKE ? OR approver_name LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like)
}
return query
}

View File

@ -0,0 +1,52 @@
package repository
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestListWithdrawalApplicationsFiltersAndSorts(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
like := "%TRC20%"
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`").
WithArgs("lalu", like, like, like, like, like).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`").
WithArgs("lalu", like, like, like, like, like, 50, 50).
WillReturnRows(sqlmock.NewRows([]string{
"id",
"app_code",
"user_id",
"withdraw_amount",
"withdraw_method",
"withdraw_address",
"status",
"approver_user_id",
"approver_name",
"approved_at_ms",
"created_at_ms",
"updated_at_ms",
}).AddRow(3, "lalu", "10003", "22.50", "USDT-TRC20", "addr-3", "approved", 9, "财务", int64(1700000200000), int64(1700000100000), int64(1700000200000)))
items, total, err := store.ListWithdrawalApplications(WithdrawalApplicationListOptions{
AppCode: " lalu ",
Keyword: " TRC20 ",
Page: 2,
PageSize: 50,
})
if err != nil {
t.Fatalf("list withdrawal applications failed: %v", err)
}
if total != 2 || len(items) != 1 {
t.Fatalf("withdrawal list page mismatch: total=%d items=%+v", total, items)
}
if items[0].AppCode != "lalu" || items[0].UserID != "10003" || items[0].WithdrawMethod != "USDT-TRC20" {
t.Fatalf("withdrawal row mismatch: %+v", items[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}

View File

@ -18,6 +18,8 @@ import (
"hyapp-admin-server/internal/modules/cumulativerechargereward"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/financeapplication"
"hyapp-admin-server/internal/modules/financewithdrawal"
"hyapp-admin-server/internal/modules/firstrechargereward"
"hyapp-admin-server/internal/modules/fullservernotice"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
@ -26,6 +28,7 @@ import (
"hyapp-admin-server/internal/modules/hostagencypolicy"
"hyapp-admin-server/internal/modules/hostorg"
"hyapp-admin-server/internal/modules/hostsalarysettlement"
"hyapp-admin-server/internal/modules/hostwithdrawal"
"hyapp-admin-server/internal/modules/inviteactivityreward"
"hyapp-admin-server/internal/modules/job"
"hyapp-admin-server/internal/modules/levelconfig"
@ -45,6 +48,7 @@ import (
"hyapp-admin-server/internal/modules/roomturnoverreward"
"hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/sevendaycheckin"
"hyapp-admin-server/internal/modules/team"
"hyapp-admin-server/internal/modules/teamsalarypolicy"
"hyapp-admin-server/internal/modules/teamsalarysettlement"
"hyapp-admin-server/internal/modules/upload"
@ -75,6 +79,8 @@ type Handlers struct {
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceWithdrawal *financewithdrawal.Handler
FullServerNotice *fullservernotice.Handler
Game *gamemanagement.Handler
GiftDiamond *giftdiamond.Handler
@ -82,6 +88,7 @@ type Handlers struct {
HostAgencyPolicy *hostagencypolicy.Handler
HostOrg *hostorg.Handler
HostSalarySettlement *hostsalarysettlement.Handler
HostWithdrawal *hostwithdrawal.Handler
InviteActivityReward *inviteactivityreward.Handler
Job *job.Handler
LevelConfig *levelconfig.Handler
@ -101,6 +108,7 @@ type Handlers struct {
RoomTurnoverReward *roomturnoverreward.Handler
Search *search.Handler
SevenDayCheckIn *sevendaycheckin.Handler
Team *team.Handler
TeamSalaryPolicy *teamsalarypolicy.Handler
TeamSalarySettlement *teamsalarysettlement.Handler
Upload *upload.Handler
@ -140,6 +148,8 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
dailytask.RegisterRoutes(protected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
regionblock.RegisterRoutes(protected, h.RegionBlock)
@ -153,6 +163,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg)
hostwithdrawal.RegisterRoutes(protected, h.HostWithdrawal)
inviteactivityreward.RegisterRoutes(protected, h.InviteActivityReward)
audit.RegisterRoutes(protected, h.Audit)
payment.RegisterRoutes(protected, h.Payment)
@ -163,6 +174,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
upload.RegisterRoutes(protected, h.Upload)
search.RegisterRoutes(protected, h.Search)
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
team.RegisterRoutes(protected, h.Team)
teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy)
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)

View File

@ -0,0 +1,131 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_teams (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
parent_id BIGINT UNSIGNED NULL COMMENT '父级团队 ID',
name VARCHAR(80) NOT NULL UNIQUE COMMENT '团队名称',
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '团队说明',
sort INT NOT NULL DEFAULT 0 COMMENT '排序值',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
INDEX idx_admin_teams_parent_sort (parent_id, sort, id),
INDEX idx_admin_teams_sort (sort, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台团队表';
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_teams' AND COLUMN_NAME = 'parent_id') = 0,
'ALTER TABLE admin_teams ADD COLUMN parent_id BIGINT UNSIGNED NULL COMMENT ''父级团队 ID'' AFTER id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_teams' AND INDEX_NAME = 'idx_admin_teams_parent_sort') = 0,
'ALTER TABLE admin_teams ADD INDEX idx_admin_teams_parent_sort (parent_id, sort, id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO admin_teams (id, parent_id, name, description, sort, created_at_ms, updated_at_ms) VALUES
(1, NULL, '产研团队', '产品和研发后台用户', 1, @now_ms, @now_ms),
(2, NULL, '运营团队', '运营后台用户;运营人员接口固定读取该团队', 2, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
name = VALUES(name),
description = VALUES(description),
sort = VALUES(sort),
updated_at_ms = @now_ms;
INSERT INTO admin_teams (parent_id, name, description, sort, created_at_ms, updated_at_ms) VALUES
(2, '运营一部', '运营团队子部门', 1, @now_ms, @now_ms),
(2, '运营二部', '运营团队子部门', 2, @now_ms, @now_ms),
(2, '运营三部', '运营团队子部门', 3, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
description = VALUES(description),
sort = VALUES(sort),
updated_at_ms = @now_ms;
-- 老环境的 admin_users 只有 team 文本字段;新增 team_id 后保留文本字段用于历史兼容和数据权限兜底。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_users' AND COLUMN_NAME = 'team_id') = 0,
'ALTER TABLE admin_users ADD COLUMN team_id BIGINT UNSIGNED NULL COMMENT ''后台团队 ID'' AFTER team',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_users' AND INDEX_NAME = 'idx_admin_users_team_id') = 0,
'ALTER TABLE admin_users ADD INDEX idx_admin_users_team_id (team_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 历史环境可能已经写过平台运营或平台运维等文本;这些都只作为迁移兼容来源,目标统一为运营团队 ID=2。
UPDATE admin_users
SET team_id = 2,
team = '运营团队',
updated_at_ms = @now_ms
WHERE team_id IS NULL
AND TRIM(COALESCE(team, '')) IN ('运营', '运营团队', '平台运营', '平台运营团队', '平台运维', '平台运维团队', '运维', '运维团队');
UPDATE admin_users
SET team = '运营团队',
updated_at_ms = @now_ms
WHERE team_id = 2
AND TRIM(COALESCE(team, '')) <> '运营团队';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('团队查看', 'team:view', 'menu', '查看后台团队配置和团队成员', @now_ms, @now_ms),
('团队创建', 'team:create', 'button', '创建后台团队', @now_ms, @now_ms),
('团队更新', 'team:update', 'button', '更新后台团队名称、说明和排序', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '团队配置', 'system-teams', '/system/teams', 'team', 'team:view', 36, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'system'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('team:view', 'team:create', 'team:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'ops-admin'
AND admin_permission.code IN ('team:view', 'team:create', 'team:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'team:view';

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