完善主播侧
This commit is contained in:
parent
199ced57fc
commit
d9fdb70016
File diff suppressed because it is too large
Load Diff
@ -27,8 +27,10 @@ message DebitGiftResponse {
|
|||||||
int64 gift_point_added = 4;
|
int64 gift_point_added = 4;
|
||||||
int64 heat_value = 5;
|
int64 heat_value = 5;
|
||||||
string price_version = 6;
|
string price_version = 6;
|
||||||
// balance_after 是 sender COIN available_amount 账后余额。
|
// balance_after 是 sender 被扣费资产 available_amount 账后余额。
|
||||||
int64 balance_after = 7;
|
int64 balance_after = 7;
|
||||||
|
string charge_asset_type = 8;
|
||||||
|
int64 charge_amount = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssetBalance 是用户某类资产的余额投影。
|
// AssetBalance 是用户某类资产的余额投影。
|
||||||
@ -168,6 +170,11 @@ message GiftConfig {
|
|||||||
int64 created_at_ms = 15;
|
int64 created_at_ms = 15;
|
||||||
int64 updated_at_ms = 16;
|
int64 updated_at_ms = 16;
|
||||||
repeated int64 region_ids = 17;
|
repeated int64 region_ids = 17;
|
||||||
|
string gift_type_code = 18;
|
||||||
|
string charge_asset_type = 19;
|
||||||
|
int64 effective_from_ms = 20;
|
||||||
|
int64 effective_to_ms = 21;
|
||||||
|
repeated string effect_types = 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UserResourceEntitlement {
|
message UserResourceEntitlement {
|
||||||
@ -399,6 +406,11 @@ message CreateGiftConfigRequest {
|
|||||||
int64 effective_at_ms = 13;
|
int64 effective_at_ms = 13;
|
||||||
int64 operator_user_id = 14;
|
int64 operator_user_id = 14;
|
||||||
repeated int64 region_ids = 15;
|
repeated int64 region_ids = 15;
|
||||||
|
string gift_type_code = 16;
|
||||||
|
string charge_asset_type = 17;
|
||||||
|
int64 effective_from_ms = 18;
|
||||||
|
int64 effective_to_ms = 19;
|
||||||
|
repeated string effect_types = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpdateGiftConfigRequest {
|
message UpdateGiftConfigRequest {
|
||||||
@ -417,6 +429,11 @@ message UpdateGiftConfigRequest {
|
|||||||
int64 effective_at_ms = 13;
|
int64 effective_at_ms = 13;
|
||||||
int64 operator_user_id = 14;
|
int64 operator_user_id = 14;
|
||||||
repeated int64 region_ids = 15;
|
repeated int64 region_ids = 15;
|
||||||
|
string gift_type_code = 16;
|
||||||
|
string charge_asset_type = 17;
|
||||||
|
int64 effective_from_ms = 18;
|
||||||
|
int64 effective_to_ms = 19;
|
||||||
|
repeated string effect_types = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetGiftConfigStatusRequest {
|
message SetGiftConfigStatusRequest {
|
||||||
@ -495,6 +512,47 @@ message ListResourceGrantsResponse {
|
|||||||
int64 total = 2;
|
int64 total = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message RechargeBill {
|
||||||
|
string app_code = 1;
|
||||||
|
string transaction_id = 2;
|
||||||
|
string command_id = 3;
|
||||||
|
string recharge_type = 4;
|
||||||
|
string status = 5;
|
||||||
|
string external_ref = 6;
|
||||||
|
int64 user_id = 7;
|
||||||
|
int64 seller_user_id = 8;
|
||||||
|
int64 seller_region_id = 9;
|
||||||
|
int64 target_region_id = 10;
|
||||||
|
int64 policy_id = 11;
|
||||||
|
string policy_version = 12;
|
||||||
|
string currency_code = 13;
|
||||||
|
int64 coin_amount = 14;
|
||||||
|
int64 usd_minor_amount = 15;
|
||||||
|
int64 exchange_coin_amount = 16;
|
||||||
|
int64 exchange_usd_minor_amount = 17;
|
||||||
|
int64 created_at_ms = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListRechargeBillsRequest {
|
||||||
|
string request_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
int64 user_id = 3;
|
||||||
|
int64 seller_user_id = 4;
|
||||||
|
int64 region_id = 5;
|
||||||
|
string recharge_type = 6;
|
||||||
|
string status = 7;
|
||||||
|
string keyword = 8;
|
||||||
|
int64 start_at_ms = 9;
|
||||||
|
int64 end_at_ms = 10;
|
||||||
|
int32 page = 11;
|
||||||
|
int32 page_size = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListRechargeBillsResponse {
|
||||||
|
repeated RechargeBill bills = 1;
|
||||||
|
int64 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||||
service WalletService {
|
service WalletService {
|
||||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||||
@ -520,4 +578,5 @@ service WalletService {
|
|||||||
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
||||||
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
||||||
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
|
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
|
||||||
|
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v5.29.2
|
// - protoc v7.34.1
|
||||||
// source: proto/wallet/v1/wallet.proto
|
// source: proto/wallet/v1/wallet.proto
|
||||||
|
|
||||||
package walletv1
|
package walletv1
|
||||||
@ -42,6 +42,7 @@ const (
|
|||||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||||
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
||||||
|
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WalletServiceClient is the client API for WalletService service.
|
// WalletServiceClient is the client API for WalletService service.
|
||||||
@ -73,6 +74,7 @@ type WalletServiceClient interface {
|
|||||||
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
||||||
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
||||||
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
|
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
|
||||||
|
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type walletServiceClient struct {
|
type walletServiceClient struct {
|
||||||
@ -313,6 +315,16 @@ func (c *walletServiceClient) ListResourceGrants(ctx context.Context, in *ListRe
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListRechargeBillsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, WalletService_ListRechargeBills_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WalletServiceServer is the server API for WalletService service.
|
// WalletServiceServer is the server API for WalletService service.
|
||||||
// All implementations must embed UnimplementedWalletServiceServer
|
// All implementations must embed UnimplementedWalletServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@ -342,6 +354,7 @@ type WalletServiceServer interface {
|
|||||||
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
||||||
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
||||||
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
|
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
|
||||||
|
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||||
mustEmbedUnimplementedWalletServiceServer()
|
mustEmbedUnimplementedWalletServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,73 +366,76 @@ type WalletServiceServer interface {
|
|||||||
type UnimplementedWalletServiceServer struct{}
|
type UnimplementedWalletServiceServer struct{}
|
||||||
|
|
||||||
func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) {
|
func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method DebitGift not implemented")
|
return nil, status.Error(codes.Unimplemented, "method DebitGift not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) {
|
func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetBalances not implemented")
|
return nil, status.Error(codes.Unimplemented, "method GetBalances not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
|
func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method AdminCreditAsset not implemented")
|
return nil, status.Error(codes.Unimplemented, "method AdminCreditAsset not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
|
func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
|
return nil, status.Error(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) {
|
func (UnimplementedWalletServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListResources not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListResources not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, error) {
|
func (UnimplementedWalletServiceServer) GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetResource not implemented")
|
return nil, status.Error(codes.Unimplemented, "method GetResource not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) CreateResource(context.Context, *CreateResourceRequest) (*ResourceResponse, error) {
|
func (UnimplementedWalletServiceServer) CreateResource(context.Context, *CreateResourceRequest) (*ResourceResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateResource not implemented")
|
return nil, status.Error(codes.Unimplemented, "method CreateResource not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) UpdateResource(context.Context, *UpdateResourceRequest) (*ResourceResponse, error) {
|
func (UnimplementedWalletServiceServer) UpdateResource(context.Context, *UpdateResourceRequest) (*ResourceResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateResource not implemented")
|
return nil, status.Error(codes.Unimplemented, "method UpdateResource not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) SetResourceStatus(context.Context, *SetResourceStatusRequest) (*ResourceResponse, error) {
|
func (UnimplementedWalletServiceServer) SetResourceStatus(context.Context, *SetResourceStatusRequest) (*ResourceResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method SetResourceStatus not implemented")
|
return nil, status.Error(codes.Unimplemented, "method SetResourceStatus not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) ListResourceGroups(context.Context, *ListResourceGroupsRequest) (*ListResourceGroupsResponse, error) {
|
func (UnimplementedWalletServiceServer) ListResourceGroups(context.Context, *ListResourceGroupsRequest) (*ListResourceGroupsResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListResourceGroups not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListResourceGroups not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) GetResourceGroup(context.Context, *GetResourceGroupRequest) (*GetResourceGroupResponse, error) {
|
func (UnimplementedWalletServiceServer) GetResourceGroup(context.Context, *GetResourceGroupRequest) (*GetResourceGroupResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetResourceGroup not implemented")
|
return nil, status.Error(codes.Unimplemented, "method GetResourceGroup not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) CreateResourceGroup(context.Context, *CreateResourceGroupRequest) (*ResourceGroupResponse, error) {
|
func (UnimplementedWalletServiceServer) CreateResourceGroup(context.Context, *CreateResourceGroupRequest) (*ResourceGroupResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateResourceGroup not implemented")
|
return nil, status.Error(codes.Unimplemented, "method CreateResourceGroup not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) UpdateResourceGroup(context.Context, *UpdateResourceGroupRequest) (*ResourceGroupResponse, error) {
|
func (UnimplementedWalletServiceServer) UpdateResourceGroup(context.Context, *UpdateResourceGroupRequest) (*ResourceGroupResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateResourceGroup not implemented")
|
return nil, status.Error(codes.Unimplemented, "method UpdateResourceGroup not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error) {
|
func (UnimplementedWalletServiceServer) SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method SetResourceGroupStatus not implemented")
|
return nil, status.Error(codes.Unimplemented, "method SetResourceGroupStatus not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error) {
|
func (UnimplementedWalletServiceServer) ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListGiftConfigs not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListGiftConfigs not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error) {
|
func (UnimplementedWalletServiceServer) CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateGiftConfig not implemented")
|
return nil, status.Error(codes.Unimplemented, "method CreateGiftConfig not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error) {
|
func (UnimplementedWalletServiceServer) UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateGiftConfig not implemented")
|
return nil, status.Error(codes.Unimplemented, "method UpdateGiftConfig not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error) {
|
func (UnimplementedWalletServiceServer) SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method SetGiftConfigStatus not implemented")
|
return nil, status.Error(codes.Unimplemented, "method SetGiftConfigStatus not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error) {
|
func (UnimplementedWalletServiceServer) GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GrantResource not implemented")
|
return nil, status.Error(codes.Unimplemented, "method GrantResource not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) {
|
func (UnimplementedWalletServiceServer) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GrantResourceGroup not implemented")
|
return nil, status.Error(codes.Unimplemented, "method GrantResourceGroup not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
|
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListUserResources not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListUserResources not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error) {
|
func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method EquipUserResource not implemented")
|
return nil, status.Error(codes.Unimplemented, "method EquipUserResource not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
|
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListResourceGrants not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
|
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
|
||||||
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
|
||||||
@ -432,7 +448,7 @@ type UnsafeWalletServiceServer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RegisterWalletServiceServer(s grpc.ServiceRegistrar, srv WalletServiceServer) {
|
func RegisterWalletServiceServer(s grpc.ServiceRegistrar, srv WalletServiceServer) {
|
||||||
// If the following call pancis, it indicates UnimplementedWalletServiceServer was
|
// If the following call panics, it indicates UnimplementedWalletServiceServer was
|
||||||
// embedded by pointer and is nil. This will cause panics if an
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
// unimplemented method is ever invoked, so we test this at initialization
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
// time to prevent it from happening at runtime later due to I/O.
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
@ -856,6 +872,24 @@ func _WalletService_ListResourceGrants_Handler(srv interface{}, ctx context.Cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListRechargeBillsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(WalletServiceServer).ListRechargeBills(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: WalletService_ListRechargeBills_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(WalletServiceServer).ListRechargeBills(ctx, req.(*ListRechargeBillsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
|
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@ -955,6 +989,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListResourceGrants",
|
MethodName: "ListResourceGrants",
|
||||||
Handler: _WalletService_ListResourceGrants_Handler,
|
Handler: _WalletService_ListResourceGrants_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListRechargeBills",
|
||||||
|
Handler: _WalletService_ListRechargeBills_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "proto/wallet/v1/wallet.proto",
|
Metadata: "proto/wallet/v1/wallet.proto",
|
||||||
|
|||||||
@ -1307,9 +1307,15 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
|
- name: region_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 区域 ID;返回该区域和 GLOBAL 兜底礼物。
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 返回 active 礼物配置;礼物资源必须来自 `resource_type=gift`。
|
description: 返回当前 active、区域可用且在有效期内的礼物配置;礼物资源必须来自 `resource_type=gift`。
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/GiftListEnvelope"
|
$ref: "#/definitions/GiftListEnvelope"
|
||||||
"400":
|
"400":
|
||||||
@ -2140,6 +2146,24 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
price_version:
|
price_version:
|
||||||
type: string
|
type: string
|
||||||
|
gift_type_code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- normal
|
||||||
|
- cp
|
||||||
|
- lucky
|
||||||
|
- super_lucky
|
||||||
|
- exclusive
|
||||||
|
- noble
|
||||||
|
- flag
|
||||||
|
- activity
|
||||||
|
- magic
|
||||||
|
- custom
|
||||||
|
charge_asset_type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- COIN
|
||||||
|
- DIAMOND
|
||||||
coin_price:
|
coin_price:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
@ -2149,6 +2173,22 @@ definitions:
|
|||||||
heat_value:
|
heat_value:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
effective_from_ms:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 0 表示不限制开始时间。
|
||||||
|
effective_to_ms:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 0 表示不限制结束时间。
|
||||||
|
effect_types:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- animation
|
||||||
|
- music
|
||||||
|
- global_broadcast
|
||||||
region_ids:
|
region_ids:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|||||||
@ -134,7 +134,17 @@ definitions:
|
|||||||
coin_spent:
|
coin_spent:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: 本次实际扣减的 COIN 数量。
|
description: 兼容旧调用方的扣费数量;新调用方应优先读取 charge_asset_type 和 charge_amount。
|
||||||
|
charge_asset_type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- COIN
|
||||||
|
- DIAMOND
|
||||||
|
description: 本次实际扣减的资产类型。
|
||||||
|
charge_amount:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 本次实际扣减的资产数量。
|
||||||
gift_point_added:
|
gift_point_added:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
@ -149,7 +159,7 @@ definitions:
|
|||||||
balance_after:
|
balance_after:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: 扣费后的发送方余额。
|
description: 扣费后的发送方被扣费资产余额。
|
||||||
HealthCheckRequest:
|
HealthCheckRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
- 资源组只表达“打包集合”,赠送资源组时按当时组内容展开成多条资源赠送事实;组内可以直接配置 `COIN` 和 `DIAMOND` 钱包资产数量。
|
- 资源组只表达“打包集合”,赠送资源组时按当时组内容展开成多条资源赠送事实;组内可以直接配置 `COIN` 和 `DIAMOND` 钱包资产数量。
|
||||||
- 资源和资源组都可以通过后台命令赠送给用户,赠送必须可审计、可幂等、可追溯。
|
- 资源和资源组都可以通过后台命令赠送给用户,赠送必须可审计、可幂等、可追溯。
|
||||||
- 金币仍由 `wallet-service` 资产账本管理;资源库里的金币资源只是对 `COIN` 入账规则的配置包装。
|
- 金币仍由 `wallet-service` 资产账本管理;资源库里的金币资源只是对 `COIN` 入账规则的配置包装。
|
||||||
- 礼物价格、礼物热度、主播积分仍由 `wallet-service` 结算,客户端不能提交价格、热度或积分。
|
- 礼物价格、收费资产、礼物热度、主播积分和有效期仍由 `wallet-service` 结算,客户端不能提交价格、收费类型、热度或积分。
|
||||||
- 按 `app_code` 隔离资源、资源组、礼物配置、赠送和用户持有状态。
|
- 按 `app_code` 隔离资源、资源组、礼物配置、赠送和用户持有状态。
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
@ -38,7 +38,7 @@
|
|||||||
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
|
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
|
||||||
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
|
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
|
||||||
| `resource_group_item` | 资源组成员。`item_type=resource` 引用资源库资源;`item_type=wallet_asset` 直接配置 `COIN` 或 `DIAMOND` 数量。 |
|
| `resource_group_item` | 资源组成员。`item_type=resource` 引用资源库资源;`item_type=wallet_asset` 直接配置 `COIN` 或 `DIAMOND` 数量。 |
|
||||||
| `gift_config` | 礼物配置是一个绑定了 `resource_type=gift` 资源的业务配置,并额外拥有价格、热度和主播积分规则。 |
|
| `gift_config` | 礼物配置是一个绑定了 `resource_type=gift` 资源的业务配置,并额外拥有礼物类型、有效期、特效、价格、收费资产、热度和主播积分规则。 |
|
||||||
| `user_resource` | 用户持有的非金币资源权益,例如某个头像框 30 天、某个徽章永久。 |
|
| `user_resource` | 用户持有的非金币资源权益,例如某个头像框 30 天、某个徽章永久。 |
|
||||||
| `wallet_asset_resource` | 会转成钱包资产入账的资源,例如金币。实际余额仍写 `wallet_accounts`。 |
|
| `wallet_asset_resource` | 会转成钱包资产入账的资源,例如金币。实际余额仍写 `wallet_accounts`。 |
|
||||||
| `grant` | 后台或系统向用户发放资源或资源组的命令事实。 |
|
| `grant` | 后台或系统向用户发放资源或资源组的命令事实。 |
|
||||||
@ -57,17 +57,44 @@
|
|||||||
|
|
||||||
## Gift Boundary
|
## Gift Boundary
|
||||||
|
|
||||||
礼物不是独立上传资源。新增礼物必须从 `resources` 中选择一个 `resource_type=gift` 的资源,然后配置礼物价格和结算值。
|
礼物不是独立上传资源。新增礼物必须从 `resources` 中选择一个 `resource_type=gift` 的资源,然后配置礼物类型、有效期、特效、价格、收费资产和结算值。
|
||||||
|
|
||||||
礼物拆成两层:
|
礼物拆成三层:
|
||||||
|
|
||||||
| Layer | Owner | Fields |
|
| Layer | Owner | Fields |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 礼物配置 | `wallet-service` resource domain | `gift_id`、`resource_id`、展示名、排序、上下架状态、客户端表现 metadata |
|
| 礼物配置 | `wallet-service` resource domain | `gift_id`、`resource_id`、展示名、排序、上下架状态、礼物类型、有效期、特效、客户端表现 metadata |
|
||||||
| 礼物区域配置 | `wallet-service` resource domain | `gift_id` 和多个 `region_id` 的映射;`region_id=0` 表示 GLOBAL 兜底区域 |
|
| 礼物区域配置 | `wallet-service` resource domain | `gift_id` 和多个 `region_id` 的映射;`region_id=0` 表示 GLOBAL 兜底区域 |
|
||||||
| 礼物账务价格 | `wallet-service` ledger domain | `gift_id`、`price_version`、`coin_price`、`gift_point_amount`、`heat_value`、生效时间 |
|
| 礼物账务价格 | `wallet-service` ledger domain | `gift_id`、`price_version`、`charge_asset_type`、`coin_price`、`gift_point_amount`、`heat_value`、价格生效时间 |
|
||||||
|
|
||||||
`SendGift` 仍然只接收 `gift_id/gift_count`。room-service 必须把房间 `visible_region_id` 传给 wallet-service,wallet-service 必须验证礼物 active 且在该区域可用,使用服务端价格扣 `COIN`,用服务端资源快照发房间消息。客户端不能提交资源 URL、礼物价格、热度、主播积分或礼物区域。
|
礼物类型 code 固定为:
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
| --- | --- |
|
||||||
|
| `normal` | 普通礼物 |
|
||||||
|
| `cp` | CP礼物 |
|
||||||
|
| `lucky` | 幸运礼物 |
|
||||||
|
| `super_lucky` | 超级幸运礼物 |
|
||||||
|
| `exclusive` | 专属礼物 |
|
||||||
|
| `noble` | 贵族礼物 |
|
||||||
|
| `flag` | 国旗礼物 |
|
||||||
|
| `activity` | 活动礼物 |
|
||||||
|
| `magic` | 魔法礼物 |
|
||||||
|
| `custom` | 定制礼物 |
|
||||||
|
|
||||||
|
礼物收费资产只允许 `COIN` 和 `DIAMOND`。字段名沿用 `coin_price` 表达“价格数值”,实际扣费资产由 `charge_asset_type` 决定。`DebitGiftResponse.coin_spent` 保留兼容旧调用方;新调用方应优先读取 `charge_asset_type` 和 `charge_amount`。
|
||||||
|
|
||||||
|
礼物特效 code 固定为:
|
||||||
|
|
||||||
|
| Code | Label |
|
||||||
|
| --- | --- |
|
||||||
|
| `animation` | 动画 |
|
||||||
|
| `music` | 音乐 |
|
||||||
|
| `global_broadcast` | 全局广播 |
|
||||||
|
|
||||||
|
`gift_configs.effective_from_ms` 和 `gift_configs.effective_to_ms` 表达礼物配置有效期。任一字段为 `0` 表示该方向不限制;两个字段都为 `0` 表示长期有效。App 礼物列表和 `DebitGift` 送礼扣费都必须过滤未开始或已结束的 active 礼物。后台列表可以展示全部礼物,便于运营提前配置或排查历史配置。
|
||||||
|
|
||||||
|
`SendGift` 仍然只接收 `gift_id/gift_count`。room-service 必须把房间 `visible_region_id` 传给 wallet-service,wallet-service 必须验证礼物 active、在有效期内且在该区域可用,再使用服务端价格按 `charge_asset_type` 扣 `COIN` 或 `DIAMOND`,用服务端资源快照发房间消息。客户端不能提交资源 URL、礼物价格、收费资产、热度、主播积分、有效期、特效或礼物区域。
|
||||||
|
|
||||||
礼物绑定资源时的约束:
|
礼物绑定资源时的约束:
|
||||||
|
|
||||||
@ -75,7 +102,7 @@
|
|||||||
- 礼物绑定的 resource 必须 `resource_type=gift`。
|
- 礼物绑定的 resource 必须 `resource_type=gift`。
|
||||||
- 下架 resource 不自动删除历史礼物交易,只阻止新礼物上架或继续发送。
|
- 下架 resource 不自动删除历史礼物交易,只阻止新礼物上架或继续发送。
|
||||||
- 修改礼物 resource 后,新发送使用新 resource;历史礼物事件使用发送时快照,不反查当前配置。
|
- 修改礼物 resource 后,新发送使用新 resource;历史礼物事件使用发送时快照,不反查当前配置。
|
||||||
- 钱包落账 metadata 必须保存 `gift_id`、`price_version`、`coin_price`、`gift_point_amount`、`heat_value`。房间礼物事件必须保存必要的 `resource_snapshot_json` 供客户端展示和审计。
|
- 钱包落账 metadata 必须保存 `gift_id`、`price_version`、`charge_asset_type`、`charge_amount`、`coin_price`、`gift_point_amount`、`heat_value`。房间礼物事件必须保存必要的 `resource_snapshot_json` 供客户端展示和审计。
|
||||||
|
|
||||||
## Grant Boundary
|
## Grant Boundary
|
||||||
|
|
||||||
@ -198,13 +225,34 @@ gift_configs(
|
|||||||
name VARCHAR(128) NOT NULL,
|
name VARCHAR(128) NOT NULL,
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
presentation_json JSON NULL,
|
presentation_json JSON NULL,
|
||||||
|
gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||||
|
effective_from_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
effective_to_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
effect_types_json JSON NULL,
|
||||||
created_by_user_id BIGINT NOT NULL,
|
created_by_user_id BIGINT NOT NULL,
|
||||||
updated_by_user_id BIGINT NOT NULL,
|
updated_by_user_id BIGINT NOT NULL,
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, gift_id),
|
PRIMARY KEY (app_code, gift_id),
|
||||||
KEY idx_gift_configs_resource (app_code, resource_id),
|
KEY idx_gift_configs_resource (app_code, resource_id),
|
||||||
KEY idx_gift_configs_status_sort (app_code, status, sort_order)
|
KEY idx_gift_configs_status_sort (app_code, status, sort_order),
|
||||||
|
KEY idx_gift_configs_effective (app_code, status, effective_from_ms, effective_to_ms)
|
||||||
|
);
|
||||||
|
|
||||||
|
wallet_gift_prices(
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||||
|
gift_id VARCHAR(96) NOT NULL,
|
||||||
|
price_version VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN',
|
||||||
|
coin_price BIGINT NOT NULL,
|
||||||
|
gift_point_amount BIGINT NOT NULL,
|
||||||
|
heat_value BIGINT NOT NULL,
|
||||||
|
effective_at_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, gift_id, price_version),
|
||||||
|
KEY idx_wallet_gift_prices_effective (app_code, gift_id, status, effective_at_ms)
|
||||||
);
|
);
|
||||||
|
|
||||||
gift_config_regions(
|
gift_config_regions(
|
||||||
@ -322,6 +370,9 @@ GET /api/v1/admin/resource-grants
|
|||||||
- 资源组内 `item_type=wallet_asset` 的单位是数量,后台前端提交 `assetType` 和 `amount`;`server/admin` 换算为 wallet-service RPC 的 `wallet_asset_type`、`wallet_asset_amount`,`duration_ms=0`、`quantity=1`。
|
- 资源组内 `item_type=wallet_asset` 的单位是数量,后台前端提交 `assetType` 和 `amount`;`server/admin` 换算为 wallet-service RPC 的 `wallet_asset_type`、`wallet_asset_amount`,`duration_ms=0`、`quantity=1`。
|
||||||
- 同一资源组内同一个 `resource_id` 不能重复,同一种钱包资产也不能重复。
|
- 同一资源组内同一个 `resource_id` 不能重复,同一种钱包资产也不能重复。
|
||||||
- active 资源组保存或启用时,组内资源项必须是 `active`、`grantable=true` 且不能是 `coin` 资源;金币和钻石必须使用 `wallet_asset` 项。
|
- active 资源组保存或启用时,组内资源项必须是 `active`、`grantable=true` 且不能是 `coin` 资源;金币和钻石必须使用 `wallet_asset` 项。
|
||||||
|
- 礼物新增或编辑时必须选择礼物类型和收费类型;收费类型只允许 `COIN` 或 `DIAMOND`。
|
||||||
|
- 礼物有效期可以为空;填写时必须满足结束时间晚于开始时间。有效期只控制礼物配置可见和可发送,不重算历史礼物交易。
|
||||||
|
- 礼物特效是枚举多选,只描述客户端表现能力;服务端不根据特效触发动画、音乐或广播,只在 App 礼物列表和房间礼物消息相关链路中透传必要配置。
|
||||||
|
|
||||||
权限建议:
|
权限建议:
|
||||||
|
|
||||||
@ -365,7 +416,7 @@ GET /api/v1/users/me/resources
|
|||||||
POST /api/v1/users/me/resources/{resource_id}/equip
|
POST /api/v1/users/me/resources/{resource_id}/equip
|
||||||
```
|
```
|
||||||
|
|
||||||
礼物列表按区域过滤:传入 `region_id=N` 时返回绑定到 `N` 的礼物和 `region_id=0` 的 GLOBAL 兜底礼物;不传 `region_id` 时只返回 GLOBAL 兜底礼物。
|
礼物列表按区域和有效期过滤:传入 `region_id=N` 时返回绑定到 `N` 的礼物和 `region_id=0` 的 GLOBAL 兜底礼物;不传 `region_id` 时只返回 GLOBAL 兜底礼物。只返回 `status=active`、资源 active、当前时间落在有效期内的礼物。响应会包含 `gift_type_code`、`charge_asset_type`、`effective_from_ms`、`effective_to_ms` 和 `effect_types`,客户端只能用于展示和选择,不能在送礼时回传这些值作为结算依据。
|
||||||
|
|
||||||
装备资源的边界:
|
装备资源的边界:
|
||||||
|
|
||||||
|
|||||||
@ -127,7 +127,7 @@
|
|||||||
| SendGift 房间命令 | `DONE` | room-service 先扣费,再更新 heat、gift_rank、outbox | 礼物目标、礼物表现和客户端动画联调 |
|
| SendGift 房间命令 | `DONE` | room-service 先扣费,再更新 heat、gift_rank、outbox | 礼物目标、礼物表现和客户端动画联调 |
|
||||||
| 房间热度 | `DONE` | `RoomSnapshot.heat` 和 `RoomHeatChanged` 事件 | 热度衰减/排行榜周期未做 |
|
| 房间热度 | `DONE` | `RoomSnapshot.heat` 和 `RoomHeatChanged` 事件 | 热度衰减/排行榜周期未做 |
|
||||||
| 本地礼物榜 | `DONE` | `LocalRank` 和 `gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
|
| 本地礼物榜 | `DONE` | `LocalRank` 和 `gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
|
||||||
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格,SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |
|
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效;SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |
|
||||||
| 背包/道具 | `PARTIAL` | `user_resource_entitlements`、`user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 |
|
| 背包/道具 | `PARTIAL` | `user_resource_entitlements`、`user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 |
|
||||||
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,主事件同步 IM | 客户端去重和展示策略未验收 |
|
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,主事件同步 IM | 客户端去重和展示策略未验收 |
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import (
|
|||||||
jobmodule "hyapp-admin-server/internal/modules/job"
|
jobmodule "hyapp-admin-server/internal/modules/job"
|
||||||
menumodule "hyapp-admin-server/internal/modules/menu"
|
menumodule "hyapp-admin-server/internal/modules/menu"
|
||||||
notificationmodule "hyapp-admin-server/internal/modules/notification"
|
notificationmodule "hyapp-admin-server/internal/modules/notification"
|
||||||
|
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
||||||
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
||||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||||
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
||||||
@ -185,6 +186,7 @@ func main() {
|
|||||||
Job: jobmodule.New(store, cfg, auditHandler),
|
Job: jobmodule.New(store, cfg, auditHandler),
|
||||||
Menu: menumodule.New(store, auditHandler),
|
Menu: menumodule.New(store, auditHandler),
|
||||||
Notification: notificationmodule.New(store, auditHandler),
|
Notification: notificationmodule.New(store, auditHandler),
|
||||||
|
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn)),
|
||||||
RBAC: rbacmodule.New(store, auditHandler),
|
RBAC: rbacmodule.New(store, auditHandler),
|
||||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
RoomAdmin: roomadminmodule.New(roomDB, userDB, auditHandler),
|
RoomAdmin: roomadminmodule.New(roomDB, userDB, auditHandler),
|
||||||
|
|||||||
@ -228,7 +228,7 @@ func fromProtoRegion(region *userv1.Region) *Region {
|
|||||||
RegionCode: region.GetRegionCode(),
|
RegionCode: region.GetRegionCode(),
|
||||||
Name: region.GetName(),
|
Name: region.GetName(),
|
||||||
Status: region.GetStatus(),
|
Status: region.GetStatus(),
|
||||||
Countries: append([]string(nil), region.GetCountries()...),
|
Countries: append([]string{}, region.GetCountries()...),
|
||||||
SortOrder: region.GetSortOrder(),
|
SortOrder: region.GetSortOrder(),
|
||||||
CreatedAtMs: region.GetCreatedAtMs(),
|
CreatedAtMs: region.GetCreatedAtMs(),
|
||||||
UpdatedAtMs: region.GetUpdatedAtMs(),
|
UpdatedAtMs: region.GetUpdatedAtMs(),
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client 是 admin-server 对 wallet-service 资源管理 RPC 的最小依赖。
|
// Client 是 admin-server 对 wallet-service 资源和账务只读 RPC 的最小依赖。
|
||||||
type Client interface {
|
type Client interface {
|
||||||
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
|
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
|
||||||
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
|
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
|
||||||
@ -27,6 +27,7 @@ type Client interface {
|
|||||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
||||||
|
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type GRPCClient struct {
|
type GRPCClient struct {
|
||||||
@ -104,3 +105,7 @@ func (c *GRPCClient) GrantResourceGroup(ctx context.Context, req *walletv1.Grant
|
|||||||
func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error) {
|
func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error) {
|
||||||
return c.client.ListResourceGrants(ctx, req)
|
return c.client.ListResourceGrants(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
|
||||||
|
return c.client.ListRechargeBills(ctx, req)
|
||||||
|
}
|
||||||
|
|||||||
@ -93,6 +93,26 @@ func (AppConfig) TableName() string {
|
|||||||
return "admin_app_configs"
|
return "admin_app_configs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AppBanner struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort,not null;default:lalu" json:"appCode"`
|
||||||
|
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
|
||||||
|
BannerType string `gorm:"size:16;not null" json:"bannerType"`
|
||||||
|
Param string `gorm:"size:2048" json:"param"`
|
||||||
|
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort,not null;default:active" json:"status"`
|
||||||
|
Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`
|
||||||
|
SortOrder int `gorm:"index:idx_admin_app_banners_app_sort,not null;default:0" json:"sortOrder"`
|
||||||
|
RegionID int64 `gorm:"index:idx_admin_app_banners_scope,not null;default:0" json:"regionId"`
|
||||||
|
CountryCode string `gorm:"size:8;index:idx_admin_app_banners_scope" json:"countryCode"`
|
||||||
|
Description string `gorm:"size:255" json:"description"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AppBanner) TableName() string {
|
||||||
|
return "admin_app_banners"
|
||||||
|
}
|
||||||
|
|
||||||
type RefreshToken struct {
|
type RefreshToken struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index;not null" json:"userId"`
|
UserID uint `gorm:"index;not null" json:"userId"`
|
||||||
|
|||||||
@ -2,7 +2,10 @@ package appconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
"hyapp-admin-server/internal/modules/shared"
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
"hyapp-admin-server/internal/response"
|
"hyapp-admin-server/internal/response"
|
||||||
@ -43,3 +46,98 @@ func (h *Handler) UpdateH5Links(c *gin.Context) {
|
|||||||
shared.OperationLog(c, h.audit, "update-app-h5-links", "admin_app_configs", "success", fmt.Sprintf("%d h5 links", len(request.Items)))
|
shared.OperationLog(c, h.audit, "update-app-h5-links", "admin_app_configs", "success", fmt.Sprintf("%d h5 links", len(request.Items)))
|
||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListBanners(c *gin.Context) {
|
||||||
|
options := shared.ListOptions(c)
|
||||||
|
items, err := h.service.ListBanners(appctx.FromContext(c.Request.Context()), repository.AppBannerListOptions{
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
Status: options.Status,
|
||||||
|
Platform: strings.TrimSpace(c.Query("platform")),
|
||||||
|
RegionID: parseOptionalInt64(c.Query("regionId"), c.Query("region_id")),
|
||||||
|
Country: firstQuery(c, "countryCode", "country_code", "country"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取 BANNER 配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateBanner(c *gin.Context) {
|
||||||
|
var request bannerRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.BadRequest(c, "参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.CreateBanner(appctx.FromContext(c.Request.Context()), request)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLog(c, h.audit, "create-app-banner", "admin_app_banners", "success", fmt.Sprintf("banner_id=%d", item.ID))
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateBanner(c *gin.Context) {
|
||||||
|
id, ok := bannerID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request bannerRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.BadRequest(c, "参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.UpdateBanner(appctx.FromContext(c.Request.Context()), id, request)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLog(c, h.audit, "update-app-banner", "admin_app_banners", "success", fmt.Sprintf("banner_id=%d", item.ID))
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteBanner(c *gin.Context) {
|
||||||
|
id, ok := bannerID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.DeleteBanner(appctx.FromContext(c.Request.Context()), id); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLog(c, h.audit, "delete-app-banner", "admin_app_banners", "success", fmt.Sprintf("banner_id=%d", id))
|
||||||
|
response.OK(c, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bannerID(c *gin.Context) (uint, bool) {
|
||||||
|
value, err := strconv.ParseUint(c.Param("banner_id"), 10, 64)
|
||||||
|
if err != nil || value == 0 {
|
||||||
|
response.BadRequest(c, "banner id is invalid")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(value), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalInt64(values ...string) int64 {
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err == nil && parsed > 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstQuery(c *gin.Context, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := c.Query(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@ -8,3 +8,15 @@ type h5LinkPayload struct {
|
|||||||
Key string `json:"key" binding:"required"`
|
Key string `json:"key" binding:"required"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type bannerRequest struct {
|
||||||
|
CoverURL string `json:"coverUrl" binding:"required"`
|
||||||
|
BannerType string `json:"bannerType" binding:"required"`
|
||||||
|
Param string `json:"param"`
|
||||||
|
Status string `json:"status" binding:"required"`
|
||||||
|
Platform string `json:"platform" binding:"required"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
RegionID int64 `json:"regionId"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|||||||
@ -13,4 +13,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
|
|
||||||
protected.GET("/admin/app-config/h5-links", middleware.RequirePermission("app-config:view"), h.ListH5Links)
|
protected.GET("/admin/app-config/h5-links", middleware.RequirePermission("app-config:view"), h.ListH5Links)
|
||||||
protected.PUT("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.UpdateH5Links)
|
protected.PUT("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.UpdateH5Links)
|
||||||
|
|
||||||
|
protected.GET("/admin/app-config/banners", middleware.RequirePermission("app-config:view"), h.ListBanners)
|
||||||
|
protected.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), h.CreateBanner)
|
||||||
|
protected.PUT("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.UpdateBanner)
|
||||||
|
protected.DELETE("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.DeleteBanner)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
"hyapp-admin-server/internal/model"
|
"hyapp-admin-server/internal/model"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
)
|
)
|
||||||
@ -36,6 +38,22 @@ type H5Link struct {
|
|||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AppBanner struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
|
CoverURL string `json:"coverUrl"`
|
||||||
|
BannerType string `json:"bannerType"`
|
||||||
|
Param string `json:"param"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
RegionID int64 `json:"regionId"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewService(store *repository.Store) *AppConfigService {
|
func NewService(store *repository.Store) *AppConfigService {
|
||||||
return &AppConfigService{store: store}
|
return &AppConfigService{store: store}
|
||||||
}
|
}
|
||||||
@ -102,6 +120,62 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
|
|||||||
return s.ListH5Links()
|
return s.ListH5Links()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
|
||||||
|
options.AppCode = appctx.Normalize(appCode)
|
||||||
|
options.Status = normalizeBannerStatus(options.Status)
|
||||||
|
options.Platform = normalizeBannerPlatform(options.Platform)
|
||||||
|
options.Country = normalizeCountryCode(options.Country)
|
||||||
|
options.Keyword = strings.TrimSpace(options.Keyword)
|
||||||
|
items, err := s.store.ListAppBanners(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]AppBanner, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, appBannerFromModel(item))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) CreateBanner(appCode string, req bannerRequest) (AppBanner, error) {
|
||||||
|
item, err := bannerModelFromRequest(appctx.Normalize(appCode), req)
|
||||||
|
if err != nil {
|
||||||
|
return AppBanner{}, err
|
||||||
|
}
|
||||||
|
if err := s.store.CreateAppBanner(&item); err != nil {
|
||||||
|
return AppBanner{}, err
|
||||||
|
}
|
||||||
|
return appBannerFromModel(item), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) UpdateBanner(appCode string, id uint, req bannerRequest) (AppBanner, error) {
|
||||||
|
item, err := s.store.GetAppBanner(appctx.Normalize(appCode), id)
|
||||||
|
if err != nil {
|
||||||
|
return AppBanner{}, err
|
||||||
|
}
|
||||||
|
updated, err := bannerModelFromRequest(item.AppCode, req)
|
||||||
|
if err != nil {
|
||||||
|
return AppBanner{}, err
|
||||||
|
}
|
||||||
|
item.CoverURL = updated.CoverURL
|
||||||
|
item.BannerType = updated.BannerType
|
||||||
|
item.Param = updated.Param
|
||||||
|
item.Status = updated.Status
|
||||||
|
item.Platform = updated.Platform
|
||||||
|
item.SortOrder = updated.SortOrder
|
||||||
|
item.RegionID = updated.RegionID
|
||||||
|
item.CountryCode = updated.CountryCode
|
||||||
|
item.Description = updated.Description
|
||||||
|
if err := s.store.UpdateAppBanner(&item); err != nil {
|
||||||
|
return AppBanner{}, err
|
||||||
|
}
|
||||||
|
return appBannerFromModel(item), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) DeleteBanner(appCode string, id uint) error {
|
||||||
|
return s.store.DeleteAppBanner(appctx.Normalize(appCode), id)
|
||||||
|
}
|
||||||
|
|
||||||
func h5LinkDefinitionSet() map[string]string {
|
func h5LinkDefinitionSet() map[string]string {
|
||||||
out := make(map[string]string, len(h5LinkDefinitions))
|
out := make(map[string]string, len(h5LinkDefinitions))
|
||||||
for _, definition := range h5LinkDefinitions {
|
for _, definition := range h5LinkDefinitions {
|
||||||
@ -124,3 +198,101 @@ func validateH5URL(value string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
|
||||||
|
item := model.AppBanner{
|
||||||
|
AppCode: appctx.Normalize(appCode),
|
||||||
|
CoverURL: strings.TrimSpace(req.CoverURL),
|
||||||
|
BannerType: normalizeBannerType(req.BannerType),
|
||||||
|
Param: strings.TrimSpace(req.Param),
|
||||||
|
Status: normalizeBannerStatus(req.Status),
|
||||||
|
Platform: normalizeBannerPlatform(req.Platform),
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
RegionID: req.RegionID,
|
||||||
|
CountryCode: normalizeCountryCode(req.CountryCode),
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
}
|
||||||
|
if item.CoverURL == "" || len(item.CoverURL) > 1024 {
|
||||||
|
return model.AppBanner{}, errors.New("banner cover is invalid")
|
||||||
|
}
|
||||||
|
if item.BannerType != "h5" && item.BannerType != "app" {
|
||||||
|
return model.AppBanner{}, errors.New("banner type is invalid")
|
||||||
|
}
|
||||||
|
if item.BannerType == "h5" {
|
||||||
|
if item.Param == "" {
|
||||||
|
return model.AppBanner{}, errors.New("h5 link is required")
|
||||||
|
}
|
||||||
|
if err := validateH5URL(item.Param); err != nil {
|
||||||
|
return model.AppBanner{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.Param) > 2048 {
|
||||||
|
return model.AppBanner{}, errors.New("banner param is too long")
|
||||||
|
}
|
||||||
|
if item.Status != "active" && item.Status != "disabled" {
|
||||||
|
return model.AppBanner{}, errors.New("banner status is invalid")
|
||||||
|
}
|
||||||
|
if item.Platform != "android" && item.Platform != "ios" {
|
||||||
|
return model.AppBanner{}, errors.New("banner platform is invalid")
|
||||||
|
}
|
||||||
|
if item.RegionID < 0 {
|
||||||
|
return model.AppBanner{}, errors.New("banner region is invalid")
|
||||||
|
}
|
||||||
|
if item.CountryCode != "" && !validCountryCode(item.CountryCode) {
|
||||||
|
return model.AppBanner{}, errors.New("banner country is invalid")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(item.Description) > 255 {
|
||||||
|
return model.AppBanner{}, errors.New("banner description is too long")
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appBannerFromModel(item model.AppBanner) AppBanner {
|
||||||
|
return AppBanner{
|
||||||
|
ID: item.ID,
|
||||||
|
AppCode: item.AppCode,
|
||||||
|
CoverURL: item.CoverURL,
|
||||||
|
BannerType: item.BannerType,
|
||||||
|
Param: item.Param,
|
||||||
|
Status: item.Status,
|
||||||
|
Platform: item.Platform,
|
||||||
|
SortOrder: item.SortOrder,
|
||||||
|
RegionID: item.RegionID,
|
||||||
|
CountryCode: item.CountryCode,
|
||||||
|
Description: item.Description,
|
||||||
|
CreatedAtMs: item.CreatedAt.UnixMilli(),
|
||||||
|
UpdatedAtMs: item.UpdatedAt.UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBannerType(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBannerStatus(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBannerPlatform(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCountryCode(value string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCountryCode(value string) bool {
|
||||||
|
if len(value) < 2 || len(value) > 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value {
|
||||||
|
if char < 'A' || char > 'Z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@ -79,8 +79,29 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
|||||||
COALESCE(u.country, ''),
|
COALESCE(u.country, ''),
|
||||||
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
||||||
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
||||||
COALESCE(u.region_id, 0),
|
CASE
|
||||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), ''),
|
WHEN COALESCE(u.region_id, 0) = 0 THEN 0
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM regions rg
|
||||||
|
WHERE rg.app_code = u.app_code
|
||||||
|
AND rg.region_id = u.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN u.region_id
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM regions rg
|
||||||
|
WHERE rg.app_code = u.app_code
|
||||||
|
AND rg.region_id = u.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN 'GLOBAL'
|
||||||
|
ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
|
||||||
|
END,
|
||||||
u.status,
|
u.status,
|
||||||
u.created_at_ms,
|
u.created_at_ms,
|
||||||
u.updated_at_ms,
|
u.updated_at_ms,
|
||||||
@ -149,8 +170,29 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
|||||||
COALESCE(u.country, ''),
|
COALESCE(u.country, ''),
|
||||||
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
||||||
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
|
||||||
COALESCE(u.region_id, 0),
|
CASE
|
||||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), ''),
|
WHEN COALESCE(u.region_id, 0) = 0 THEN 0
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM regions rg
|
||||||
|
WHERE rg.app_code = u.app_code
|
||||||
|
AND rg.region_id = u.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN u.region_id
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM regions rg
|
||||||
|
WHERE rg.app_code = u.app_code
|
||||||
|
AND rg.region_id = u.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN 'GLOBAL'
|
||||||
|
ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
|
||||||
|
END,
|
||||||
u.status,
|
u.status,
|
||||||
u.created_at_ms,
|
u.created_at_ms,
|
||||||
u.updated_at_ms,
|
u.updated_at_ms,
|
||||||
@ -344,6 +386,7 @@ func (s *Service) resolveCountryRegion(ctx context.Context, input string) (strin
|
|||||||
AND rc.country_code = c.country_code
|
AND rc.country_code = c.country_code
|
||||||
AND rc.status = 'active'
|
AND rc.status = 'active'
|
||||||
AND rg.status = 'active'
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
ORDER BY rg.sort_order ASC, rg.region_id ASC
|
ORDER BY rg.sort_order ASC, rg.region_id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
), 0)
|
), 0)
|
||||||
|
|||||||
@ -354,6 +354,10 @@ func writeCountryRegionError(c *gin.Context, err error, fallback string) {
|
|||||||
response.BadRequest(c, "国家已归属其他启用区域")
|
response.BadRequest(c, "国家已归属其他启用区域")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, errGlobalRegionProtected) {
|
||||||
|
response.BadRequest(c, "GLOBAL 区域不能停用或配置国家")
|
||||||
|
return
|
||||||
|
}
|
||||||
if st, ok := status.FromError(err); ok {
|
if st, ok := status.FromError(err); ok {
|
||||||
switch st.Code() {
|
switch st.Code() {
|
||||||
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
|
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
|
||||||
|
|||||||
@ -17,9 +17,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const countryRegionCaller = "hyapp-admin-server"
|
const countryRegionCaller = "hyapp-admin-server"
|
||||||
|
const globalRegionCode = "GLOBAL"
|
||||||
const regionStatusActive = "active"
|
const regionStatusActive = "active"
|
||||||
const regionStatusDisabled = "disabled"
|
const regionStatusDisabled = "disabled"
|
||||||
const regionRebuildTaskStatusPending = "pending"
|
const regionRebuildTaskStatusPending = "pending"
|
||||||
|
const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'"
|
||||||
const maxCountryNameLength = 128
|
const maxCountryNameLength = 128
|
||||||
const maxCountryDisplayNameLength = 128
|
const maxCountryDisplayNameLength = 128
|
||||||
const maxCountryFlagLength = 16
|
const maxCountryFlagLength = 16
|
||||||
@ -33,13 +35,14 @@ var (
|
|||||||
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
||||||
descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`)
|
descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`)
|
||||||
|
|
||||||
errCountryNotFound = errors.New("country not found")
|
errCountryNotFound = errors.New("country not found")
|
||||||
errCountryInvalid = errors.New("country payload invalid")
|
errCountryInvalid = errors.New("country payload invalid")
|
||||||
errCountryConflict = errors.New("country already exists")
|
errCountryConflict = errors.New("country already exists")
|
||||||
errRegionNotFound = errors.New("region not found")
|
errRegionNotFound = errors.New("region not found")
|
||||||
errRegionInvalid = errors.New("region payload invalid")
|
errRegionInvalid = errors.New("region payload invalid")
|
||||||
errRegionConflict = errors.New("region already exists")
|
errRegionConflict = errors.New("region already exists")
|
||||||
errRegionCountryMapping = errors.New("country already belongs to another active region")
|
errRegionCountryMapping = errors.New("country already belongs to another active region")
|
||||||
|
errGlobalRegionProtected = errors.New("global region is protected")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@ -170,11 +173,20 @@ func (s *Service) CreateRegion(ctx context.Context, actorID int64, requestID str
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
countries, err := canonicalCountryCodes(ctx, tx, req.Countries)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
|
countries := []string{}
|
||||||
|
if !isGlobalRegionCode(req.RegionCode) {
|
||||||
|
countries, err = canonicalCountryCodes(ctx, tx, req.Countries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := releaseRetiredCountryMappings(ctx, tx, countries, actorID, nowMs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ensureCountriesAssignableToNewRegion(ctx, tx, countries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
appCode := appctx.FromContext(ctx)
|
appCode := appctx.FromContext(ctx)
|
||||||
result, err := tx.ExecContext(ctx, `
|
result, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO regions (app_code, region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms)
|
INSERT INTO regions (app_code, region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms)
|
||||||
@ -265,6 +277,9 @@ func (s *Service) setRegionEnabled(ctx context.Context, actorID int64, regionID
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !enabled && isGlobalRegionCode(before.RegionCode) {
|
||||||
|
return nil, errGlobalRegionProtected
|
||||||
|
}
|
||||||
|
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
nextStatus := regionStatusDisabled
|
nextStatus := regionStatusDisabled
|
||||||
@ -544,9 +559,15 @@ func validRegionCreateRequest(req createRegionRequest) bool {
|
|||||||
if !validRegionCode(req.RegionCode) {
|
if !validRegionCode(req.RegionCode) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if isRetiredRegionCode(req.RegionCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if req.Name == "" || utf8.RuneCountInString(req.Name) > maxRegionNameLength {
|
if req.Name == "" || utf8.RuneCountInString(req.Name) > maxRegionNameLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if isGlobalRegionCode(req.RegionCode) {
|
||||||
|
return len(req.Countries) == 0
|
||||||
|
}
|
||||||
return len(req.Countries) > 0
|
return len(req.Countries) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,6 +575,19 @@ func validRegionCode(code string) bool {
|
|||||||
return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code)
|
return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isGlobalRegionCode(code string) bool {
|
||||||
|
return normalizeRegionCode(code) == globalRegionCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRetiredRegionCode(code string) bool {
|
||||||
|
switch strings.ToUpper(normalizeRegionCode(code)) {
|
||||||
|
case "AUSTRALIA AND NEW ZEALAND", "CARIBBEAN", "MELANESIA", "MICRONESIA", "POLYNESIA", "SOUTHERN AFRICA", "UNSPECIFIED":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalCountryCodes(ctx context.Context, q regionQueryer, countries []string) ([]string, error) {
|
func canonicalCountryCodes(ctx context.Context, q regionQueryer, countries []string) ([]string, error) {
|
||||||
if len(countries) == 0 {
|
if len(countries) == 0 {
|
||||||
return nil, errRegionInvalid
|
return nil, errRegionInvalid
|
||||||
@ -586,6 +620,41 @@ func canonicalCountryCodes(ctx context.Context, q regionQueryer, countries []str
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureCountriesAssignableToNewRegion(ctx context.Context, q regionQueryer, countries []string) error {
|
||||||
|
for _, country := range countries {
|
||||||
|
var existingRegionID int64
|
||||||
|
err := q.QueryRowContext(ctx, `
|
||||||
|
SELECT region_id
|
||||||
|
FROM region_countries
|
||||||
|
WHERE app_code = ? AND country_code = ? AND status = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, appctx.FromContext(ctx), country, regionStatusActive).Scan(&existingRegionID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errRegionCountryMapping
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseRetiredCountryMappings(ctx context.Context, tx *sql.Tx, countries []string, actorID int64, nowMs int64) error {
|
||||||
|
for _, country := range countries {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE region_countries rc
|
||||||
|
INNER JOIN regions rg ON rg.app_code = rc.app_code AND rg.region_id = rc.region_id
|
||||||
|
SET rc.status = ?, rc.updated_by_user_id = ?, rc.updated_at_ms = ?
|
||||||
|
WHERE rc.app_code = ? AND rc.country_code = ? AND rc.status = ?
|
||||||
|
AND rg.region_code IN (`+retiredRegionCodeSQLList+`)
|
||||||
|
`, regionStatusDisabled, nullableInt64(actorID), nowMs, appctx.FromContext(ctx), country, regionStatusActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func mapRegionWriteError(err error) error {
|
func mapRegionWriteError(err error) error {
|
||||||
var mysqlErr *mysqlDriver.MySQLError
|
var mysqlErr *mysqlDriver.MySQLError
|
||||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
|
|||||||
50
server/admin/internal/modules/payment/dto.go
Normal file
50
server/admin/internal/modules/payment/dto.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
|
||||||
|
type rechargeBillDTO struct {
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
|
TransactionID string `json:"transactionId"`
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
RechargeType string `json:"rechargeType"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExternalRef string `json:"externalRef"`
|
||||||
|
UserID int64 `json:"userId"`
|
||||||
|
SellerUserID int64 `json:"sellerUserId"`
|
||||||
|
SellerRegionID int64 `json:"sellerRegionId"`
|
||||||
|
TargetRegionID int64 `json:"targetRegionId"`
|
||||||
|
PolicyID int64 `json:"policyId"`
|
||||||
|
PolicyVersion string `json:"policyVersion"`
|
||||||
|
CurrencyCode string `json:"currencyCode"`
|
||||||
|
CoinAmount int64 `json:"coinAmount"`
|
||||||
|
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||||
|
ExchangeCoinAmount int64 `json:"exchangeCoinAmount"`
|
||||||
|
ExchangeUSDMinorAmount int64 `json:"exchangeUsdMinorAmount"`
|
||||||
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
|
||||||
|
if item == nil {
|
||||||
|
return rechargeBillDTO{}
|
||||||
|
}
|
||||||
|
return rechargeBillDTO{
|
||||||
|
AppCode: item.GetAppCode(),
|
||||||
|
TransactionID: item.GetTransactionId(),
|
||||||
|
CommandID: item.GetCommandId(),
|
||||||
|
RechargeType: item.GetRechargeType(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
ExternalRef: item.GetExternalRef(),
|
||||||
|
UserID: item.GetUserId(),
|
||||||
|
SellerUserID: item.GetSellerUserId(),
|
||||||
|
SellerRegionID: item.GetSellerRegionId(),
|
||||||
|
TargetRegionID: item.GetTargetRegionId(),
|
||||||
|
PolicyID: item.GetPolicyId(),
|
||||||
|
PolicyVersion: item.GetPolicyVersion(),
|
||||||
|
CurrencyCode: item.GetCurrencyCode(),
|
||||||
|
CoinAmount: item.GetCoinAmount(),
|
||||||
|
USDMinorAmount: item.GetUsdMinorAmount(),
|
||||||
|
ExchangeCoinAmount: item.GetExchangeCoinAmount(),
|
||||||
|
ExchangeUSDMinorAmount: item.GetExchangeUsdMinorAmount(),
|
||||||
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
72
server/admin/internal/modules/payment/handler.go
Normal file
72
server/admin/internal/modules/payment/handler.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
|
"hyapp-admin-server/internal/integration/walletclient"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
"hyapp-admin-server/internal/response"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
wallet walletclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(wallet walletclient.Client) *Handler {
|
||||||
|
return &Handler{wallet: wallet}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListRechargeBills(c *gin.Context) {
|
||||||
|
options := shared.ListOptions(c)
|
||||||
|
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
UserId: queryInt64(c, "user_id", "userId"),
|
||||||
|
SellerUserId: queryInt64(c, "seller_user_id", "sellerUserId"),
|
||||||
|
RegionId: queryInt64(c, "region_id", "regionId"),
|
||||||
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
|
Status: options.Status,
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||||
|
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||||
|
Page: int32(options.Page),
|
||||||
|
PageSize: int32(options.PageSize),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取账单列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]rechargeBillDTO, 0, len(resp.GetBills()))
|
||||||
|
for _, item := range resp.GetBills() {
|
||||||
|
items = append(items, rechargeBillFromProto(item))
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryInt64(c *gin.Context, keys ...string) int64 {
|
||||||
|
value := strings.TrimSpace(firstQuery(c, keys...))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstQuery(c *gin.Context, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := c.Query(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
15
server/admin/internal/modules/payment/routes.go
Normal file
15
server/admin/internal/modules/payment/routes.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
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/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
|
||||||
|
}
|
||||||
@ -64,9 +64,14 @@ type giftDTO struct {
|
|||||||
SortOrder int32 `json:"sortOrder"`
|
SortOrder int32 `json:"sortOrder"`
|
||||||
PresentationJSON string `json:"presentationJson"`
|
PresentationJSON string `json:"presentationJson"`
|
||||||
PriceVersion string `json:"priceVersion"`
|
PriceVersion string `json:"priceVersion"`
|
||||||
|
GiftTypeCode string `json:"giftTypeCode"`
|
||||||
|
ChargeAssetType string `json:"chargeAssetType"`
|
||||||
CoinPrice int64 `json:"coinPrice"`
|
CoinPrice int64 `json:"coinPrice"`
|
||||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||||
HeatValue int64 `json:"heatValue"`
|
HeatValue int64 `json:"heatValue"`
|
||||||
|
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
||||||
|
EffectiveToMS int64 `json:"effectiveToMs"`
|
||||||
|
EffectTypes []string `json:"effectTypes"`
|
||||||
CreatedByUserID int64 `json:"createdByUserId"`
|
CreatedByUserID int64 `json:"createdByUserId"`
|
||||||
UpdatedByUserID int64 `json:"updatedByUserId"`
|
UpdatedByUserID int64 `json:"updatedByUserId"`
|
||||||
CreatedAtMS int64 `json:"createdAtMs"`
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
@ -189,9 +194,14 @@ func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
|
|||||||
SortOrder: gift.GetSortOrder(),
|
SortOrder: gift.GetSortOrder(),
|
||||||
PresentationJSON: gift.GetPresentationJson(),
|
PresentationJSON: gift.GetPresentationJson(),
|
||||||
PriceVersion: gift.GetPriceVersion(),
|
PriceVersion: gift.GetPriceVersion(),
|
||||||
|
GiftTypeCode: gift.GetGiftTypeCode(),
|
||||||
|
ChargeAssetType: gift.GetChargeAssetType(),
|
||||||
CoinPrice: gift.GetCoinPrice(),
|
CoinPrice: gift.GetCoinPrice(),
|
||||||
GiftPointAmount: gift.GetGiftPointAmount(),
|
GiftPointAmount: gift.GetGiftPointAmount(),
|
||||||
HeatValue: gift.GetHeatValue(),
|
HeatValue: gift.GetHeatValue(),
|
||||||
|
EffectiveFromMS: gift.GetEffectiveFromMs(),
|
||||||
|
EffectiveToMS: gift.GetEffectiveToMs(),
|
||||||
|
EffectTypes: gift.GetEffectTypes(),
|
||||||
CreatedByUserID: gift.GetCreatedByUserId(),
|
CreatedByUserID: gift.GetCreatedByUserId(),
|
||||||
UpdatedByUserID: gift.GetUpdatedByUserId(),
|
UpdatedByUserID: gift.GetUpdatedByUserId(),
|
||||||
CreatedAtMS: gift.GetCreatedAtMs(),
|
CreatedAtMS: gift.GetCreatedAtMs(),
|
||||||
|
|||||||
@ -42,18 +42,23 @@ type resourceGroupItemRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type giftRequest struct {
|
type giftRequest struct {
|
||||||
GiftID string `json:"giftId"`
|
GiftID string `json:"giftId"`
|
||||||
ResourceID int64 `json:"resourceId"`
|
ResourceID int64 `json:"resourceId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SortOrder int32 `json:"sortOrder"`
|
SortOrder int32 `json:"sortOrder"`
|
||||||
PresentationJSON string `json:"presentationJson"`
|
PresentationJSON string `json:"presentationJson"`
|
||||||
PriceVersion string `json:"priceVersion"`
|
PriceVersion string `json:"priceVersion"`
|
||||||
CoinPrice int64 `json:"coinPrice"`
|
GiftTypeCode string `json:"giftTypeCode"`
|
||||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
ChargeAssetType string `json:"chargeAssetType"`
|
||||||
HeatValue int64 `json:"heatValue"`
|
CoinPrice int64 `json:"coinPrice"`
|
||||||
EffectiveAtMS int64 `json:"effectiveAtMs"`
|
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||||
RegionIDs []int64 `json:"regionIds"`
|
HeatValue int64 `json:"heatValue"`
|
||||||
|
EffectiveAtMS int64 `json:"effectiveAtMs"`
|
||||||
|
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
||||||
|
EffectiveToMS int64 `json:"effectiveToMs"`
|
||||||
|
EffectTypes []string `json:"effectTypes"`
|
||||||
|
RegionIDs []int64 `json:"regionIds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type grantResourceRequest struct {
|
type grantResourceRequest struct {
|
||||||
@ -161,10 +166,15 @@ func (r giftRequest) createProto(c *gin.Context) *walletv1.CreateGiftConfigReque
|
|||||||
SortOrder: r.SortOrder,
|
SortOrder: r.SortOrder,
|
||||||
PresentationJson: strings.TrimSpace(r.PresentationJSON),
|
PresentationJson: strings.TrimSpace(r.PresentationJSON),
|
||||||
PriceVersion: strings.TrimSpace(r.PriceVersion),
|
PriceVersion: strings.TrimSpace(r.PriceVersion),
|
||||||
|
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
|
||||||
|
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
|
||||||
CoinPrice: r.CoinPrice,
|
CoinPrice: r.CoinPrice,
|
||||||
GiftPointAmount: r.GiftPointAmount,
|
GiftPointAmount: r.GiftPointAmount,
|
||||||
HeatValue: r.HeatValue,
|
HeatValue: r.HeatValue,
|
||||||
EffectiveAtMs: r.EffectiveAtMS,
|
EffectiveAtMs: r.EffectiveAtMS,
|
||||||
|
EffectiveFromMs: r.EffectiveFromMS,
|
||||||
|
EffectiveToMs: r.EffectiveToMS,
|
||||||
|
EffectTypes: r.EffectTypes,
|
||||||
OperatorUserId: actorID(c),
|
OperatorUserId: actorID(c),
|
||||||
RegionIds: r.RegionIDs,
|
RegionIds: r.RegionIDs,
|
||||||
}
|
}
|
||||||
@ -181,10 +191,15 @@ func (r giftRequest) updateProto(c *gin.Context, giftID string) *walletv1.Update
|
|||||||
SortOrder: r.SortOrder,
|
SortOrder: r.SortOrder,
|
||||||
PresentationJson: strings.TrimSpace(r.PresentationJSON),
|
PresentationJson: strings.TrimSpace(r.PresentationJSON),
|
||||||
PriceVersion: strings.TrimSpace(r.PriceVersion),
|
PriceVersion: strings.TrimSpace(r.PriceVersion),
|
||||||
|
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
|
||||||
|
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
|
||||||
CoinPrice: r.CoinPrice,
|
CoinPrice: r.CoinPrice,
|
||||||
GiftPointAmount: r.GiftPointAmount,
|
GiftPointAmount: r.GiftPointAmount,
|
||||||
HeatValue: r.HeatValue,
|
HeatValue: r.HeatValue,
|
||||||
EffectiveAtMs: r.EffectiveAtMS,
|
EffectiveAtMs: r.EffectiveAtMS,
|
||||||
|
EffectiveFromMs: r.EffectiveFromMS,
|
||||||
|
EffectiveToMs: r.EffectiveToMS,
|
||||||
|
EffectTypes: r.EffectTypes,
|
||||||
OperatorUserId: actorID(c),
|
OperatorUserId: actorID(c),
|
||||||
RegionIds: r.RegionIDs,
|
RegionIds: r.RegionIDs,
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/admin/internal/repository/app_banner_repository.go
Normal file
57
server/admin/internal/repository/app_banner_repository.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppBannerListOptions struct {
|
||||||
|
AppCode string
|
||||||
|
Keyword string
|
||||||
|
Status string
|
||||||
|
Platform string
|
||||||
|
RegionID int64
|
||||||
|
Country string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner, error) {
|
||||||
|
var items []model.AppBanner
|
||||||
|
query := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
||||||
|
if options.Status != "" {
|
||||||
|
query = query.Where("status = ?", strings.TrimSpace(options.Status))
|
||||||
|
}
|
||||||
|
if options.Platform != "" {
|
||||||
|
query = query.Where("platform = ?", strings.TrimSpace(options.Platform))
|
||||||
|
}
|
||||||
|
if options.RegionID > 0 {
|
||||||
|
query = query.Where("region_id = ?", options.RegionID)
|
||||||
|
}
|
||||||
|
if options.Country != "" {
|
||||||
|
query = query.Where("country_code = ?", strings.TrimSpace(options.Country))
|
||||||
|
}
|
||||||
|
if options.Keyword != "" {
|
||||||
|
like := "%" + strings.TrimSpace(options.Keyword) + "%"
|
||||||
|
query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ?)", like, like, like)
|
||||||
|
}
|
||||||
|
err := query.Order("sort_order ASC, id DESC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetAppBanner(appCode string, id uint) (model.AppBanner, error) {
|
||||||
|
var item model.AppBanner
|
||||||
|
err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAppBanner(item *model.AppBanner) error {
|
||||||
|
return s.db.Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAppBanner(item *model.AppBanner) error {
|
||||||
|
return s.db.Save(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteAppBanner(appCode string, id uint) error {
|
||||||
|
return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppBanner{}).Error
|
||||||
|
}
|
||||||
@ -65,6 +65,7 @@ func (s *Store) AutoMigrate() error {
|
|||||||
&model.Permission{},
|
&model.Permission{},
|
||||||
&model.Menu{},
|
&model.Menu{},
|
||||||
&model.AppConfig{},
|
&model.AppConfig{},
|
||||||
|
&model.AppBanner{},
|
||||||
&model.RefreshToken{},
|
&model.RefreshToken{},
|
||||||
&model.LoginLog{},
|
&model.LoginLog{},
|
||||||
&model.OperationLog{},
|
&model.OperationLog{},
|
||||||
|
|||||||
@ -55,6 +55,7 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "币商查看", Code: "coin-seller:view", Kind: "menu"},
|
{Name: "币商查看", Code: "coin-seller:view", Kind: "menu"},
|
||||||
{Name: "币商创建", Code: "coin-seller:create", Kind: "button"},
|
{Name: "币商创建", Code: "coin-seller:create", Kind: "button"},
|
||||||
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
|
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
|
||||||
|
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
||||||
{Name: "角色查看", Code: "role:view", Kind: "menu"},
|
{Name: "角色查看", Code: "role:view", Kind: "menu"},
|
||||||
{Name: "角色创建", Code: "role:create", Kind: "button"},
|
{Name: "角色创建", Code: "role:create", Kind: "button"},
|
||||||
{Name: "角色更新", Code: "role:update", Kind: "button"},
|
{Name: "角色更新", Code: "role:update", Kind: "button"},
|
||||||
@ -101,6 +102,7 @@ func (s *Store) seedMenus() error {
|
|||||||
appConfigID := uint(0)
|
appConfigID := uint(0)
|
||||||
resourceID := uint(0)
|
resourceID := uint(0)
|
||||||
roomsID := uint(0)
|
roomsID := uint(0)
|
||||||
|
paymentID := uint(0)
|
||||||
menus := []model.Menu{
|
menus := []model.Menu{
|
||||||
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
|
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
|
||||||
{Title: "系统管理", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
|
{Title: "系统管理", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
|
||||||
@ -110,6 +112,7 @@ func (s *Store) seedMenus() error {
|
|||||||
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true},
|
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true},
|
||||||
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true},
|
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true},
|
||||||
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
|
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
|
||||||
|
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 68, Visible: true},
|
||||||
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 70, Visible: true},
|
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 70, Visible: true},
|
||||||
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
|
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
|
||||||
{Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true},
|
{Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true},
|
||||||
@ -143,6 +146,9 @@ func (s *Store) seedMenus() error {
|
|||||||
if syncedMenu.Code == "rooms" {
|
if syncedMenu.Code == "rooms" {
|
||||||
roomsID = syncedMenu.ID
|
roomsID = syncedMenu.ID
|
||||||
}
|
}
|
||||||
|
if syncedMenu.Code == "payment" {
|
||||||
|
paymentID = syncedMenu.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
children := []model.Menu{
|
children := []model.Menu{
|
||||||
@ -154,10 +160,12 @@ func (s *Store) seedMenus() error {
|
|||||||
{ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true},
|
{ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true},
|
||||||
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
|
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
|
||||||
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
|
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
|
||||||
|
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
|
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
|
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
|
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
|
||||||
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
|
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
|
||||||
|
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
|
||||||
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
|
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
|
||||||
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
|
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
|
||||||
{ParentID: &hostOrgID, Title: "Agency 管理", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 80, Visible: true},
|
{ParentID: &hostOrgID, Title: "Agency 管理", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 80, Visible: true},
|
||||||
@ -402,13 +410,14 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"agency:view", "agency:create", "agency:status",
|
"agency:view", "agency:create", "agency:status",
|
||||||
"bd:view", "bd:create", "bd:update",
|
"bd:view", "bd:create", "bd:update",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
||||||
|
"payment-bill:view",
|
||||||
"log:view",
|
"log:view",
|
||||||
"notification:view", "notification:read",
|
"notification:view", "notification:read",
|
||||||
"job:view", "job:cancel", "export:create",
|
"job:view", "job:cancel", "export:create",
|
||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
case "auditor":
|
||||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "role:view", "permission:view", "job:view"}
|
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "role:view", "permission:view", "job:view"}
|
||||||
case "readonly":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -426,6 +435,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"agency:view",
|
"agency:view",
|
||||||
"bd:view",
|
"bd:view",
|
||||||
"coin-seller:view",
|
"coin-seller:view",
|
||||||
|
"payment-bill:view",
|
||||||
"role:view",
|
"role:view",
|
||||||
"permission:view",
|
"permission:view",
|
||||||
"menu:view",
|
"menu:view",
|
||||||
@ -453,9 +463,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"country:view", "country:create", "country:update", "country:status",
|
"country:view", "country:create", "country:update", "country:status",
|
||||||
"region:view", "region:create", "region:update", "region:status",
|
"region:view", "region:create", "region:update", "region:status",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
||||||
|
"payment-bill:view",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
case "auditor", "readonly":
|
||||||
return []string{"room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view"}
|
return []string{"room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/job"
|
"hyapp-admin-server/internal/modules/job"
|
||||||
"hyapp-admin-server/internal/modules/menu"
|
"hyapp-admin-server/internal/modules/menu"
|
||||||
"hyapp-admin-server/internal/modules/notification"
|
"hyapp-admin-server/internal/modules/notification"
|
||||||
|
"hyapp-admin-server/internal/modules/payment"
|
||||||
"hyapp-admin-server/internal/modules/rbac"
|
"hyapp-admin-server/internal/modules/rbac"
|
||||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||||
"hyapp-admin-server/internal/modules/roomadmin"
|
"hyapp-admin-server/internal/modules/roomadmin"
|
||||||
@ -40,6 +41,7 @@ type Handlers struct {
|
|||||||
Job *job.Handler
|
Job *job.Handler
|
||||||
Menu *menu.Handler
|
Menu *menu.Handler
|
||||||
Notification *notification.Handler
|
Notification *notification.Handler
|
||||||
|
Payment *payment.Handler
|
||||||
RBAC *rbac.Handler
|
RBAC *rbac.Handler
|
||||||
Resource *resourcemodule.Handler
|
Resource *resourcemodule.Handler
|
||||||
RoomAdmin *roomadmin.Handler
|
RoomAdmin *roomadmin.Handler
|
||||||
@ -72,6 +74,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||||
audit.RegisterRoutes(protected, h.Audit)
|
audit.RegisterRoutes(protected, h.Audit)
|
||||||
notification.RegisterRoutes(protected, h.Notification)
|
notification.RegisterRoutes(protected, h.Notification)
|
||||||
|
payment.RegisterRoutes(protected, h.Payment)
|
||||||
job.RegisterRoutes(protected, h.Job)
|
job.RegisterRoutes(protected, h.Job)
|
||||||
upload.RegisterRoutes(protected, h.Upload)
|
upload.RegisterRoutes(protected, h.Upload)
|
||||||
search.RegisterRoutes(protected, h.Search)
|
search.RegisterRoutes(protected, h.Search)
|
||||||
|
|||||||
17
server/admin/migrations/006_admin_app_banners.sql
Normal file
17
server/admin/migrations/006_admin_app_banners.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS admin_app_banners (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||||
|
cover_url VARCHAR(1024) NOT NULL,
|
||||||
|
banner_type VARCHAR(16) NOT NULL,
|
||||||
|
param VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
platform VARCHAR(24) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
country_code VARCHAR(8) NOT NULL DEFAULT '',
|
||||||
|
description VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
INDEX idx_admin_app_banners_app_sort (app_code, status, sort_order, id),
|
||||||
|
INDEX idx_admin_app_banners_scope (app_code, platform, region_id, country_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@ -13,6 +13,14 @@ import (
|
|||||||
const h5LinkGroup = "h5-links"
|
const h5LinkGroup = "h5-links"
|
||||||
|
|
||||||
const listH5LinksSQL = "SELECT `key`, COALESCE(value, ''), updated_at FROM admin_app_configs WHERE `group` = ?"
|
const listH5LinksSQL = "SELECT `key`, COALESCE(value, ''), updated_at FROM admin_app_configs WHERE `group` = ?"
|
||||||
|
const listAppBannersSQL = `
|
||||||
|
SELECT id, app_code, cover_url, banner_type, param, platform, sort_order, region_id, country_code, description, updated_at
|
||||||
|
FROM admin_app_banners
|
||||||
|
WHERE app_code = ? AND status = 'active'
|
||||||
|
AND (? = '' OR platform = ?)
|
||||||
|
AND (region_id = 0 OR region_id = ?)
|
||||||
|
AND (country_code = '' OR country_code = ?)
|
||||||
|
ORDER BY sort_order ASC, id DESC`
|
||||||
|
|
||||||
// H5Link 是 gateway 下发给 App 的 H5 入口配置。
|
// H5Link 是 gateway 下发给 App 的 H5 入口配置。
|
||||||
type H5Link struct {
|
type H5Link struct {
|
||||||
@ -22,9 +30,32 @@ type H5Link struct {
|
|||||||
UpdatedAtMs int64 `json:"updated_at_ms"`
|
UpdatedAtMs int64 `json:"updated_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BannerQuery 是 App 首页 banner 的公开筛选条件。
|
||||||
|
type BannerQuery struct {
|
||||||
|
AppCode string
|
||||||
|
Platform string
|
||||||
|
RegionID int64
|
||||||
|
Country string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner 是 gateway 下发给 App 首页的 banner 配置。
|
||||||
|
type Banner struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
BannerType string `json:"type"`
|
||||||
|
Param string `json:"param"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
RegionID int64 `json:"region_id,omitempty"`
|
||||||
|
CountryCode string `json:"country_code,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
UpdatedAtMs int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
// Reader 是 HTTP 层读取 H5 配置的最小依赖。
|
// Reader 是 HTTP 层读取 H5 配置的最小依赖。
|
||||||
type Reader interface {
|
type Reader interface {
|
||||||
ListH5Links(ctx context.Context) ([]H5Link, error)
|
ListH5Links(ctx context.Context) ([]H5Link, error)
|
||||||
|
ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type h5LinkDefinition struct {
|
type h5LinkDefinition struct {
|
||||||
@ -115,10 +146,57 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBanners 返回当前 App、平台和地域可见的首页 banner。
|
||||||
|
func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("app config reader is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
appCode := normalizeAppCode(query.AppCode)
|
||||||
|
platform := normalizePlatform(query.Platform)
|
||||||
|
country := normalizeCountry(query.Country)
|
||||||
|
rows, err := r.db.QueryContext(ctx, listAppBannersSQL, appCode, platform, platform, query.RegionID, country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]Banner, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item Banner
|
||||||
|
var appCode string
|
||||||
|
var updatedAt sql.NullTime
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&appCode,
|
||||||
|
&item.CoverURL,
|
||||||
|
&item.BannerType,
|
||||||
|
&item.Param,
|
||||||
|
&item.Platform,
|
||||||
|
&item.SortOrder,
|
||||||
|
&item.RegionID,
|
||||||
|
&item.CountryCode,
|
||||||
|
&item.Description,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if updatedAt.Valid {
|
||||||
|
item.UpdatedAtMs = updatedAt.Time.UnixMilli()
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
// StaticReader 给测试和临时环境提供内存版 H5 配置读取。
|
// StaticReader 给测试和临时环境提供内存版 H5 配置读取。
|
||||||
type StaticReader struct {
|
type StaticReader struct {
|
||||||
Links []H5Link
|
Links []H5Link
|
||||||
Err error
|
Banners []Banner
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListH5Links 返回预置 H5 配置。
|
// ListH5Links 返回预置 H5 配置。
|
||||||
@ -131,3 +209,30 @@ func (r StaticReader) ListH5Links(context.Context) ([]H5Link, error) {
|
|||||||
copy(out, r.Links)
|
copy(out, r.Links)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBanners 返回预置 App banner 配置。
|
||||||
|
func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error) {
|
||||||
|
if r.Err != nil {
|
||||||
|
return nil, r.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Banner, len(r.Banners))
|
||||||
|
copy(out, r.Banners)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAppCode(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "lalu"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePlatform(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCountry(value string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|||||||
@ -3,13 +3,17 @@ package http
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/services/gateway-service/internal/appconfig"
|
"hyapp/services/gateway-service/internal/appconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// appConfigReader 是 gateway 对后台 App 配置的只读依赖。
|
// appConfigReader 是 gateway 对后台 App 配置的只读依赖。
|
||||||
type appConfigReader interface {
|
type appConfigReader interface {
|
||||||
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
|
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
|
||||||
|
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listH5Links 返回后台 APP配置/H5配置 中维护的 H5 入口地址。
|
// listH5Links 返回后台 APP配置/H5配置 中维护的 H5 入口地址。
|
||||||
@ -29,3 +33,43 @@ func (h *Handler) listH5Links(writer http.ResponseWriter, request *http.Request)
|
|||||||
|
|
||||||
writeOK(writer, request, map[string]any{"items": items, "total": len(items)})
|
writeOK(writer, request, map[string]any{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listAppBanners 返回后台 APP配置/BANNER配置 中维护的首页 banner。
|
||||||
|
func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.appConfigReader == nil {
|
||||||
|
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := h.appConfigReader.ListBanners(request.Context(), appconfig.BannerQuery{
|
||||||
|
AppCode: appcode.FromContext(request.Context()),
|
||||||
|
Platform: firstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"),
|
||||||
|
RegionID: optionalInt64Query(request, "region_id"),
|
||||||
|
Country: firstQueryOrHeader(request, "country", "X-Country-Code", "X-App-Country"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeOK(writer, request, map[string]any{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstQueryOrHeader(request *http.Request, queryKey string, headerNames ...string) string {
|
||||||
|
if value := strings.TrimSpace(request.URL.Query().Get(queryKey)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return firstHeader(request, headerNames...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalInt64Query(request *http.Request, key string) int64 {
|
||||||
|
value := strings.TrimSpace(request.URL.Query().Get(key))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|||||||
@ -65,9 +65,14 @@ type giftConfigData struct {
|
|||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
PresentationJSON string `json:"presentation_json"`
|
PresentationJSON string `json:"presentation_json"`
|
||||||
PriceVersion string `json:"price_version"`
|
PriceVersion string `json:"price_version"`
|
||||||
|
GiftTypeCode string `json:"gift_type_code"`
|
||||||
|
ChargeAssetType string `json:"charge_asset_type"`
|
||||||
CoinPrice int64 `json:"coin_price"`
|
CoinPrice int64 `json:"coin_price"`
|
||||||
GiftPointAmount int64 `json:"gift_point_amount"`
|
GiftPointAmount int64 `json:"gift_point_amount"`
|
||||||
HeatValue int64 `json:"heat_value"`
|
HeatValue int64 `json:"heat_value"`
|
||||||
|
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||||
|
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||||
|
EffectTypes []string `json:"effect_types"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
RegionIDs []int64 `json:"region_ids"`
|
RegionIDs []int64 `json:"region_ids"`
|
||||||
@ -317,9 +322,14 @@ func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
|
|||||||
SortOrder: gift.GetSortOrder(),
|
SortOrder: gift.GetSortOrder(),
|
||||||
PresentationJSON: gift.GetPresentationJson(),
|
PresentationJSON: gift.GetPresentationJson(),
|
||||||
PriceVersion: gift.GetPriceVersion(),
|
PriceVersion: gift.GetPriceVersion(),
|
||||||
|
GiftTypeCode: gift.GetGiftTypeCode(),
|
||||||
|
ChargeAssetType: gift.GetChargeAssetType(),
|
||||||
CoinPrice: gift.GetCoinPrice(),
|
CoinPrice: gift.GetCoinPrice(),
|
||||||
GiftPointAmount: gift.GetGiftPointAmount(),
|
GiftPointAmount: gift.GetGiftPointAmount(),
|
||||||
HeatValue: gift.GetHeatValue(),
|
HeatValue: gift.GetHeatValue(),
|
||||||
|
EffectiveFromMS: gift.GetEffectiveFromMs(),
|
||||||
|
EffectiveToMS: gift.GetEffectiveToMs(),
|
||||||
|
EffectTypes: gift.GetEffectTypes(),
|
||||||
CreatedAtMS: gift.GetCreatedAtMs(),
|
CreatedAtMS: gift.GetCreatedAtMs(),
|
||||||
UpdatedAtMS: gift.GetUpdatedAtMs(),
|
UpdatedAtMS: gift.GetUpdatedAtMs(),
|
||||||
RegionIDs: gift.GetRegionIds(),
|
RegionIDs: gift.GetRegionIds(),
|
||||||
|
|||||||
@ -918,6 +918,57 @@ func TestListH5LinksRequiresConfigReader(t *testing.T) {
|
|||||||
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-h5-links-missing")
|
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-h5-links-missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetAppConfigReader(appconfig.StaticReader{Banners: []appconfig.Banner{
|
||||||
|
{
|
||||||
|
ID: 8,
|
||||||
|
CoverURL: "https://cdn.example.com/banner.png",
|
||||||
|
BannerType: "h5",
|
||||||
|
Param: "https://h5.example.com/activity",
|
||||||
|
Platform: "android",
|
||||||
|
SortOrder: 10,
|
||||||
|
RegionID: 1,
|
||||||
|
CountryCode: "CN",
|
||||||
|
Description: "homepage",
|
||||||
|
UpdatedAtMs: 1700000002000,
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?platform=android&country=CN®ion_id=1", nil)
|
||||||
|
request.Header.Set("X-Request-ID", "req-app-banners")
|
||||||
|
request.Header.Set("X-App-Package", "com.org.laluparty")
|
||||||
|
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 responseEnvelope
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
|
t.Fatalf("decode response failed: %v", err)
|
||||||
|
}
|
||||||
|
data, ok := response.Data.(map[string]any)
|
||||||
|
if response.Code != codeOK || !ok {
|
||||||
|
t.Fatalf("unexpected envelope: %+v", response)
|
||||||
|
}
|
||||||
|
if data["total"].(float64) != 1 {
|
||||||
|
t.Fatalf("banner total mismatch: %+v", data)
|
||||||
|
}
|
||||||
|
items, ok := data["items"].([]any)
|
||||||
|
if !ok || len(items) != 1 {
|
||||||
|
t.Fatalf("banner items shape mismatch: %+v", data)
|
||||||
|
}
|
||||||
|
first, ok := items[0].(map[string]any)
|
||||||
|
if !ok || first["cover_url"] != "https://cdn.example.com/banner.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/activity" {
|
||||||
|
t.Fatalf("banner item mismatch: %+v", first)
|
||||||
|
}
|
||||||
|
if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["updated_at_ms"].(float64) != 1700000002000 {
|
||||||
|
t.Fatalf("banner metadata mismatch: %+v", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
|
func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
|
||||||
hostClient := &fakeUserHostClient{
|
hostClient := &fakeUserHostClient{
|
||||||
hostProfile: &userv1.HostProfile{
|
hostProfile: &userv1.HostProfile{
|
||||||
|
|||||||
@ -21,6 +21,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
|
|
||||||
mux.Handle(apiV1Prefix+"/countries", h.publicAPIHandler(h.listRegistrationCountries))
|
mux.Handle(apiV1Prefix+"/countries", h.publicAPIHandler(h.listRegistrationCountries))
|
||||||
mux.Handle(apiV1Prefix+"/app/h5-links", h.publicAPIHandler(h.listH5Links))
|
mux.Handle(apiV1Prefix+"/app/h5-links", h.publicAPIHandler(h.listH5Links))
|
||||||
|
mux.Handle(apiV1Prefix+"/app/banners", h.publicAPIHandler(h.listAppBanners))
|
||||||
mux.Handle(apiV1Prefix+"/resources", h.publicAPIHandler(h.listResources))
|
mux.Handle(apiV1Prefix+"/resources", h.publicAPIHandler(h.listResources))
|
||||||
mux.Handle(apiV1Prefix+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup))
|
mux.Handle(apiV1Prefix+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup))
|
||||||
mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts))
|
mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts))
|
||||||
|
|||||||
@ -596,30 +596,24 @@ ON DUPLICATE KEY UPDATE
|
|||||||
updated_at_ms = VALUES(updated_at_ms);
|
updated_at_ms = VALUES(updated_at_ms);
|
||||||
|
|
||||||
INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES
|
INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('GLOBAL', 'GLOBAL', 'active', 0, 0, 0),
|
||||||
('Southern Europe', '南欧', 'active', 10, 0, 0),
|
('Southern Europe', '南欧', 'active', 10, 0, 0),
|
||||||
('Western Asia', '西亚', 'active', 20, 0, 0),
|
('Western Asia', '西亚', 'active', 20, 0, 0),
|
||||||
('Southern Asia', '南亚', 'active', 30, 0, 0),
|
('Southern Asia', '南亚', 'active', 30, 0, 0),
|
||||||
('Caribbean', '加勒比地区', 'active', 40, 0, 0),
|
|
||||||
('Southeast Europe', '东南欧', 'active', 50, 0, 0),
|
('Southeast Europe', '东南欧', 'active', 50, 0, 0),
|
||||||
('Middle Africa', '中非', 'active', 60, 0, 0),
|
('Middle Africa', '中非', 'active', 60, 0, 0),
|
||||||
('UNSPECIFIED', 'UNSPECIFIED', 'active', 70, 0, 0),
|
|
||||||
('South America', '南美洲', 'active', 80, 0, 0),
|
('South America', '南美洲', 'active', 80, 0, 0),
|
||||||
('Polynesia', '波利尼西亚', 'active', 90, 0, 0),
|
|
||||||
('Central Europe', '中欧', 'active', 100, 0, 0),
|
('Central Europe', '中欧', 'active', 100, 0, 0),
|
||||||
('Australia and New Zealand', '澳大利亚和新西兰', 'active', 110, 0, 0),
|
|
||||||
('Northern Europe', '北欧', 'active', 120, 0, 0),
|
('Northern Europe', '北欧', 'active', 120, 0, 0),
|
||||||
('Western Europe', '西欧', 'active', 130, 0, 0),
|
('Western Europe', '西欧', 'active', 130, 0, 0),
|
||||||
('Western Africa', '西非', 'active', 140, 0, 0),
|
('Western Africa', '西非', 'active', 140, 0, 0),
|
||||||
('Eastern Africa', '东非', 'active', 150, 0, 0),
|
('Eastern Africa', '东非', 'active', 150, 0, 0),
|
||||||
('North America', '北美洲', 'active', 160, 0, 0),
|
('North America', '北美洲', 'active', 160, 0, 0),
|
||||||
('South-Eastern Asia', '东南亚', 'active', 170, 0, 0),
|
('South-Eastern Asia', '东南亚', 'active', 170, 0, 0),
|
||||||
('Southern Africa', '南部非洲', 'active', 180, 0, 0),
|
|
||||||
('Eastern Europe', '东欧', 'active', 190, 0, 0),
|
('Eastern Europe', '东欧', 'active', 190, 0, 0),
|
||||||
('Central America', '中美洲', 'active', 200, 0, 0),
|
('Central America', '中美洲', 'active', 200, 0, 0),
|
||||||
('Eastern Asia', '东亚', 'active', 210, 0, 0),
|
('Eastern Asia', '东亚', 'active', 210, 0, 0),
|
||||||
('Northern Africa', '北非', 'active', 220, 0, 0),
|
('Northern Africa', '北非', 'active', 220, 0, 0),
|
||||||
('Melanesia', '美拉尼西亚', 'active', 230, 0, 0),
|
|
||||||
('Micronesia', '密克罗尼西亚', 'active', 240, 0, 0),
|
|
||||||
('Central Asia', '中亚', 'active', 250, 0, 0)
|
('Central Asia', '中亚', 'active', 250, 0, 0)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
name = VALUES(name),
|
name = VALUES(name),
|
||||||
@ -1131,6 +1125,7 @@ FROM (
|
|||||||
SELECT 'ZW' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'ZW' AS country_code, 'Eastern Africa' AS region_code
|
||||||
) AS seed
|
) AS seed
|
||||||
INNER JOIN regions r ON r.app_code = 'lalu' AND r.region_code = seed.region_code
|
INNER JOIN regions r ON r.app_code = 'lalu' AND r.region_code = seed.region_code
|
||||||
|
AND r.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
INNER JOIN countries c ON c.app_code = 'lalu' AND c.country_code = seed.country_code
|
INNER JOIN countries c ON c.app_code = 'lalu' AND c.country_code = seed.country_code
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
region_id = VALUES(region_id),
|
region_id = VALUES(region_id),
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// GlobalRegionCode 是没有命中任何显式国家映射时使用的兜底区域。
|
||||||
|
GlobalRegionCode = "GLOBAL"
|
||||||
|
|
||||||
// RegionStatusActive 表示区域映射参与国家到区域的实时解析。
|
// RegionStatusActive 表示区域映射参与国家到区域的实时解析。
|
||||||
RegionStatusActive = "active"
|
RegionStatusActive = "active"
|
||||||
// RegionStatusDisabled 表示区域停止解析并释放 active 国家归属。
|
// RegionStatusDisabled 表示区域停止解析并释放 active 国家归属。
|
||||||
@ -30,6 +33,15 @@ var (
|
|||||||
phoneCountryCodePattern = regexp.MustCompile(`^\+[1-9][0-9]{0,2}$`)
|
phoneCountryCodePattern = regexp.MustCompile(`^\+[1-9][0-9]{0,2}$`)
|
||||||
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
||||||
descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`)
|
descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`)
|
||||||
|
retiredRegionCodes = map[string]struct{}{
|
||||||
|
"AUSTRALIA AND NEW ZEALAND": {},
|
||||||
|
"CARIBBEAN": {},
|
||||||
|
"MELANESIA": {},
|
||||||
|
"MICRONESIA": {},
|
||||||
|
"POLYNESIA": {},
|
||||||
|
"SOUTHERN AFRICA": {},
|
||||||
|
"UNSPECIFIED": {},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Country 是国家主数据;enabled 是唯一可用性开关,外部只提交 CountryCode。
|
// Country 是国家主数据;enabled 是唯一可用性开关,外部只提交 CountryCode。
|
||||||
@ -223,3 +235,14 @@ func NormalizeRegionCode(code string) string {
|
|||||||
func ValidRegionCode(code string) bool {
|
func ValidRegionCode(code string) bool {
|
||||||
return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code)
|
return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsGlobalRegionCode 判断区域是否是兜底 GLOBAL 区域。
|
||||||
|
func IsGlobalRegionCode(code string) bool {
|
||||||
|
return NormalizeRegionCode(code) == GlobalRegionCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetiredRegionCode 判断区域是否已从业务区域集合中移除。
|
||||||
|
func IsRetiredRegionCode(code string) bool {
|
||||||
|
_, ok := retiredRegionCodes[strings.ToUpper(NormalizeRegionCode(code))]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|||||||
@ -227,7 +227,7 @@ type CompleteOnboardingCommand struct {
|
|||||||
Gender string
|
Gender string
|
||||||
// Country 是 active countries.country_code 中的规范国家码。
|
// Country 是 active countries.country_code 中的规范国家码。
|
||||||
Country string
|
Country string
|
||||||
// RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示进入 GLOBAL。
|
// RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示 GLOBAL 兜底桶。
|
||||||
RegionID int64
|
RegionID int64
|
||||||
// CompletedAtMs 是资料完成时间,同时作为 updated_at_ms。
|
// CompletedAtMs 是资料完成时间,同时作为 updated_at_ms。
|
||||||
CompletedAtMs int64
|
CompletedAtMs int64
|
||||||
@ -241,7 +241,7 @@ type CountryChangeCommand struct {
|
|||||||
UserID int64
|
UserID int64
|
||||||
// NewCountry 是新的国家或地区。
|
// NewCountry 是新的国家或地区。
|
||||||
NewCountry string
|
NewCountry string
|
||||||
// NewRegionID 是按 NewCountry 解析出的区域快照,0 表示无区域。
|
// NewRegionID 是按 NewCountry 解析出的区域快照,0 表示 GLOBAL 兜底桶。
|
||||||
NewRegionID int64
|
NewRegionID int64
|
||||||
// RequestID 是入口请求 ID,写入国家修改日志。
|
// RequestID 是入口请求 ID,写入国家修改日志。
|
||||||
RequestID string
|
RequestID string
|
||||||
|
|||||||
@ -129,7 +129,7 @@ func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username
|
|||||||
if region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, resolvedCountry.CountryCode); err != nil {
|
if region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, resolvedCountry.CountryCode); err != nil {
|
||||||
return userdomain.User{}, err
|
return userdomain.User{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
// 国家可能暂时没有区域映射,region_id=0 时由房间列表落到 GLOBAL。
|
// 国家未命中显式区域时,user-service 会返回 region_id=0 的 GLOBAL 兜底桶。
|
||||||
regionID = region.RegionID
|
regionID = region.RegionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -176,6 +176,9 @@ func validateRegionPayload(regionCode string, name string) error {
|
|||||||
if !userdomain.ValidRegionCode(regionCode) {
|
if !userdomain.ValidRegionCode(regionCode) {
|
||||||
return xerr.New(xerr.InvalidArgument, "region_code is invalid")
|
return xerr.New(xerr.InvalidArgument, "region_code is invalid")
|
||||||
}
|
}
|
||||||
|
if userdomain.IsRetiredRegionCode(regionCode) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "region has been retired")
|
||||||
|
}
|
||||||
if name == "" || utf8.RuneCountInString(name) > maxRegionNameLength {
|
if name == "" || utf8.RuneCountInString(name) > maxRegionNameLength {
|
||||||
return xerr.New(xerr.InvalidArgument, "region name length is invalid")
|
return xerr.New(xerr.InvalidArgument, "region name length is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ type CountryRegionRepository interface {
|
|||||||
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error)
|
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error)
|
||||||
// ListRegistrationCountries 返回 App 注册页可选国家列表。
|
// ListRegistrationCountries 返回 App 注册页可选国家列表。
|
||||||
ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error)
|
ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error)
|
||||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域;无映射返回 ok=false。
|
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域;无显式映射时返回 GLOBAL 兜底区域。
|
||||||
ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error)
|
ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -252,6 +252,34 @@ func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCountryWithoutExplicitRegionFallsBackToGlobal(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
seedCountry(t, repository, "ZZ")
|
||||||
|
repository.PutUser(userdomain.User{
|
||||||
|
UserID: 10001,
|
||||||
|
CurrentDisplayUserID: "100001",
|
||||||
|
Status: userdomain.StatusActive,
|
||||||
|
})
|
||||||
|
svc := newUserService(repository)
|
||||||
|
|
||||||
|
region, ok, err := repository.ResolveActiveRegionByCountry(ctx, "ZZ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveActiveRegionByCountry failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || region.RegionID != 0 || region.RegionCode != userdomain.GlobalRegionCode {
|
||||||
|
t.Fatalf("country without mapping should resolve to GLOBAL: ok=%t region=%+v", ok, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||||
|
}
|
||||||
|
if user.RegionID != 0 || user.RegionCode != userdomain.GlobalRegionCode || user.RegionName != userdomain.GlobalRegionCode {
|
||||||
|
t.Fatalf("user should enter GLOBAL region: %+v", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompleteOnboardingValidation(t *testing.T) {
|
func TestCompleteOnboardingValidation(t *testing.T) {
|
||||||
// 必填字段和展示资料格式在 service 层稳定返回 INVALID_ARGUMENT,不能依赖 MySQL 约束报错。
|
// 必填字段和展示资料格式在 service 层稳定返回 INVALID_ARGUMENT,不能依赖 MySQL 约束报错。
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@ -23,7 +23,7 @@ func (r *Repository) ListRegistrationCountries(ctx context.Context) ([]userdomai
|
|||||||
return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: boolPtr(true)})
|
return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: boolPtr(true)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无映射返回 ok=false。
|
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无显式映射时由 regions.go 返回 GLOBAL。
|
||||||
|
|
||||||
// UpdateCountry 修改国家展示字段;country_code 不允许原地修改。
|
// UpdateCountry 修改国家展示字段;country_code 不允许原地修改。
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import (
|
|||||||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'"
|
||||||
|
|
||||||
func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error) {
|
func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error) {
|
||||||
country, ok, err := resolveCountryByCode(ctx, r.db, countryCode, true)
|
country, ok, err := resolveCountryByCode(ctx, r.db, countryCode, true)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
@ -27,6 +29,7 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
|||||||
FROM region_countries rc
|
FROM region_countries rc
|
||||||
INNER JOIN regions rg ON rg.app_code = rc.app_code AND rg.region_id = rc.region_id
|
INNER JOIN regions rg ON rg.app_code = rc.app_code AND rg.region_id = rc.region_id
|
||||||
WHERE rc.app_code = ? AND rc.country_code = ? AND rc.status = ? AND rg.status = ?
|
WHERE rc.app_code = ? AND rc.country_code = ? AND rc.status = ? AND rg.status = ?
|
||||||
|
AND rg.region_code NOT IN (`+retiredRegionCodeSQLList+`)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, appcode.FromContext(ctx), country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive).Scan(
|
`, appcode.FromContext(ctx), country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive).Scan(
|
||||||
®ion.AppCode,
|
®ion.AppCode,
|
||||||
@ -41,7 +44,7 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
|||||||
®ion.UpdatedAtMs,
|
®ion.UpdatedAtMs,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return userdomain.Region{}, false, nil
|
return globalRegionFromContext(ctx), true, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return userdomain.Region{}, false, err
|
return userdomain.Region{}, false, err
|
||||||
@ -71,6 +74,12 @@ func (r *Repository) UpdateRegion(ctx context.Context, command userdomain.Update
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return userdomain.Region{}, err
|
return userdomain.Region{}, err
|
||||||
}
|
}
|
||||||
|
if userdomain.IsRetiredRegionCode(before.RegionCode) {
|
||||||
|
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||||||
|
}
|
||||||
|
if userdomain.IsGlobalRegionCode(before.RegionCode) && !userdomain.IsGlobalRegionCode(command.RegionCode) {
|
||||||
|
return userdomain.Region{}, xerr.New(xerr.InvalidArgument, "global region code cannot be changed")
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE regions
|
UPDATE regions
|
||||||
SET region_code = ?, name = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
|
SET region_code = ?, name = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||||
@ -107,6 +116,7 @@ func (r *Repository) ListRegions(ctx context.Context, filter userdomain.RegionFi
|
|||||||
conditions = append(conditions, "status = ?")
|
conditions = append(conditions, "status = ?")
|
||||||
args = append(args, filter.Status)
|
args = append(args, filter.Status)
|
||||||
}
|
}
|
||||||
|
conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")")
|
||||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||||
query += " ORDER BY sort_order ASC, region_code ASC"
|
query += " ORDER BY sort_order ASC, region_code ASC"
|
||||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
@ -116,20 +126,35 @@ func (r *Repository) ListRegions(ctx context.Context, filter userdomain.RegionFi
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
regions := []userdomain.Region{}
|
regions := []userdomain.Region{}
|
||||||
|
hasGlobal := false
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
region, err := scanRegion(rows)
|
region, err := scanRegion(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
||||||
|
hasGlobal = true
|
||||||
|
}
|
||||||
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
region.Countries = countries
|
region.Countries = countries
|
||||||
|
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
||||||
|
region.RegionID = 0
|
||||||
|
region.Countries = []string{}
|
||||||
|
}
|
||||||
regions = append(regions, region)
|
regions = append(regions, region)
|
||||||
}
|
}
|
||||||
|
|
||||||
return regions, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !hasGlobal && (filter.Status == "" || filter.Status == userdomain.RegionStatusActive) {
|
||||||
|
regions = append([]userdomain.Region{globalRegionFromContext(ctx)}, regions...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRegion 返回单个区域及其 active 国家列表。
|
// GetRegion 返回单个区域及其 active 国家列表。
|
||||||
@ -142,6 +167,9 @@ func (r *Repository) GetRegion(ctx context.Context, regionID int64) (userdomain.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return userdomain.Region{}, err
|
return userdomain.Region{}, err
|
||||||
}
|
}
|
||||||
|
if userdomain.IsRetiredRegionCode(region.RegionCode) {
|
||||||
|
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||||||
|
}
|
||||||
|
|
||||||
return region, nil
|
return region, nil
|
||||||
}
|
}
|
||||||
@ -162,6 +190,12 @@ func (r *Repository) ReplaceRegionCountries(ctx context.Context, command userdom
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return userdomain.Region{}, err
|
return userdomain.Region{}, err
|
||||||
}
|
}
|
||||||
|
if userdomain.IsRetiredRegionCode(before.RegionCode) {
|
||||||
|
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||||||
|
}
|
||||||
|
if userdomain.IsGlobalRegionCode(before.RegionCode) {
|
||||||
|
return userdomain.Region{}, xerr.New(xerr.InvalidArgument, "global region countries are implicit")
|
||||||
|
}
|
||||||
if before.Status != userdomain.RegionStatusActive {
|
if before.Status != userdomain.RegionStatusActive {
|
||||||
return userdomain.Region{}, xerr.New(xerr.RegionDisabled, "region is disabled")
|
return userdomain.Region{}, xerr.New(xerr.RegionDisabled, "region is disabled")
|
||||||
}
|
}
|
||||||
@ -169,6 +203,9 @@ func (r *Repository) ReplaceRegionCountries(ctx context.Context, command userdom
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return userdomain.Region{}, err
|
return userdomain.Region{}, err
|
||||||
}
|
}
|
||||||
|
if err := releaseRetiredCountryMappings(ctx, tx, appcode.Normalize(command.AppCode), countries, command.OperatorUserID, command.NowMs); err != nil {
|
||||||
|
return userdomain.Region{}, err
|
||||||
|
}
|
||||||
if err := ensureCountriesAssignableToRegion(ctx, tx, countries, command.RegionID); err != nil {
|
if err := ensureCountriesAssignableToRegion(ctx, tx, countries, command.RegionID); err != nil {
|
||||||
return userdomain.Region{}, err
|
return userdomain.Region{}, err
|
||||||
}
|
}
|
||||||
@ -248,6 +285,17 @@ func scanRegion(scanner interface{ Scan(dest ...any) error }) (userdomain.Region
|
|||||||
return region, err
|
return region, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func globalRegionFromContext(ctx context.Context) userdomain.Region {
|
||||||
|
return userdomain.Region{
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
RegionID: 0,
|
||||||
|
RegionCode: userdomain.GlobalRegionCode,
|
||||||
|
Name: userdomain.GlobalRegionCode,
|
||||||
|
Status: userdomain.RegionStatusActive,
|
||||||
|
Countries: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func activeCountriesForRegion(ctx context.Context, q queryer, regionID int64) ([]string, error) {
|
func activeCountriesForRegion(ctx context.Context, q queryer, regionID int64) ([]string, error) {
|
||||||
rows, err := queryRows(ctx, q, `
|
rows, err := queryRows(ctx, q, `
|
||||||
SELECT country_code
|
SELECT country_code
|
||||||
@ -272,6 +320,21 @@ func activeCountriesForRegion(ctx context.Context, q queryer, regionID int64) ([
|
|||||||
return countries, rows.Err()
|
return countries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func releaseRetiredCountryMappings(ctx context.Context, tx *sql.Tx, appCode string, countries []string, operatorUserID int64, nowMs int64) error {
|
||||||
|
for _, country := range countries {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE region_countries rc
|
||||||
|
INNER JOIN regions rg ON rg.app_code = rc.app_code AND rg.region_id = rc.region_id
|
||||||
|
SET rc.status = ?, rc.updated_by_user_id = ?, rc.updated_at_ms = ?
|
||||||
|
WHERE rc.app_code = ? AND rc.country_code = ? AND rc.status = ?
|
||||||
|
AND rg.region_code IN (`+retiredRegionCodeSQLList+`)
|
||||||
|
`, userdomain.RegionStatusDisabled, shared.NullableOperator(operatorUserID), nowMs, appcode.Normalize(appCode), country, userdomain.RegionStatusActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func insertRegionChangeLog(ctx context.Context, tx *sql.Tx, regionID int64, operation string, before any, after any, operatorUserID int64, requestID string, nowMs int64) error {
|
func insertRegionChangeLog(ctx context.Context, tx *sql.Tx, regionID int64, operation string, before any, after any, operatorUserID int64, requestID string, nowMs int64) error {
|
||||||
beforeJSON, err := marshalNullableJSON(before)
|
beforeJSON, err := marshalNullableJSON(before)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -32,15 +32,49 @@ const userSelectColumns = `
|
|||||||
COALESCE(username, ''),
|
COALESCE(username, ''),
|
||||||
COALESCE(gender, ''),
|
COALESCE(gender, ''),
|
||||||
COALESCE(country, ''),
|
COALESCE(country, ''),
|
||||||
COALESCE(region_id, 0),
|
CASE
|
||||||
|
WHEN COALESCE(region_id, 0) = 0 THEN 0
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM regions rg
|
||||||
|
WHERE rg.app_code = users.app_code
|
||||||
|
AND rg.region_id = users.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN users.region_id
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
COALESCE((SELECT c.country_id FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), 0),
|
COALESCE((SELECT c.country_id FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), 0),
|
||||||
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
||||||
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
||||||
COALESCE((SELECT c.iso_numeric FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
COALESCE((SELECT c.iso_numeric FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
||||||
COALESCE((SELECT c.phone_country_code FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
COALESCE((SELECT c.phone_country_code FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''),
|
||||||
COALESCE((SELECT c.enabled FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), 0),
|
COALESCE((SELECT c.enabled FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), 0),
|
||||||
COALESCE((SELECT rg.region_code FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), ''),
|
CASE
|
||||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), ''),
|
WHEN COALESCE(users.region_id, 0) = 0 OR NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM regions rg
|
||||||
|
WHERE rg.app_code = users.app_code
|
||||||
|
AND rg.region_id = users.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN 'GLOBAL'
|
||||||
|
ELSE COALESCE((SELECT rg.region_code FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), '')
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(users.region_id, 0) = 0 OR NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM regions rg
|
||||||
|
WHERE rg.app_code = users.app_code
|
||||||
|
AND rg.region_id = users.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
) THEN 'GLOBAL'
|
||||||
|
ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), '')
|
||||||
|
END,
|
||||||
COALESCE(invite_code, ''),
|
COALESCE(invite_code, ''),
|
||||||
COALESCE(register_ip, ''),
|
COALESCE(register_ip, ''),
|
||||||
COALESCE(register_user_agent, ''),
|
COALESCE(register_user_agent, ''),
|
||||||
|
|||||||
@ -104,7 +104,7 @@ func toProtoRegion(region userdomain.Region) *userv1.Region {
|
|||||||
RegionCode: region.RegionCode,
|
RegionCode: region.RegionCode,
|
||||||
Name: region.Name,
|
Name: region.Name,
|
||||||
Status: region.Status,
|
Status: region.Status,
|
||||||
Countries: append([]string(nil), region.Countries...),
|
Countries: append([]string{}, region.Countries...),
|
||||||
SortOrder: region.SortOrder,
|
SortOrder: region.SortOrder,
|
||||||
CreatedAtMs: region.CreatedAtMs,
|
CreatedAtMs: region.CreatedAtMs,
|
||||||
UpdatedAtMs: region.UpdatedAtMs,
|
UpdatedAtMs: region.UpdatedAtMs,
|
||||||
|
|||||||
@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS wallet_gift_prices (
|
|||||||
gift_id VARCHAR(96) NOT NULL,
|
gift_id VARCHAR(96) NOT NULL,
|
||||||
price_version VARCHAR(64) NOT NULL,
|
price_version VARCHAR(64) NOT NULL,
|
||||||
status VARCHAR(32) NOT NULL,
|
status VARCHAR(32) NOT NULL,
|
||||||
|
charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN',
|
||||||
coin_price BIGINT NOT NULL,
|
coin_price BIGINT NOT NULL,
|
||||||
gift_point_amount BIGINT NOT NULL,
|
gift_point_amount BIGINT NOT NULL,
|
||||||
heat_value BIGINT NOT NULL,
|
heat_value BIGINT NOT NULL,
|
||||||
@ -192,15 +193,65 @@ CREATE TABLE IF NOT EXISTS gift_configs (
|
|||||||
name VARCHAR(128) NOT NULL,
|
name VARCHAR(128) NOT NULL,
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
presentation_json JSON NULL,
|
presentation_json JSON NULL,
|
||||||
|
gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||||
|
effective_from_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
effective_to_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
effect_types_json JSON NULL,
|
||||||
created_by_user_id BIGINT NOT NULL DEFAULT 0,
|
created_by_user_id BIGINT NOT NULL DEFAULT 0,
|
||||||
updated_by_user_id BIGINT NOT NULL DEFAULT 0,
|
updated_by_user_id BIGINT NOT NULL DEFAULT 0,
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, gift_id),
|
PRIMARY KEY (app_code, gift_id),
|
||||||
KEY idx_gift_configs_resource (app_code, resource_id),
|
KEY idx_gift_configs_resource (app_code, resource_id),
|
||||||
KEY idx_gift_configs_status_sort (app_code, status, sort_order)
|
KEY idx_gift_configs_status_sort (app_code, status, sort_order),
|
||||||
|
KEY idx_gift_configs_effective (app_code, status, effective_from_ms, effective_to_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_gift_prices' AND COLUMN_NAME = 'charge_asset_type') = 0,
|
||||||
|
'ALTER TABLE wallet_gift_prices ADD COLUMN charge_asset_type VARCHAR(32) NOT NULL DEFAULT ''COIN'' AFTER status',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_configs' AND COLUMN_NAME = 'gift_type_code') = 0,
|
||||||
|
'ALTER TABLE gift_configs ADD COLUMN gift_type_code VARCHAR(32) NOT NULL DEFAULT ''normal'' AFTER presentation_json',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_configs' AND COLUMN_NAME = 'effective_from_ms') = 0,
|
||||||
|
'ALTER TABLE gift_configs ADD COLUMN effective_from_ms BIGINT NOT NULL DEFAULT 0 AFTER gift_type_code',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_configs' AND COLUMN_NAME = 'effective_to_ms') = 0,
|
||||||
|
'ALTER TABLE gift_configs ADD COLUMN effective_to_ms BIGINT NOT NULL DEFAULT 0 AFTER effective_from_ms',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_configs' AND COLUMN_NAME = 'effect_types_json') = 0,
|
||||||
|
'ALTER TABLE gift_configs ADD COLUMN effect_types_json JSON NULL AFTER effective_to_ms',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS gift_config_regions (
|
CREATE TABLE IF NOT EXISTS gift_config_regions (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||||
gift_id VARCHAR(96) NOT NULL,
|
gift_id VARCHAR(96) NOT NULL,
|
||||||
@ -276,11 +327,11 @@ CREATE TABLE IF NOT EXISTS user_resource_equipment (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
INSERT IGNORE INTO wallet_gift_prices (
|
INSERT IGNORE INTO wallet_gift_prices (
|
||||||
app_code, gift_id, price_version, status, coin_price, gift_point_amount, heat_value,
|
app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value,
|
||||||
effective_at_ms, created_at_ms, updated_at_ms
|
effective_at_ms, created_at_ms, updated_at_ms
|
||||||
) VALUES
|
) VALUES
|
||||||
('lalu', 'rose', 'v1', 'active', 10, 10, 10, 0, 0, 0),
|
('lalu', 'rose', 'v1', 'active', 'COIN', 10, 10, 10, 0, 0, 0),
|
||||||
('lalu', 'rocket', 'v1', 'active', 100, 100, 100, 0, 0, 0);
|
('lalu', 'rocket', 'v1', 'active', 'COIN', 100, 100, 100, 0, 0, 0);
|
||||||
|
|
||||||
INSERT IGNORE INTO resources (
|
INSERT IGNORE INTO resources (
|
||||||
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
|
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
|
||||||
@ -293,18 +344,24 @@ INSERT IGNORE INTO resources (
|
|||||||
|
|
||||||
INSERT IGNORE INTO gift_configs (
|
INSERT IGNORE INTO gift_configs (
|
||||||
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
||||||
|
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
|
||||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||||
)
|
)
|
||||||
SELECT 'lalu', 'rose', resource_id, 'active', 'Rose', 10, JSON_OBJECT(), 0, 0, 0, 0
|
SELECT 'lalu', 'rose', resource_id, 'active', 'Rose', 10, JSON_OBJECT(),
|
||||||
|
'normal', 0, 0, JSON_ARRAY(),
|
||||||
|
0, 0, 0, 0
|
||||||
FROM resources
|
FROM resources
|
||||||
WHERE app_code = 'lalu' AND resource_code = 'gift_rose'
|
WHERE app_code = 'lalu' AND resource_code = 'gift_rose'
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
INSERT IGNORE INTO gift_configs (
|
INSERT IGNORE INTO gift_configs (
|
||||||
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
||||||
|
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
|
||||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||||
)
|
)
|
||||||
SELECT 'lalu', 'rocket', resource_id, 'active', 'Rocket', 20, JSON_OBJECT(), 0, 0, 0, 0
|
SELECT 'lalu', 'rocket', resource_id, 'active', 'Rocket', 20, JSON_OBJECT(),
|
||||||
|
'normal', 0, 0, JSON_ARRAY('animation', 'global_broadcast'),
|
||||||
|
0, 0, 0, 0
|
||||||
FROM resources
|
FROM resources
|
||||||
WHERE app_code = 'lalu' AND resource_code = 'gift_rocket'
|
WHERE app_code = 'lalu' AND resource_code = 'gift_rocket'
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package ledger
|
package ledger
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// AssetCoin 是用户送礼消费资产。
|
// AssetCoin 是用户送礼消费资产。
|
||||||
AssetCoin = "COIN"
|
AssetCoin = "COIN"
|
||||||
@ -31,6 +33,8 @@ type Receipt struct {
|
|||||||
BillingReceiptID string
|
BillingReceiptID string
|
||||||
TransactionID string
|
TransactionID string
|
||||||
CoinSpent int64
|
CoinSpent int64
|
||||||
|
ChargeAssetType string
|
||||||
|
ChargeAmount int64
|
||||||
GiftPointAdded int64
|
GiftPointAdded int64
|
||||||
HeatValue int64
|
HeatValue int64
|
||||||
PriceVersion string
|
PriceVersion string
|
||||||
@ -63,6 +67,43 @@ type CoinSellerTransferReceipt struct {
|
|||||||
RechargePolicyUSDMinorUnit int64
|
RechargePolicyUSDMinorUnit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RechargeBill 是充值账单的只读投影;充值事实由 wallet_recharge_records 保存。
|
||||||
|
type RechargeBill struct {
|
||||||
|
AppCode string
|
||||||
|
TransactionID string
|
||||||
|
CommandID string
|
||||||
|
RechargeType string
|
||||||
|
Status string
|
||||||
|
ExternalRef string
|
||||||
|
UserID int64
|
||||||
|
SellerUserID int64
|
||||||
|
SellerRegionID int64
|
||||||
|
TargetRegionID int64
|
||||||
|
PolicyID int64
|
||||||
|
PolicyVersion string
|
||||||
|
CurrencyCode string
|
||||||
|
CoinAmount int64
|
||||||
|
USDMinorAmount int64
|
||||||
|
ExchangeCoinAmount int64
|
||||||
|
ExchangeUSDMinorAmount int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRechargeBillsQuery 是后台查询所有充值渠道账单的筛选条件。
|
||||||
|
type ListRechargeBillsQuery struct {
|
||||||
|
AppCode string
|
||||||
|
UserID int64
|
||||||
|
SellerUserID int64
|
||||||
|
RegionID int64
|
||||||
|
RechargeType string
|
||||||
|
Status string
|
||||||
|
Keyword string
|
||||||
|
StartAtMS int64
|
||||||
|
EndAtMS int64
|
||||||
|
Page int32
|
||||||
|
PageSize int32
|
||||||
|
}
|
||||||
|
|
||||||
// AssetBalance 是账户当前可用/冻结余额投影。
|
// AssetBalance 是账户当前可用/冻结余额投影。
|
||||||
type AssetBalance struct {
|
type AssetBalance struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
@ -95,3 +136,21 @@ func ValidAssetType(assetType string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeGiftChargeAssetType(assetType string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(assetType)) {
|
||||||
|
case AssetDiamond:
|
||||||
|
return AssetDiamond
|
||||||
|
default:
|
||||||
|
return AssetCoin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidGiftChargeAssetType(assetType string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(assetType)) {
|
||||||
|
case AssetCoin, AssetDiamond:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -34,6 +34,21 @@ const (
|
|||||||
|
|
||||||
GroupItemTypeResource = "resource"
|
GroupItemTypeResource = "resource"
|
||||||
GroupItemTypeWalletAsset = "wallet_asset"
|
GroupItemTypeWalletAsset = "wallet_asset"
|
||||||
|
|
||||||
|
GiftTypeNormal = "normal"
|
||||||
|
GiftTypeCP = "cp"
|
||||||
|
GiftTypeLucky = "lucky"
|
||||||
|
GiftTypeSuperLucky = "super_lucky"
|
||||||
|
GiftTypeExclusive = "exclusive"
|
||||||
|
GiftTypeNoble = "noble"
|
||||||
|
GiftTypeFlag = "flag"
|
||||||
|
GiftTypeActivity = "activity"
|
||||||
|
GiftTypeMagic = "magic"
|
||||||
|
GiftTypeCustom = "custom"
|
||||||
|
|
||||||
|
GiftEffectAnimation = "animation"
|
||||||
|
GiftEffectMusic = "music"
|
||||||
|
GiftEffectGlobalBroadcast = "global_broadcast"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
@ -102,6 +117,11 @@ type GiftConfig struct {
|
|||||||
CoinPrice int64
|
CoinPrice int64
|
||||||
GiftPointAmount int64
|
GiftPointAmount int64
|
||||||
HeatValue int64
|
HeatValue int64
|
||||||
|
GiftTypeCode string
|
||||||
|
ChargeAssetType string
|
||||||
|
EffectiveFromMS int64
|
||||||
|
EffectiveToMS int64
|
||||||
|
EffectTypes []string
|
||||||
CreatedByUserID int64
|
CreatedByUserID int64
|
||||||
UpdatedByUserID int64
|
UpdatedByUserID int64
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
@ -247,6 +267,11 @@ type GiftConfigCommand struct {
|
|||||||
GiftPointAmount int64
|
GiftPointAmount int64
|
||||||
HeatValue int64
|
HeatValue int64
|
||||||
EffectiveAtMS int64
|
EffectiveAtMS int64
|
||||||
|
GiftTypeCode string
|
||||||
|
ChargeAssetType string
|
||||||
|
EffectiveFromMS int64
|
||||||
|
EffectiveToMS int64
|
||||||
|
EffectTypes []string
|
||||||
OperatorUserID int64
|
OperatorUserID int64
|
||||||
RegionIDs []int64
|
RegionIDs []int64
|
||||||
}
|
}
|
||||||
@ -367,6 +392,36 @@ func NormalizeWalletAssetType(value string) string {
|
|||||||
return strings.ToUpper(strings.TrimSpace(value))
|
return strings.ToUpper(strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeGiftTypeCode(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return GiftTypeNormal
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidGiftTypeCode(value string) bool {
|
||||||
|
switch NormalizeGiftTypeCode(value) {
|
||||||
|
case GiftTypeNormal, GiftTypeCP, GiftTypeLucky, GiftTypeSuperLucky, GiftTypeExclusive, GiftTypeNoble, GiftTypeFlag, GiftTypeActivity, GiftTypeMagic, GiftTypeCustom:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeGiftEffectType(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidGiftEffectType(value string) bool {
|
||||||
|
switch NormalizeGiftEffectType(value) {
|
||||||
|
case GiftEffectAnimation, GiftEffectMusic, GiftEffectGlobalBroadcast:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func IsWalletCredit(resource Resource) bool {
|
func IsWalletCredit(resource Resource) bool {
|
||||||
return NormalizeGrantStrategy(resource.GrantStrategy) == GrantStrategyWalletCredit
|
return NormalizeGrantStrategy(resource.GrantStrategy) == GrantStrategyWalletCredit
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type Repository interface {
|
|||||||
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
|
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
|
||||||
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
|
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
|
||||||
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
|
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
|
||||||
|
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
|
||||||
ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error)
|
ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error)
|
||||||
GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error)
|
GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error)
|
||||||
CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error)
|
CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error)
|
||||||
@ -138,3 +139,14 @@ func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.Coi
|
|||||||
|
|
||||||
return s.repository.TransferCoinFromSeller(ctx, command)
|
return s.repository.TransferCoinFromSeller(ctx, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。
|
||||||
|
func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) {
|
||||||
|
if s.repository == nil {
|
||||||
|
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||||
|
}
|
||||||
|
query.AppCode = appcode.Normalize(query.AppCode)
|
||||||
|
ctx = appcode.WithContext(ctx, query.AppCode)
|
||||||
|
|
||||||
|
return s.repository.ListRechargeBills(ctx, query)
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package wallet_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
@ -378,6 +379,66 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
svc := walletservice.New(repository)
|
||||||
|
ctx := context.Background()
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||||
|
ResourceCode: "gift_diamond_magic",
|
||||||
|
ResourceType: resourcedomain.TypeGift,
|
||||||
|
Name: "Diamond Magic",
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
Grantable: true,
|
||||||
|
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||||||
|
UsageScopes: []string{"gift"},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create diamond gift resource failed: %v", err)
|
||||||
|
}
|
||||||
|
gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||||||
|
GiftID: "diamond-magic",
|
||||||
|
ResourceID: giftResource.ResourceID,
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
Name: "Diamond Magic",
|
||||||
|
PriceVersion: "v1",
|
||||||
|
ChargeAssetType: ledger.AssetDiamond,
|
||||||
|
CoinPrice: 7,
|
||||||
|
GiftPointAmount: 3,
|
||||||
|
HeatValue: 9,
|
||||||
|
GiftTypeCode: resourcedomain.GiftTypeMagic,
|
||||||
|
EffectiveFromMS: nowMs - 1000,
|
||||||
|
EffectiveToMS: nowMs + 60_000,
|
||||||
|
EffectTypes: []string{resourcedomain.GiftEffectAnimation, resourcedomain.GiftEffectMusic},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
RegionIDs: []int64{0},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create diamond gift config failed: %v", err)
|
||||||
|
}
|
||||||
|
if gift.ChargeAssetType != ledger.AssetDiamond || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
|
||||||
|
t.Fatalf("diamond gift config mismatch: %+v", gift)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.SetAssetBalance(52001, ledger.AssetDiamond, 100)
|
||||||
|
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||||||
|
CommandID: "cmd-diamond-gift",
|
||||||
|
RoomID: "room-diamond",
|
||||||
|
SenderUserID: 52001,
|
||||||
|
TargetUserID: 52002,
|
||||||
|
GiftID: "diamond-magic",
|
||||||
|
GiftCount: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DebitGift with diamond-backed gift failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.ChargeAssetType != ledger.AssetDiamond || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
|
||||||
|
t.Fatalf("diamond gift settlement mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验,region_id=0 作为 GLOBAL 兜底区域。
|
// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验,region_id=0 作为 GLOBAL 兜底区域。
|
||||||
func TestGiftConfigRegionFilter(t *testing.T) {
|
func TestGiftConfigRegionFilter(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRechargeBills 查询 wallet-service 的充值账单投影,后台不能直接读写业务库内部语义。
|
||||||
|
func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
ctx = contextWithCommandApp(ctx, query.AppCode)
|
||||||
|
query = normalizeRechargeBillsQuery(query)
|
||||||
|
query.AppCode = appcode.FromContext(ctx)
|
||||||
|
|
||||||
|
where, args := rechargeBillsWhereSQL(query)
|
||||||
|
total, err := r.countRechargeBillRows(ctx, where, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT rr.app_code, rr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref,
|
||||||
|
rr.user_id, rr.seller_user_id, rr.seller_region_id, rr.target_region_id,
|
||||||
|
rr.policy_id, rr.policy_version, rr.currency_code,
|
||||||
|
rr.coin_amount, rr.usd_minor_amount, rr.exchange_coin_amount, rr.exchange_usd_minor_amount,
|
||||||
|
rr.created_at_ms
|
||||||
|
FROM wallet_recharge_records rr
|
||||||
|
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
|
||||||
|
`+where+`
|
||||||
|
ORDER BY rr.created_at_ms DESC, rr.transaction_id DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]ledger.RechargeBill, 0, query.PageSize)
|
||||||
|
for rows.Next() {
|
||||||
|
var item ledger.RechargeBill
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.AppCode,
|
||||||
|
&item.TransactionID,
|
||||||
|
&item.CommandID,
|
||||||
|
&item.RechargeType,
|
||||||
|
&item.Status,
|
||||||
|
&item.ExternalRef,
|
||||||
|
&item.UserID,
|
||||||
|
&item.SellerUserID,
|
||||||
|
&item.SellerRegionID,
|
||||||
|
&item.TargetRegionID,
|
||||||
|
&item.PolicyID,
|
||||||
|
&item.PolicyVersion,
|
||||||
|
&item.CurrencyCode,
|
||||||
|
&item.CoinAmount,
|
||||||
|
&item.USDMinorAmount,
|
||||||
|
&item.ExchangeCoinAmount,
|
||||||
|
&item.ExchangeUSDMinorAmount,
|
||||||
|
&item.CreatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) countRechargeBillRows(ctx context.Context, where string, args ...any) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM wallet_recharge_records rr
|
||||||
|
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
|
||||||
|
`+where,
|
||||||
|
args...,
|
||||||
|
).Scan(&total)
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRechargeBillsQuery(query ledger.ListRechargeBillsQuery) ledger.ListRechargeBillsQuery {
|
||||||
|
query.AppCode = appcode.Normalize(query.AppCode)
|
||||||
|
query.RechargeType = strings.ToLower(strings.TrimSpace(query.RechargeType))
|
||||||
|
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||||
|
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||||
|
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
|
||||||
|
if query.EndAtMS > 0 && query.StartAtMS > query.EndAtMS {
|
||||||
|
query.StartAtMS, query.EndAtMS = query.EndAtMS, query.StartAtMS
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func rechargeBillsWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any) {
|
||||||
|
where := `WHERE rr.app_code = ?`
|
||||||
|
args := []any{query.AppCode}
|
||||||
|
if query.UserID > 0 {
|
||||||
|
where += ` AND rr.user_id = ?`
|
||||||
|
args = append(args, query.UserID)
|
||||||
|
}
|
||||||
|
if query.SellerUserID > 0 {
|
||||||
|
where += ` AND rr.seller_user_id = ?`
|
||||||
|
args = append(args, query.SellerUserID)
|
||||||
|
}
|
||||||
|
if query.RegionID > 0 {
|
||||||
|
where += ` AND rr.target_region_id = ?`
|
||||||
|
args = append(args, query.RegionID)
|
||||||
|
}
|
||||||
|
if query.RechargeType != "" {
|
||||||
|
where += ` AND wt.biz_type = ?`
|
||||||
|
args = append(args, query.RechargeType)
|
||||||
|
}
|
||||||
|
if query.Status != "" {
|
||||||
|
where += ` AND wt.status = ?`
|
||||||
|
args = append(args, query.Status)
|
||||||
|
}
|
||||||
|
if query.StartAtMS > 0 {
|
||||||
|
where += ` AND rr.created_at_ms >= ?`
|
||||||
|
args = append(args, query.StartAtMS)
|
||||||
|
}
|
||||||
|
if query.EndAtMS > 0 {
|
||||||
|
where += ` AND rr.created_at_ms <= ?`
|
||||||
|
args = append(args, query.EndAtMS)
|
||||||
|
}
|
||||||
|
if query.Keyword != "" {
|
||||||
|
like := "%" + query.Keyword + "%"
|
||||||
|
where += ` AND (rr.transaction_id LIKE ? OR wt.command_id LIKE ? OR wt.external_ref LIKE ? OR rr.policy_version LIKE ?)`
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
@ -109,7 +109,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
coinSpent, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
|
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
@ -122,11 +122,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, ledger.AssetCoin, false, nowMs)
|
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, false, nowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
if sender.AvailableAmount < coinSpent {
|
if sender.AvailableAmount < chargeAmount {
|
||||||
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||||
}
|
}
|
||||||
target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetGiftPoint, true, nowMs)
|
target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetGiftPoint, true, nowMs)
|
||||||
@ -143,10 +143,12 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
ResourceSnapshot: mustJSON(giftConfig.Resource),
|
ResourceSnapshot: mustJSON(giftConfig.Resource),
|
||||||
GiftCount: command.GiftCount,
|
GiftCount: command.GiftCount,
|
||||||
PriceVersion: price.PriceVersion,
|
PriceVersion: price.PriceVersion,
|
||||||
CoinSpent: coinSpent,
|
ChargeAssetType: price.ChargeAssetType,
|
||||||
|
ChargeAmount: chargeAmount,
|
||||||
|
CoinSpent: chargeAmount,
|
||||||
GiftPointAdded: giftPointAdded,
|
GiftPointAdded: giftPointAdded,
|
||||||
HeatValue: heatValue,
|
HeatValue: heatValue,
|
||||||
BalanceAfter: sender.AvailableAmount - coinSpent,
|
BalanceAfter: sender.AvailableAmount - chargeAmount,
|
||||||
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
|
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
|
||||||
SenderUserID: command.SenderUserID,
|
SenderUserID: command.SenderUserID,
|
||||||
TargetUserID: command.TargetUserID,
|
TargetUserID: command.TargetUserID,
|
||||||
@ -158,15 +160,15 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
|
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.applyAccountDelta(ctx, tx, sender, -coinSpent, 0, nowMs); err != nil {
|
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
senderAfter := sender.AvailableAmount - coinSpent
|
senderAfter := sender.AvailableAmount - chargeAmount
|
||||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
UserID: command.SenderUserID,
|
UserID: command.SenderUserID,
|
||||||
AssetType: ledger.AssetCoin,
|
AssetType: price.ChargeAssetType,
|
||||||
AvailableDelta: -coinSpent,
|
AvailableDelta: -chargeAmount,
|
||||||
FrozenDelta: 0,
|
FrozenDelta: 0,
|
||||||
AvailableAfter: senderAfter,
|
AvailableAfter: senderAfter,
|
||||||
FrozenAfter: sender.FrozenAmount,
|
FrozenAfter: sender.FrozenAmount,
|
||||||
@ -195,9 +197,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -coinSpent, 0, senderAfter, sender.FrozenAmount, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, metadata, nowMs),
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, metadata, nowMs),
|
||||||
giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -coinSpent, 0, metadata, nowMs),
|
giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
@ -590,6 +592,7 @@ func (r *Repository) applyAccountDelta(ctx context.Context, tx *sql.Tx, account
|
|||||||
type giftPrice struct {
|
type giftPrice struct {
|
||||||
GiftID string
|
GiftID string
|
||||||
PriceVersion string
|
PriceVersion string
|
||||||
|
ChargeAssetType string
|
||||||
CoinPrice int64
|
CoinPrice int64
|
||||||
GiftPointAmount int64
|
GiftPointAmount int64
|
||||||
HeatValue int64
|
HeatValue int64
|
||||||
@ -609,14 +612,14 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st
|
|||||||
var row *sql.Row
|
var row *sql.Row
|
||||||
if strings.TrimSpace(priceVersion) != "" {
|
if strings.TrimSpace(priceVersion) != "" {
|
||||||
row = tx.QueryRowContext(ctx,
|
row = tx.QueryRowContext(ctx,
|
||||||
`SELECT gift_id, price_version, coin_price, gift_point_amount, heat_value
|
`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
|
||||||
FROM wallet_gift_prices
|
FROM wallet_gift_prices
|
||||||
WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`,
|
WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`,
|
||||||
appcode.FromContext(ctx), giftID, priceVersion, nowMs,
|
appcode.FromContext(ctx), giftID, priceVersion, nowMs,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
row = tx.QueryRowContext(ctx,
|
row = tx.QueryRowContext(ctx,
|
||||||
`SELECT gift_id, price_version, coin_price, gift_point_amount, heat_value
|
`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
|
||||||
FROM wallet_gift_prices
|
FROM wallet_gift_prices
|
||||||
WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
||||||
ORDER BY effective_at_ms DESC, price_version DESC
|
ORDER BY effective_at_ms DESC, price_version DESC
|
||||||
@ -626,7 +629,7 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st
|
|||||||
}
|
}
|
||||||
|
|
||||||
var price giftPrice
|
var price giftPrice
|
||||||
if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
|
if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
|
return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
|
||||||
}
|
}
|
||||||
@ -635,6 +638,7 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st
|
|||||||
if price.CoinPrice <= 0 || price.GiftPointAmount < 0 || price.HeatValue < 0 {
|
if price.CoinPrice <= 0 || price.GiftPointAmount < 0 || price.HeatValue < 0 {
|
||||||
return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid")
|
return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid")
|
||||||
}
|
}
|
||||||
|
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
|
||||||
return price, nil
|
return price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -849,6 +853,8 @@ type giftMetadata struct {
|
|||||||
CoinPrice int64 `json:"coin_price"`
|
CoinPrice int64 `json:"coin_price"`
|
||||||
GiftPointAmount int64 `json:"gift_point_amount"`
|
GiftPointAmount int64 `json:"gift_point_amount"`
|
||||||
HeatUnitValue int64 `json:"heat_unit_value"`
|
HeatUnitValue int64 `json:"heat_unit_value"`
|
||||||
|
ChargeAssetType string `json:"charge_asset_type"`
|
||||||
|
ChargeAmount int64 `json:"charge_amount"`
|
||||||
CoinSpent int64 `json:"coin_spent"`
|
CoinSpent int64 `json:"coin_spent"`
|
||||||
GiftPointAdded int64 `json:"gift_point_added"`
|
GiftPointAdded int64 `json:"gift_point_added"`
|
||||||
HeatValue int64 `json:"heat_value"`
|
HeatValue int64 `json:"heat_value"`
|
||||||
@ -890,10 +896,17 @@ type coinSellerTransferMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
|
func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
|
||||||
|
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
|
||||||
|
chargeAmount := metadata.ChargeAmount
|
||||||
|
if chargeAmount == 0 {
|
||||||
|
chargeAmount = metadata.CoinSpent
|
||||||
|
}
|
||||||
return ledger.Receipt{
|
return ledger.Receipt{
|
||||||
BillingReceiptID: metadata.BillingReceipt,
|
BillingReceiptID: metadata.BillingReceipt,
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
CoinSpent: metadata.CoinSpent,
|
CoinSpent: metadata.CoinSpent,
|
||||||
|
ChargeAssetType: chargeAssetType,
|
||||||
|
ChargeAmount: chargeAmount,
|
||||||
GiftPointAdded: metadata.GiftPointAdded,
|
GiftPointAdded: metadata.GiftPointAdded,
|
||||||
HeatValue: metadata.HeatValue,
|
HeatValue: metadata.HeatValue,
|
||||||
PriceVersion: metadata.PriceVersion,
|
PriceVersion: metadata.PriceVersion,
|
||||||
|
|||||||
@ -540,6 +540,7 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
|
|||||||
price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
|
price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
|
||||||
if priceErr == nil {
|
if priceErr == nil {
|
||||||
item.PriceVersion = price.PriceVersion
|
item.PriceVersion = price.PriceVersion
|
||||||
|
item.ChargeAssetType = price.ChargeAssetType
|
||||||
item.CoinPrice = price.CoinPrice
|
item.CoinPrice = price.CoinPrice
|
||||||
item.GiftPointAmount = price.GiftPointAmount
|
item.GiftPointAmount = price.GiftPointAmount
|
||||||
item.HeatValue = price.HeatValue
|
item.HeatValue = price.HeatValue
|
||||||
@ -898,13 +899,19 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai
|
|||||||
}
|
}
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
presentation := normalizeJSONObject(command.PresentationJSON)
|
presentation := normalizeJSONObject(command.PresentationJSON)
|
||||||
|
effectTypesJSON, err := json.Marshal(command.EffectTypes)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.GiftConfig{}, err
|
||||||
|
}
|
||||||
if !update {
|
if !update {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO gift_configs (
|
INSERT INTO gift_configs (
|
||||||
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
||||||
|
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
|
||||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
|
command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
|
||||||
|
command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
|
||||||
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
|
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return resourcedomain.GiftConfig{}, err
|
return resourcedomain.GiftConfig{}, err
|
||||||
@ -913,9 +920,11 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai
|
|||||||
result, err := tx.ExecContext(ctx, `
|
result, err := tx.ExecContext(ctx, `
|
||||||
UPDATE gift_configs
|
UPDATE gift_configs
|
||||||
SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?,
|
SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?,
|
||||||
|
gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?,
|
||||||
updated_by_user_id = ?, updated_at_ms = ?
|
updated_by_user_id = ?, updated_at_ms = ?
|
||||||
WHERE app_code = ? AND gift_id = ?`,
|
WHERE app_code = ? AND gift_id = ?`,
|
||||||
command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
|
command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
|
||||||
|
command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
|
||||||
command.OperatorUserID, nowMs, command.AppCode, command.GiftID,
|
command.OperatorUserID, nowMs, command.AppCode, command.GiftID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1315,17 +1324,18 @@ func (r *Repository) listResourceGrantItemsWithQuery(ctx context.Context, querie
|
|||||||
func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error {
|
func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO wallet_gift_prices (
|
INSERT INTO wallet_gift_prices (
|
||||||
app_code, gift_id, price_version, status, coin_price, gift_point_amount,
|
app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount,
|
||||||
heat_value, effective_at_ms, created_at_ms, updated_at_ms
|
heat_value, effective_at_ms, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
|
charge_asset_type = VALUES(charge_asset_type),
|
||||||
coin_price = VALUES(coin_price),
|
coin_price = VALUES(coin_price),
|
||||||
gift_point_amount = VALUES(gift_point_amount),
|
gift_point_amount = VALUES(gift_point_amount),
|
||||||
heat_value = VALUES(heat_value),
|
heat_value = VALUES(heat_value),
|
||||||
effective_at_ms = VALUES(effective_at_ms),
|
effective_at_ms = VALUES(effective_at_ms),
|
||||||
updated_at_ms = VALUES(updated_at_ms)`,
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.CoinPrice,
|
appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.ChargeAssetType, command.CoinPrice,
|
||||||
command.GiftPointAmount, command.HeatValue, command.EffectiveAtMS, nowMs, nowMs,
|
command.GiftPointAmount, command.HeatValue, command.EffectiveAtMS, nowMs, nowMs,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@ -1333,7 +1343,7 @@ func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command
|
|||||||
|
|
||||||
func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) {
|
func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) {
|
||||||
row := querier.QueryRowContext(ctx,
|
row := querier.QueryRowContext(ctx,
|
||||||
`SELECT gift_id, price_version, coin_price, gift_point_amount, heat_value
|
`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
|
||||||
FROM wallet_gift_prices
|
FROM wallet_gift_prices
|
||||||
WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
||||||
ORDER BY effective_at_ms DESC, price_version DESC
|
ORDER BY effective_at_ms DESC, price_version DESC
|
||||||
@ -1341,12 +1351,13 @@ func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier,
|
|||||||
appcode.FromContext(ctx), giftID, nowMs,
|
appcode.FromContext(ctx), giftID, nowMs,
|
||||||
)
|
)
|
||||||
var price giftPrice
|
var price giftPrice
|
||||||
if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
|
if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
|
return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
|
||||||
}
|
}
|
||||||
return giftPrice{}, err
|
return giftPrice{}, err
|
||||||
}
|
}
|
||||||
|
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
|
||||||
return price, nil
|
return price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1362,6 +1373,7 @@ func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resource
|
|||||||
price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
|
price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
|
||||||
if priceErr == nil {
|
if priceErr == nil {
|
||||||
gift.PriceVersion = price.PriceVersion
|
gift.PriceVersion = price.PriceVersion
|
||||||
|
gift.ChargeAssetType = price.ChargeAssetType
|
||||||
gift.CoinPrice = price.CoinPrice
|
gift.CoinPrice = price.CoinPrice
|
||||||
gift.GiftPointAmount = price.GiftPointAmount
|
gift.GiftPointAmount = price.GiftPointAmount
|
||||||
gift.HeatValue = price.HeatValue
|
gift.HeatValue = price.HeatValue
|
||||||
@ -1374,8 +1386,11 @@ func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resource
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
|
func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`
|
row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`
|
||||||
WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
|
WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
|
||||||
|
AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
|
||||||
|
AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM gift_config_regions gcr
|
FROM gift_config_regions gcr
|
||||||
@ -1384,7 +1399,7 @@ func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, gi
|
|||||||
AND (gcr.region_id = 0 OR gcr.region_id = ?)
|
AND (gcr.region_id = 0 OR gcr.region_id = ?)
|
||||||
)
|
)
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
appcode.FromContext(ctx), giftID, regionID,
|
appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID,
|
||||||
)
|
)
|
||||||
gift, err := scanGiftConfig(row)
|
gift, err := scanGiftConfig(row)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@ -1709,6 +1724,7 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
|||||||
var gift resourcedomain.GiftConfig
|
var gift resourcedomain.GiftConfig
|
||||||
var resource resourcedomain.Resource
|
var resource resourcedomain.Resource
|
||||||
var scopesJSON string
|
var scopesJSON string
|
||||||
|
var effectTypesJSON string
|
||||||
if err := scanner.Scan(
|
if err := scanner.Scan(
|
||||||
&gift.AppCode,
|
&gift.AppCode,
|
||||||
&gift.GiftID,
|
&gift.GiftID,
|
||||||
@ -1717,6 +1733,10 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
|||||||
&gift.Name,
|
&gift.Name,
|
||||||
&gift.SortOrder,
|
&gift.SortOrder,
|
||||||
&gift.PresentationJSON,
|
&gift.PresentationJSON,
|
||||||
|
&gift.GiftTypeCode,
|
||||||
|
&gift.EffectiveFromMS,
|
||||||
|
&gift.EffectiveToMS,
|
||||||
|
&effectTypesJSON,
|
||||||
&gift.CreatedByUserID,
|
&gift.CreatedByUserID,
|
||||||
&gift.UpdatedByUserID,
|
&gift.UpdatedByUserID,
|
||||||
&gift.CreatedAtMS,
|
&gift.CreatedAtMS,
|
||||||
@ -1746,6 +1766,7 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
|||||||
}
|
}
|
||||||
resource.UsageScopes = parseStringArray(scopesJSON)
|
resource.UsageScopes = parseStringArray(scopesJSON)
|
||||||
gift.Resource = resource
|
gift.Resource = resource
|
||||||
|
gift.EffectTypes = parseStringArray(effectTypesJSON)
|
||||||
return gift, nil
|
return gift, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1848,6 +1869,8 @@ func giftConfigSelectSQL() string {
|
|||||||
return `
|
return `
|
||||||
SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order,
|
SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order,
|
||||||
COALESCE(CAST(gc.presentation_json AS CHAR), '{}'),
|
COALESCE(CAST(gc.presentation_json AS CHAR), '{}'),
|
||||||
|
COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0),
|
||||||
|
COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'),
|
||||||
gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms,
|
gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms,
|
||||||
` + resourceColumnsWithAlias("r") + `
|
` + resourceColumnsWithAlias("r") + `
|
||||||
FROM gift_configs gc
|
FROM gift_configs gc
|
||||||
@ -1916,7 +1939,11 @@ func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []an
|
|||||||
where := `WHERE gc.app_code = ?`
|
where := `WHERE gc.app_code = ?`
|
||||||
args := []any{query.AppCode}
|
args := []any{query.AppCode}
|
||||||
if query.ActiveOnly {
|
if query.ActiveOnly {
|
||||||
where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'`
|
where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
|
||||||
|
AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
|
||||||
|
AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)`
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
|
args = append(args, nowMs, nowMs)
|
||||||
} else if query.Status != "" {
|
} else if query.Status != "" {
|
||||||
where += ` AND gc.status = ?`
|
where += ` AND gc.status = ?`
|
||||||
args = append(args, query.Status)
|
args = append(args, query.Status)
|
||||||
@ -2107,9 +2134,21 @@ func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resour
|
|||||||
command.PresentationJSON = normalizeJSONObject(command.PresentationJSON)
|
command.PresentationJSON = normalizeJSONObject(command.PresentationJSON)
|
||||||
command.PriceVersion = strings.TrimSpace(command.PriceVersion)
|
command.PriceVersion = strings.TrimSpace(command.PriceVersion)
|
||||||
command.RegionIDs = normalizeRegionIDs(command.RegionIDs)
|
command.RegionIDs = normalizeRegionIDs(command.RegionIDs)
|
||||||
|
command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode)
|
||||||
|
command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType))
|
||||||
|
if command.ChargeAssetType == "" {
|
||||||
|
command.ChargeAssetType = ledger.AssetCoin
|
||||||
|
}
|
||||||
|
command.EffectTypes = normalizeGiftEffectTypes(command.EffectTypes)
|
||||||
if command.EffectiveAtMS < 0 {
|
if command.EffectiveAtMS < 0 {
|
||||||
command.EffectiveAtMS = 0
|
command.EffectiveAtMS = 0
|
||||||
}
|
}
|
||||||
|
if command.EffectiveFromMS < 0 {
|
||||||
|
command.EffectiveFromMS = 0
|
||||||
|
}
|
||||||
|
if command.EffectiveToMS < 0 {
|
||||||
|
command.EffectiveToMS = 0
|
||||||
|
}
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2123,6 +2162,20 @@ func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error {
|
|||||||
if command.CoinPrice <= 0 || command.GiftPointAmount < 0 || command.HeatValue < 0 {
|
if command.CoinPrice <= 0 || command.GiftPointAmount < 0 || command.HeatValue < 0 {
|
||||||
return xerr.New(xerr.InvalidArgument, "gift price is invalid")
|
return xerr.New(xerr.InvalidArgument, "gift price is invalid")
|
||||||
}
|
}
|
||||||
|
if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "gift type is invalid")
|
||||||
|
}
|
||||||
|
if !ledger.ValidGiftChargeAssetType(command.ChargeAssetType) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "gift charge asset type is invalid")
|
||||||
|
}
|
||||||
|
if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "gift effective time range is invalid")
|
||||||
|
}
|
||||||
|
for _, effectType := range command.EffectTypes {
|
||||||
|
if !resourcedomain.ValidGiftEffectType(effectType) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "gift effect type is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(command.RegionIDs) == 0 {
|
if len(command.RegionIDs) == 0 {
|
||||||
return xerr.New(xerr.InvalidArgument, "gift regions are required")
|
return xerr.New(xerr.InvalidArgument, "gift regions are required")
|
||||||
}
|
}
|
||||||
@ -2264,6 +2317,24 @@ func normalizeRegionIDs(values []int64) []int64 {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeGiftEffectTypes(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = resourcedomain.NormalizeGiftEffectType(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func parseStringArray(value string) []string {
|
func parseStringArray(value string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
if err := json.Unmarshal([]byte(value), &out); err != nil {
|
if err := json.Unmarshal([]byte(value), &out); err != nil {
|
||||||
|
|||||||
@ -80,18 +80,23 @@ func (r *Repository) Close() {
|
|||||||
|
|
||||||
// SetBalance prepares a sender COIN account for debit tests.
|
// SetBalance prepares a sender COIN account for debit tests.
|
||||||
func (r *Repository) SetBalance(userID int64, balance int64) {
|
func (r *Repository) SetBalance(userID int64, balance int64) {
|
||||||
|
r.SetAssetBalance(userID, "COIN", balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAssetBalance prepares a sender account for debit tests.
|
||||||
|
func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int64) {
|
||||||
r.t.Helper()
|
r.t.Helper()
|
||||||
|
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||||
INSERT INTO wallet_accounts (user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms)
|
INSERT INTO wallet_accounts (user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms)
|
||||||
VALUES (?, 'COIN', ?, 0, 1, ?, ?)
|
VALUES (?, ?, ?, 0, 1, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
available_amount = VALUES(available_amount),
|
available_amount = VALUES(available_amount),
|
||||||
frozen_amount = VALUES(frozen_amount),
|
frozen_amount = VALUES(frozen_amount),
|
||||||
version = version + 1,
|
version = version + 1,
|
||||||
updated_at_ms = VALUES(updated_at_ms)
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
`, userID, balance, nowMs, nowMs)
|
`, userID, assetType, balance, nowMs, nowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.t.Fatalf("seed wallet balance failed: %v", err)
|
r.t.Fatalf("seed wallet balance failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
60
services/wallet-service/internal/transport/grpc/recharge.go
Normal file
60
services/wallet-service/internal/transport/grpc/recharge.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRechargeBills 暴露充值账单只读查询;后台 HTTP 层负责登录态和菜单权限。
|
||||||
|
func (s *Server) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
|
items, total, err := s.svc.ListRechargeBills(ctx, ledger.ListRechargeBillsQuery{
|
||||||
|
AppCode: req.GetAppCode(),
|
||||||
|
UserID: req.GetUserId(),
|
||||||
|
SellerUserID: req.GetSellerUserId(),
|
||||||
|
RegionID: req.GetRegionId(),
|
||||||
|
RechargeType: req.GetRechargeType(),
|
||||||
|
Status: req.GetStatus(),
|
||||||
|
Keyword: req.GetKeyword(),
|
||||||
|
StartAtMS: req.GetStartAtMs(),
|
||||||
|
EndAtMS: req.GetEndAtMs(),
|
||||||
|
Page: req.GetPage(),
|
||||||
|
PageSize: req.GetPageSize(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &walletv1.ListRechargeBillsResponse{Bills: make([]*walletv1.RechargeBill, 0, len(items)), Total: total}
|
||||||
|
for _, item := range items {
|
||||||
|
resp.Bills = append(resp.Bills, rechargeBillToProto(item))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rechargeBillToProto(item ledger.RechargeBill) *walletv1.RechargeBill {
|
||||||
|
return &walletv1.RechargeBill{
|
||||||
|
AppCode: item.AppCode,
|
||||||
|
TransactionId: item.TransactionID,
|
||||||
|
CommandId: item.CommandID,
|
||||||
|
RechargeType: item.RechargeType,
|
||||||
|
Status: item.Status,
|
||||||
|
ExternalRef: item.ExternalRef,
|
||||||
|
UserId: item.UserID,
|
||||||
|
SellerUserId: item.SellerUserID,
|
||||||
|
SellerRegionId: item.SellerRegionID,
|
||||||
|
TargetRegionId: item.TargetRegionID,
|
||||||
|
PolicyId: item.PolicyID,
|
||||||
|
PolicyVersion: item.PolicyVersion,
|
||||||
|
CurrencyCode: item.CurrencyCode,
|
||||||
|
CoinAmount: item.CoinAmount,
|
||||||
|
UsdMinorAmount: item.USDMinorAmount,
|
||||||
|
ExchangeCoinAmount: item.ExchangeCoinAmount,
|
||||||
|
ExchangeUsdMinorAmount: item.ExchangeUSDMinorAmount,
|
||||||
|
CreatedAtMs: item.CreatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -339,6 +339,11 @@ func giftConfigCommandFromCreate(req *walletv1.CreateGiftConfigRequest) resource
|
|||||||
GiftPointAmount: req.GetGiftPointAmount(),
|
GiftPointAmount: req.GetGiftPointAmount(),
|
||||||
HeatValue: req.GetHeatValue(),
|
HeatValue: req.GetHeatValue(),
|
||||||
EffectiveAtMS: req.GetEffectiveAtMs(),
|
EffectiveAtMS: req.GetEffectiveAtMs(),
|
||||||
|
GiftTypeCode: req.GetGiftTypeCode(),
|
||||||
|
ChargeAssetType: req.GetChargeAssetType(),
|
||||||
|
EffectiveFromMS: req.GetEffectiveFromMs(),
|
||||||
|
EffectiveToMS: req.GetEffectiveToMs(),
|
||||||
|
EffectTypes: req.GetEffectTypes(),
|
||||||
OperatorUserID: req.GetOperatorUserId(),
|
OperatorUserID: req.GetOperatorUserId(),
|
||||||
RegionIDs: req.GetRegionIds(),
|
RegionIDs: req.GetRegionIds(),
|
||||||
}
|
}
|
||||||
@ -358,6 +363,11 @@ func giftConfigCommandFromUpdate(req *walletv1.UpdateGiftConfigRequest) resource
|
|||||||
GiftPointAmount: req.GetGiftPointAmount(),
|
GiftPointAmount: req.GetGiftPointAmount(),
|
||||||
HeatValue: req.GetHeatValue(),
|
HeatValue: req.GetHeatValue(),
|
||||||
EffectiveAtMS: req.GetEffectiveAtMs(),
|
EffectiveAtMS: req.GetEffectiveAtMs(),
|
||||||
|
GiftTypeCode: req.GetGiftTypeCode(),
|
||||||
|
ChargeAssetType: req.GetChargeAssetType(),
|
||||||
|
EffectiveFromMS: req.GetEffectiveFromMs(),
|
||||||
|
EffectiveToMS: req.GetEffectiveToMs(),
|
||||||
|
EffectTypes: req.GetEffectTypes(),
|
||||||
OperatorUserID: req.GetOperatorUserId(),
|
OperatorUserID: req.GetOperatorUserId(),
|
||||||
RegionIDs: req.GetRegionIds(),
|
RegionIDs: req.GetRegionIds(),
|
||||||
}
|
}
|
||||||
@ -452,9 +462,14 @@ func giftConfigToProto(gift resourcedomain.GiftConfig) *walletv1.GiftConfig {
|
|||||||
SortOrder: gift.SortOrder,
|
SortOrder: gift.SortOrder,
|
||||||
PresentationJson: gift.PresentationJSON,
|
PresentationJson: gift.PresentationJSON,
|
||||||
PriceVersion: gift.PriceVersion,
|
PriceVersion: gift.PriceVersion,
|
||||||
|
ChargeAssetType: gift.ChargeAssetType,
|
||||||
CoinPrice: gift.CoinPrice,
|
CoinPrice: gift.CoinPrice,
|
||||||
GiftPointAmount: gift.GiftPointAmount,
|
GiftPointAmount: gift.GiftPointAmount,
|
||||||
HeatValue: gift.HeatValue,
|
HeatValue: gift.HeatValue,
|
||||||
|
GiftTypeCode: gift.GiftTypeCode,
|
||||||
|
EffectiveFromMs: gift.EffectiveFromMS,
|
||||||
|
EffectiveToMs: gift.EffectiveToMS,
|
||||||
|
EffectTypes: gift.EffectTypes,
|
||||||
CreatedByUserId: gift.CreatedByUserID,
|
CreatedByUserId: gift.CreatedByUserID,
|
||||||
UpdatedByUserId: gift.UpdatedByUserID,
|
UpdatedByUserId: gift.UpdatedByUserID,
|
||||||
CreatedAtMs: gift.CreatedAtMS,
|
CreatedAtMs: gift.CreatedAtMS,
|
||||||
|
|||||||
@ -45,6 +45,8 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
|
|||||||
BalanceAfter: receipt.BalanceAfter,
|
BalanceAfter: receipt.BalanceAfter,
|
||||||
TransactionId: receipt.TransactionID,
|
TransactionId: receipt.TransactionID,
|
||||||
CoinSpent: receipt.CoinSpent,
|
CoinSpent: receipt.CoinSpent,
|
||||||
|
ChargeAssetType: receipt.ChargeAssetType,
|
||||||
|
ChargeAmount: receipt.ChargeAmount,
|
||||||
GiftPointAdded: receipt.GiftPointAdded,
|
GiftPointAdded: receipt.GiftPointAdded,
|
||||||
HeatValue: receipt.HeatValue,
|
HeatValue: receipt.HeatValue,
|
||||||
PriceVersion: receipt.PriceVersion,
|
PriceVersion: receipt.PriceVersion,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user