merge main into test
This commit is contained in:
commit
fedd4ea416
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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
@ -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);
|
||||
}
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 的基础配置/种子数据,不新建一套服务,也不复制业务代码。
|
||||
|
||||
|
||||
94
docs/数据大屏统计口径.md
Normal file
94
docs/数据大屏统计口径.md
Normal 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`。
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
- 每个用户有一个可展示、可复制的永久邀请码。
|
||||
- 新用户注册或完成资料时可以选填邀请码。
|
||||
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
|
||||
- 非空邀请码必须能解析到同一 `app_code`、同一 `region_id` 下的有效邀请人。
|
||||
- 邀请关系一旦绑定不可被客户端修改。
|
||||
- 邀请关系必须落 MySQL;Redis 只能做短 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` |
|
||||
| 已绑定关系后再次提交邀请码 | 原关系不变 |
|
||||
|
||||
@ -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 扣费成功后产生。统计服务不读取钱包流水来反推礼物消耗。
|
||||
|
||||
12
go.work.sum
12
go.work.sum
@ -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
174
pkg/dingtalkrobot/client.go
Normal 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"`
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
|
||||
|
||||
@ -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 {
|
||||
|
||||
12
scripts/mysql/052_wallet_entries_asset_user_time_index.sql
Normal file
12
scripts/mysql/052_wallet_entries_asset_user_time_index.sql
Normal 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;
|
||||
2
scripts/mysql/053_external_recharge_wallet_tx_index.sql
Normal file
2
scripts/mysql/053_external_recharge_wallet_tx_index.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE external_recharge_orders
|
||||
ADD INDEX idx_external_recharge_orders_wallet_tx (app_code, wallet_transaction_id);
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
169
server/admin/internal/integration/dingtalk/client.go
Normal file
169
server/admin/internal/integration/dingtalk/client.go
Normal 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"`
|
||||
}
|
||||
75
server/admin/internal/integration/dingtalk/client_test.go
Normal file
75
server/admin/internal/integration/dingtalk/client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -72,16 +72,28 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list
|
||||
}
|
||||
|
||||
// 后台只做只读查询:金币流水事实仍以 wallet_entries 追加分录为准,不在 admin 库冗余账务状态。
|
||||
// 普通打开列表时不带 biz_type,total 只依赖 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, `
|
||||
|
||||
697
server/admin/internal/modules/dashboard/aslan_dashboard.go
Normal file
697
server/admin/internal/modules/dashboard/aslan_dashboard.go
Normal 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, ®ions); 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, ®ions); 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 字段"},
|
||||
}
|
||||
}
|
||||
125
server/admin/internal/modules/dashboard/aslan_dashboard_test.go
Normal file
125
server/admin/internal/modules/dashboard/aslan_dashboard_test.go
Normal 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")
|
||||
}
|
||||
823
server/admin/internal/modules/dashboard/external_dashboard.go
Normal file
823
server/admin/internal/modules/dashboard/external_dashboard.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("", 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)
|
||||
}
|
||||
@ -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{
|
||||
"### 财务申请待审核",
|
||||
"- APP:lalu",
|
||||
"- 操作:增加币商金币",
|
||||
"- 目标用户ID:10001",
|
||||
"- 金币数量:1000",
|
||||
"- 充值金额 $ 10.50",
|
||||
"- 申请人:ops(7)",
|
||||
"- 发起时间:2026-07-01 09:02:03",
|
||||
"- 后台地址:https://admin-acc.global-interaction.com/finance/",
|
||||
"- 凭证文字:bank order abc",
|
||||
"- 凭证图片:",
|
||||
"",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
180
server/admin/internal/modules/financeapplication/handler.go
Normal file
180
server/admin/internal/modules/financeapplication/handler.go
Normal 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)
|
||||
}
|
||||
16
server/admin/internal/modules/financeapplication/request.go
Normal file
16
server/admin/internal/modules/financeapplication/request.go
Normal 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"`
|
||||
}
|
||||
68
server/admin/internal/modules/financeapplication/response.go
Normal file
68
server/admin/internal/modules/financeapplication/response.go
Normal 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
|
||||
}
|
||||
19
server/admin/internal/modules/financeapplication/routes.go
Normal file
19
server/admin/internal/modules/financeapplication/routes.go
Normal 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)
|
||||
}
|
||||
623
server/admin/internal/modules/financeapplication/service.go
Normal file
623
server/admin/internal/modules/financeapplication/service.go
Normal 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 = ¬ified
|
||||
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_amount,recharge_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
|
||||
}
|
||||
269
server/admin/internal/modules/financeapplication/service_test.go
Normal file
269
server/admin/internal/modules/financeapplication/service_test.go
Normal 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},
|
||||
}
|
||||
}
|
||||
117
server/admin/internal/modules/financewithdrawal/handler.go
Normal file
117
server/admin/internal/modules/financewithdrawal/handler.go
Normal 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 ""
|
||||
}
|
||||
57
server/admin/internal/modules/financewithdrawal/response.go
Normal file
57
server/admin/internal/modules/financewithdrawal/response.go
Normal 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
|
||||
}
|
||||
16
server/admin/internal/modules/financewithdrawal/routes.go
Normal file
16
server/admin/internal/modules/financewithdrawal/routes.go
Normal 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)
|
||||
}
|
||||
250
server/admin/internal/modules/financewithdrawal/service.go
Normal file
250
server/admin/internal/modules/financewithdrawal/service.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
34
server/admin/internal/modules/hostwithdrawal/dto.go
Normal file
34
server/admin/internal/modules/hostwithdrawal/dto.go
Normal 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"`
|
||||
}
|
||||
86
server/admin/internal/modules/hostwithdrawal/handler.go
Normal file
86
server/admin/internal/modules/hostwithdrawal/handler.go
Normal 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
|
||||
}
|
||||
119
server/admin/internal/modules/hostwithdrawal/request.go
Normal file
119
server/admin/internal/modules/hostwithdrawal/request.go
Normal 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
|
||||
}
|
||||
15
server/admin/internal/modules/hostwithdrawal/routes.go
Normal file
15
server/admin/internal/modules/hostwithdrawal/routes.go
Normal 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)
|
||||
}
|
||||
603
server/admin/internal/modules/hostwithdrawal/service.go
Normal file
603
server/admin/internal/modules/hostwithdrawal/service.go
Normal 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
|
||||
}
|
||||
141
server/admin/internal/modules/hostwithdrawal/service_test.go
Normal file
141
server/admin/internal/modules/hostwithdrawal/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 组装支付后台 handler;wallet 负责账单和商品事实,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) {
|
||||
|
||||
@ -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{{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
350
server/admin/internal/modules/payment/money_region_source.go
Normal file
350
server/admin/internal/modules/payment/money_region_source.go
Normal 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 ObjectId;admin 授权表只能存 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
45
server/admin/internal/modules/shared/context_test.go
Normal file
45
server/admin/internal/modules/shared/context_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
86
server/admin/internal/modules/team/handler.go
Normal file
86
server/admin/internal/modules/team/handler.go
Normal 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})
|
||||
}
|
||||
8
server/admin/internal/modules/team/request.go
Normal file
8
server/admin/internal/modules/team/request.go
Normal 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"`
|
||||
}
|
||||
25
server/admin/internal/modules/team/response.go
Normal file
25
server/admin/internal/modules/team/response.go
Normal 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,
|
||||
}
|
||||
}
|
||||
14
server/admin/internal/modules/team/routes.go
Normal file
14
server/admin/internal/modules/team/routes.go
Normal 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)
|
||||
}
|
||||
66
server/admin/internal/modules/team/service.go
Normal file
66
server/admin/internal/modules/team/service.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
74
server/admin/internal/repository/seed_test.go
Normal file
74
server/admin/internal/repository/seed_test.go
Normal 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
|
||||
}
|
||||
178
server/admin/internal/repository/team_repository.go
Normal file
178
server/admin/internal/repository/team_repository.go
Normal 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
|
||||
}
|
||||
70
server/admin/internal/repository/team_repository_test.go
Normal file
70
server/admin/internal/repository/team_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
131
server/admin/migrations/065_admin_teams.sql
Normal file
131
server/admin/migrations/065_admin_teams.sql
Normal 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
Loading…
x
Reference in New Issue
Block a user