完善主播侧
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 heat_value = 5;
|
||||
string price_version = 6;
|
||||
// balance_after 是 sender COIN available_amount 账后余额。
|
||||
// balance_after 是 sender 被扣费资产 available_amount 账后余额。
|
||||
int64 balance_after = 7;
|
||||
string charge_asset_type = 8;
|
||||
int64 charge_amount = 9;
|
||||
}
|
||||
|
||||
// AssetBalance 是用户某类资产的余额投影。
|
||||
@ -168,6 +170,11 @@ message GiftConfig {
|
||||
int64 created_at_ms = 15;
|
||||
int64 updated_at_ms = 16;
|
||||
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 {
|
||||
@ -399,6 +406,11 @@ message CreateGiftConfigRequest {
|
||||
int64 effective_at_ms = 13;
|
||||
int64 operator_user_id = 14;
|
||||
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 {
|
||||
@ -417,6 +429,11 @@ message UpdateGiftConfigRequest {
|
||||
int64 effective_at_ms = 13;
|
||||
int64 operator_user_id = 14;
|
||||
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 {
|
||||
@ -495,6 +512,47 @@ message ListResourceGrantsResponse {
|
||||
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 承载。
|
||||
service WalletService {
|
||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||
@ -520,4 +578,5 @@ service WalletService {
|
||||
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
||||
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
||||
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
|
||||
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.2
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v7.34.1
|
||||
// source: proto/wallet/v1/wallet.proto
|
||||
|
||||
package walletv1
|
||||
@ -42,6 +42,7 @@ const (
|
||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
)
|
||||
|
||||
// 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)
|
||||
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, 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 {
|
||||
@ -313,6 +315,16 @@ func (c *walletServiceClient) ListResourceGrants(ctx context.Context, in *ListRe
|
||||
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.
|
||||
// All implementations must embed UnimplementedWalletServiceServer
|
||||
// for forward compatibility.
|
||||
@ -342,6 +354,7 @@ type WalletServiceServer interface {
|
||||
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
||||
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
||||
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
|
||||
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||
mustEmbedUnimplementedWalletServiceServer()
|
||||
}
|
||||
|
||||
@ -353,73 +366,76 @@ type WalletServiceServer interface {
|
||||
type UnimplementedWalletServiceServer struct{}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) testEmbeddedByValue() {}
|
||||
@ -432,7 +448,7 @@ type UnsafeWalletServiceServer interface {
|
||||
}
|
||||
|
||||
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
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// 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)
|
||||
}
|
||||
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -955,6 +989,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListResourceGrants",
|
||||
Handler: _WalletService_ListResourceGrants_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRechargeBills",
|
||||
Handler: _WalletService_ListRechargeBills_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/wallet/v1/wallet.proto",
|
||||
|
||||
@ -1307,9 +1307,15 @@ paths:
|
||||
required: false
|
||||
type: integer
|
||||
format: int32
|
||||
- name: region_id
|
||||
in: query
|
||||
required: false
|
||||
type: integer
|
||||
format: int64
|
||||
description: 区域 ID;返回该区域和 GLOBAL 兜底礼物。
|
||||
responses:
|
||||
"200":
|
||||
description: 返回 active 礼物配置;礼物资源必须来自 `resource_type=gift`。
|
||||
description: 返回当前 active、区域可用且在有效期内的礼物配置;礼物资源必须来自 `resource_type=gift`。
|
||||
schema:
|
||||
$ref: "#/definitions/GiftListEnvelope"
|
||||
"400":
|
||||
@ -2140,6 +2146,24 @@ definitions:
|
||||
type: string
|
||||
price_version:
|
||||
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:
|
||||
type: integer
|
||||
format: int64
|
||||
@ -2149,6 +2173,22 @@ definitions:
|
||||
heat_value:
|
||||
type: integer
|
||||
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:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@ -134,7 +134,17 @@ definitions:
|
||||
coin_spent:
|
||||
type: integer
|
||||
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:
|
||||
type: integer
|
||||
format: int64
|
||||
@ -149,7 +159,7 @@ definitions:
|
||||
balance_after:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 扣费后的发送方余额。
|
||||
description: 扣费后的发送方被扣费资产余额。
|
||||
HealthCheckRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
- 资源组只表达“打包集合”,赠送资源组时按当时组内容展开成多条资源赠送事实;组内可以直接配置 `COIN` 和 `DIAMOND` 钱包资产数量。
|
||||
- 资源和资源组都可以通过后台命令赠送给用户,赠送必须可审计、可幂等、可追溯。
|
||||
- 金币仍由 `wallet-service` 资产账本管理;资源库里的金币资源只是对 `COIN` 入账规则的配置包装。
|
||||
- 礼物价格、礼物热度、主播积分仍由 `wallet-service` 结算,客户端不能提交价格、热度或积分。
|
||||
- 礼物价格、收费资产、礼物热度、主播积分和有效期仍由 `wallet-service` 结算,客户端不能提交价格、收费类型、热度或积分。
|
||||
- 按 `app_code` 隔离资源、资源组、礼物配置、赠送和用户持有状态。
|
||||
|
||||
## Non-Goals
|
||||
@ -38,7 +38,7 @@
|
||||
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
|
||||
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
|
||||
| `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 天、某个徽章永久。 |
|
||||
| `wallet_asset_resource` | 会转成钱包资产入账的资源,例如金币。实际余额仍写 `wallet_accounts`。 |
|
||||
| `grant` | 后台或系统向用户发放资源或资源组的命令事实。 |
|
||||
@ -57,17 +57,44 @@
|
||||
|
||||
## Gift Boundary
|
||||
|
||||
礼物不是独立上传资源。新增礼物必须从 `resources` 中选择一个 `resource_type=gift` 的资源,然后配置礼物价格和结算值。
|
||||
礼物不是独立上传资源。新增礼物必须从 `resources` 中选择一个 `resource_type=gift` 的资源,然后配置礼物类型、有效期、特效、价格、收费资产和结算值。
|
||||
|
||||
礼物拆成两层:
|
||||
礼物拆成三层:
|
||||
|
||||
| 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` 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 后,新发送使用新 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
|
||||
|
||||
@ -198,13 +225,34 @@ gift_configs(
|
||||
name VARCHAR(128) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
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,
|
||||
updated_by_user_id BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, gift_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(
|
||||
@ -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`。
|
||||
- 同一资源组内同一个 `resource_id` 不能重复,同一种钱包资产也不能重复。
|
||||
- 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
|
||||
```
|
||||
|
||||
礼物列表按区域过滤:传入 `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 | 礼物目标、礼物表现和客户端动画联调 |
|
||||
| 房间热度 | `DONE` | `RoomSnapshot.heat` 和 `RoomHeatChanged` 事件 | 热度衰减/排行榜周期未做 |
|
||||
| 本地礼物榜 | `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` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 |
|
||||
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,主事件同步 IM | 客户端去重和展示策略未验收 |
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ import (
|
||||
jobmodule "hyapp-admin-server/internal/modules/job"
|
||||
menumodule "hyapp-admin-server/internal/modules/menu"
|
||||
notificationmodule "hyapp-admin-server/internal/modules/notification"
|
||||
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
||||
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
||||
@ -185,6 +186,7 @@ func main() {
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
Notification: notificationmodule.New(store, auditHandler),
|
||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn)),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(roomDB, userDB, auditHandler),
|
||||
|
||||
@ -228,7 +228,7 @@ func fromProtoRegion(region *userv1.Region) *Region {
|
||||
RegionCode: region.GetRegionCode(),
|
||||
Name: region.GetName(),
|
||||
Status: region.GetStatus(),
|
||||
Countries: append([]string(nil), region.GetCountries()...),
|
||||
Countries: append([]string{}, region.GetCountries()...),
|
||||
SortOrder: region.GetSortOrder(),
|
||||
CreatedAtMs: region.GetCreatedAtMs(),
|
||||
UpdatedAtMs: region.GetUpdatedAtMs(),
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Client 是 admin-server 对 wallet-service 资源管理 RPC 的最小依赖。
|
||||
// Client 是 admin-server 对 wallet-service 资源和账务只读 RPC 的最小依赖。
|
||||
type Client interface {
|
||||
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, 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)
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
||||
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
|
||||
}
|
||||
|
||||
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) {
|
||||
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"
|
||||
}
|
||||
|
||||
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 {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"userId"`
|
||||
|
||||
@ -2,7 +2,10 @@ package appconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"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)))
|
||||
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"`
|
||||
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.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"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
)
|
||||
@ -36,6 +38,22 @@ type H5Link struct {
|
||||
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 {
|
||||
return &AppConfigService{store: store}
|
||||
}
|
||||
@ -102,6 +120,62 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
|
||||
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 {
|
||||
out := make(map[string]string, len(h5LinkDefinitions))
|
||||
for _, definition := range h5LinkDefinitions {
|
||||
@ -124,3 +198,101 @@ func validateH5URL(value string) error {
|
||||
}
|
||||
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((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(u.region_id, 0),
|
||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), ''),
|
||||
CASE
|
||||
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.created_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((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(u.region_id, 0),
|
||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), ''),
|
||||
CASE
|
||||
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.created_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.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
|
||||
LIMIT 1
|
||||
), 0)
|
||||
|
||||
@ -354,6 +354,10 @@ func writeCountryRegionError(c *gin.Context, err error, fallback string) {
|
||||
response.BadRequest(c, "国家已归属其他启用区域")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errGlobalRegionProtected) {
|
||||
response.BadRequest(c, "GLOBAL 区域不能停用或配置国家")
|
||||
return
|
||||
}
|
||||
if st, ok := status.FromError(err); ok {
|
||||
switch st.Code() {
|
||||
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
|
||||
|
||||
@ -17,9 +17,11 @@ import (
|
||||
)
|
||||
|
||||
const countryRegionCaller = "hyapp-admin-server"
|
||||
const globalRegionCode = "GLOBAL"
|
||||
const regionStatusActive = "active"
|
||||
const regionStatusDisabled = "disabled"
|
||||
const regionRebuildTaskStatusPending = "pending"
|
||||
const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'"
|
||||
const maxCountryNameLength = 128
|
||||
const maxCountryDisplayNameLength = 128
|
||||
const maxCountryFlagLength = 16
|
||||
@ -33,13 +35,14 @@ var (
|
||||
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
||||
descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`)
|
||||
|
||||
errCountryNotFound = errors.New("country not found")
|
||||
errCountryInvalid = errors.New("country payload invalid")
|
||||
errCountryConflict = errors.New("country already exists")
|
||||
errRegionNotFound = errors.New("region not found")
|
||||
errRegionInvalid = errors.New("region payload invalid")
|
||||
errRegionConflict = errors.New("region already exists")
|
||||
errRegionCountryMapping = errors.New("country already belongs to another active region")
|
||||
errCountryNotFound = errors.New("country not found")
|
||||
errCountryInvalid = errors.New("country payload invalid")
|
||||
errCountryConflict = errors.New("country already exists")
|
||||
errRegionNotFound = errors.New("region not found")
|
||||
errRegionInvalid = errors.New("region payload invalid")
|
||||
errRegionConflict = errors.New("region already exists")
|
||||
errRegionCountryMapping = errors.New("country already belongs to another active region")
|
||||
errGlobalRegionProtected = errors.New("global region is protected")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@ -170,11 +173,20 @@ func (s *Service) CreateRegion(ctx context.Context, actorID int64, requestID str
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
countries, err := canonicalCountryCodes(ctx, tx, req.Countries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
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)
|
||||
@ -265,6 +277,9 @@ func (s *Service) setRegionEnabled(ctx context.Context, actorID int64, regionID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled && isGlobalRegionCode(before.RegionCode) {
|
||||
return nil, errGlobalRegionProtected
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
nextStatus := regionStatusDisabled
|
||||
@ -544,9 +559,15 @@ func validRegionCreateRequest(req createRegionRequest) bool {
|
||||
if !validRegionCode(req.RegionCode) {
|
||||
return false
|
||||
}
|
||||
if isRetiredRegionCode(req.RegionCode) {
|
||||
return false
|
||||
}
|
||||
if req.Name == "" || utf8.RuneCountInString(req.Name) > maxRegionNameLength {
|
||||
return false
|
||||
}
|
||||
if isGlobalRegionCode(req.RegionCode) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if len(countries) == 0 {
|
||||
return nil, errRegionInvalid
|
||||
@ -586,6 +620,41 @@ func canonicalCountryCodes(ctx context.Context, q regionQueryer, countries []str
|
||||
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 {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
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"`
|
||||
PresentationJSON string `json:"presentationJson"`
|
||||
PriceVersion string `json:"priceVersion"`
|
||||
GiftTypeCode string `json:"giftTypeCode"`
|
||||
ChargeAssetType string `json:"chargeAssetType"`
|
||||
CoinPrice int64 `json:"coinPrice"`
|
||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||
HeatValue int64 `json:"heatValue"`
|
||||
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
||||
EffectiveToMS int64 `json:"effectiveToMs"`
|
||||
EffectTypes []string `json:"effectTypes"`
|
||||
CreatedByUserID int64 `json:"createdByUserId"`
|
||||
UpdatedByUserID int64 `json:"updatedByUserId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
@ -189,9 +194,14 @@ func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
|
||||
SortOrder: gift.GetSortOrder(),
|
||||
PresentationJSON: gift.GetPresentationJson(),
|
||||
PriceVersion: gift.GetPriceVersion(),
|
||||
GiftTypeCode: gift.GetGiftTypeCode(),
|
||||
ChargeAssetType: gift.GetChargeAssetType(),
|
||||
CoinPrice: gift.GetCoinPrice(),
|
||||
GiftPointAmount: gift.GetGiftPointAmount(),
|
||||
HeatValue: gift.GetHeatValue(),
|
||||
EffectiveFromMS: gift.GetEffectiveFromMs(),
|
||||
EffectiveToMS: gift.GetEffectiveToMs(),
|
||||
EffectTypes: gift.GetEffectTypes(),
|
||||
CreatedByUserID: gift.GetCreatedByUserId(),
|
||||
UpdatedByUserID: gift.GetUpdatedByUserId(),
|
||||
CreatedAtMS: gift.GetCreatedAtMs(),
|
||||
|
||||
@ -42,18 +42,23 @@ type resourceGroupItemRequest struct {
|
||||
}
|
||||
|
||||
type giftRequest struct {
|
||||
GiftID string `json:"giftId"`
|
||||
ResourceID int64 `json:"resourceId"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
PresentationJSON string `json:"presentationJson"`
|
||||
PriceVersion string `json:"priceVersion"`
|
||||
CoinPrice int64 `json:"coinPrice"`
|
||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||
HeatValue int64 `json:"heatValue"`
|
||||
EffectiveAtMS int64 `json:"effectiveAtMs"`
|
||||
RegionIDs []int64 `json:"regionIds"`
|
||||
GiftID string `json:"giftId"`
|
||||
ResourceID int64 `json:"resourceId"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
PresentationJSON string `json:"presentationJson"`
|
||||
PriceVersion string `json:"priceVersion"`
|
||||
GiftTypeCode string `json:"giftTypeCode"`
|
||||
ChargeAssetType string `json:"chargeAssetType"`
|
||||
CoinPrice int64 `json:"coinPrice"`
|
||||
GiftPointAmount int64 `json:"giftPointAmount"`
|
||||
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 {
|
||||
@ -161,10 +166,15 @@ func (r giftRequest) createProto(c *gin.Context) *walletv1.CreateGiftConfigReque
|
||||
SortOrder: r.SortOrder,
|
||||
PresentationJson: strings.TrimSpace(r.PresentationJSON),
|
||||
PriceVersion: strings.TrimSpace(r.PriceVersion),
|
||||
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
|
||||
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
|
||||
CoinPrice: r.CoinPrice,
|
||||
GiftPointAmount: r.GiftPointAmount,
|
||||
HeatValue: r.HeatValue,
|
||||
EffectiveAtMs: r.EffectiveAtMS,
|
||||
EffectiveFromMs: r.EffectiveFromMS,
|
||||
EffectiveToMs: r.EffectiveToMS,
|
||||
EffectTypes: r.EffectTypes,
|
||||
OperatorUserId: actorID(c),
|
||||
RegionIds: r.RegionIDs,
|
||||
}
|
||||
@ -181,10 +191,15 @@ func (r giftRequest) updateProto(c *gin.Context, giftID string) *walletv1.Update
|
||||
SortOrder: r.SortOrder,
|
||||
PresentationJson: strings.TrimSpace(r.PresentationJSON),
|
||||
PriceVersion: strings.TrimSpace(r.PriceVersion),
|
||||
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
|
||||
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
|
||||
CoinPrice: r.CoinPrice,
|
||||
GiftPointAmount: r.GiftPointAmount,
|
||||
HeatValue: r.HeatValue,
|
||||
EffectiveAtMs: r.EffectiveAtMS,
|
||||
EffectiveFromMs: r.EffectiveFromMS,
|
||||
EffectiveToMs: r.EffectiveToMS,
|
||||
EffectTypes: r.EffectTypes,
|
||||
OperatorUserId: actorID(c),
|
||||
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.Menu{},
|
||||
&model.AppConfig{},
|
||||
&model.AppBanner{},
|
||||
&model.RefreshToken{},
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
|
||||
@ -55,6 +55,7 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "币商查看", Code: "coin-seller:view", Kind: "menu"},
|
||||
{Name: "币商创建", Code: "coin-seller:create", 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:create", Kind: "button"},
|
||||
{Name: "角色更新", Code: "role:update", Kind: "button"},
|
||||
@ -101,6 +102,7 @@ func (s *Store) seedMenus() error {
|
||||
appConfigID := uint(0)
|
||||
resourceID := uint(0)
|
||||
roomsID := uint(0)
|
||||
paymentID := uint(0)
|
||||
menus := []model.Menu{
|
||||
{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},
|
||||
@ -110,6 +112,7 @@ func (s *Store) seedMenus() error {
|
||||
{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: "资源管理", 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: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, 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" {
|
||||
roomsID = syncedMenu.ID
|
||||
}
|
||||
if syncedMenu.Code == "payment" {
|
||||
paymentID = syncedMenu.ID
|
||||
}
|
||||
}
|
||||
|
||||
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: &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: "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-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: "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-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},
|
||||
@ -402,13 +410,14 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"agency:view", "agency:create", "agency:status",
|
||||
"bd:view", "bd:create", "bd:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
||||
"payment-bill:view",
|
||||
"log:view",
|
||||
"notification:view", "notification:read",
|
||||
"job:view", "job:cancel", "export:create",
|
||||
"upload:create",
|
||||
}
|
||||
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":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -426,6 +435,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"agency:view",
|
||||
"bd:view",
|
||||
"coin-seller:view",
|
||||
"payment-bill:view",
|
||||
"role:view",
|
||||
"permission:view",
|
||||
"menu:view",
|
||||
@ -453,9 +463,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"country:view", "country:create", "country:update", "country:status",
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
||||
"payment-bill:view",
|
||||
}
|
||||
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:
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/job"
|
||||
"hyapp-admin-server/internal/modules/menu"
|
||||
"hyapp-admin-server/internal/modules/notification"
|
||||
"hyapp-admin-server/internal/modules/payment"
|
||||
"hyapp-admin-server/internal/modules/rbac"
|
||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||
"hyapp-admin-server/internal/modules/roomadmin"
|
||||
@ -40,6 +41,7 @@ type Handlers struct {
|
||||
Job *job.Handler
|
||||
Menu *menu.Handler
|
||||
Notification *notification.Handler
|
||||
Payment *payment.Handler
|
||||
RBAC *rbac.Handler
|
||||
Resource *resourcemodule.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)
|
||||
audit.RegisterRoutes(protected, h.Audit)
|
||||
notification.RegisterRoutes(protected, h.Notification)
|
||||
payment.RegisterRoutes(protected, h.Payment)
|
||||
job.RegisterRoutes(protected, h.Job)
|
||||
upload.RegisterRoutes(protected, h.Upload)
|
||||
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 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 入口配置。
|
||||
type H5Link struct {
|
||||
@ -22,9 +30,32 @@ type H5Link struct {
|
||||
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 配置的最小依赖。
|
||||
type Reader interface {
|
||||
ListH5Links(ctx context.Context) ([]H5Link, error)
|
||||
ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error)
|
||||
}
|
||||
|
||||
type h5LinkDefinition struct {
|
||||
@ -115,10 +146,57 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) {
|
||||
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 配置读取。
|
||||
type StaticReader struct {
|
||||
Links []H5Link
|
||||
Err error
|
||||
Links []H5Link
|
||||
Banners []Banner
|
||||
Err error
|
||||
}
|
||||
|
||||
// ListH5Links 返回预置 H5 配置。
|
||||
@ -131,3 +209,30 @@ func (r StaticReader) ListH5Links(context.Context) ([]H5Link, error) {
|
||||
copy(out, r.Links)
|
||||
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 (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
)
|
||||
|
||||
// appConfigReader 是 gateway 对后台 App 配置的只读依赖。
|
||||
type appConfigReader interface {
|
||||
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
|
||||
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
|
||||
}
|
||||
|
||||
// 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)})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
PresentationJSON string `json:"presentation_json"`
|
||||
PriceVersion string `json:"price_version"`
|
||||
GiftTypeCode string `json:"gift_type_code"`
|
||||
ChargeAssetType string `json:"charge_asset_type"`
|
||||
CoinPrice int64 `json:"coin_price"`
|
||||
GiftPointAmount int64 `json:"gift_point_amount"`
|
||||
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"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
@ -317,9 +322,14 @@ func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
|
||||
SortOrder: gift.GetSortOrder(),
|
||||
PresentationJSON: gift.GetPresentationJson(),
|
||||
PriceVersion: gift.GetPriceVersion(),
|
||||
GiftTypeCode: gift.GetGiftTypeCode(),
|
||||
ChargeAssetType: gift.GetChargeAssetType(),
|
||||
CoinPrice: gift.GetCoinPrice(),
|
||||
GiftPointAmount: gift.GetGiftPointAmount(),
|
||||
HeatValue: gift.GetHeatValue(),
|
||||
EffectiveFromMS: gift.GetEffectiveFromMs(),
|
||||
EffectiveToMS: gift.GetEffectiveToMs(),
|
||||
EffectTypes: gift.GetEffectTypes(),
|
||||
CreatedAtMS: gift.GetCreatedAtMs(),
|
||||
UpdatedAtMS: gift.GetUpdatedAtMs(),
|
||||
RegionIDs: gift.GetRegionIds(),
|
||||
|
||||
@ -918,6 +918,57 @@ func TestListH5LinksRequiresConfigReader(t *testing.T) {
|
||||
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) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
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+"/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+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup))
|
||||
mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts))
|
||||
|
||||
@ -596,30 +596,24 @@ ON DUPLICATE KEY UPDATE
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
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),
|
||||
('Western Asia', '西亚', 'active', 20, 0, 0),
|
||||
('Southern Asia', '南亚', 'active', 30, 0, 0),
|
||||
('Caribbean', '加勒比地区', 'active', 40, 0, 0),
|
||||
('Southeast Europe', '东南欧', 'active', 50, 0, 0),
|
||||
('Middle Africa', '中非', 'active', 60, 0, 0),
|
||||
('UNSPECIFIED', 'UNSPECIFIED', 'active', 70, 0, 0),
|
||||
('South America', '南美洲', 'active', 80, 0, 0),
|
||||
('Polynesia', '波利尼西亚', 'active', 90, 0, 0),
|
||||
('Central Europe', '中欧', 'active', 100, 0, 0),
|
||||
('Australia and New Zealand', '澳大利亚和新西兰', 'active', 110, 0, 0),
|
||||
('Northern Europe', '北欧', 'active', 120, 0, 0),
|
||||
('Western Europe', '西欧', 'active', 130, 0, 0),
|
||||
('Western Africa', '西非', 'active', 140, 0, 0),
|
||||
('Eastern Africa', '东非', 'active', 150, 0, 0),
|
||||
('North America', '北美洲', 'active', 160, 0, 0),
|
||||
('South-Eastern Asia', '东南亚', 'active', 170, 0, 0),
|
||||
('Southern Africa', '南部非洲', 'active', 180, 0, 0),
|
||||
('Eastern Europe', '东欧', 'active', 190, 0, 0),
|
||||
('Central America', '中美洲', 'active', 200, 0, 0),
|
||||
('Eastern Asia', '东亚', 'active', 210, 0, 0),
|
||||
('Northern Africa', '北非', 'active', 220, 0, 0),
|
||||
('Melanesia', '美拉尼西亚', 'active', 230, 0, 0),
|
||||
('Micronesia', '密克罗尼西亚', 'active', 240, 0, 0),
|
||||
('Central Asia', '中亚', 'active', 250, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
@ -1131,6 +1125,7 @@ FROM (
|
||||
SELECT 'ZW' AS country_code, 'Eastern Africa' AS region_code
|
||||
) AS seed
|
||||
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
|
||||
ON DUPLICATE KEY UPDATE
|
||||
region_id = VALUES(region_id),
|
||||
|
||||
@ -6,6 +6,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// GlobalRegionCode 是没有命中任何显式国家映射时使用的兜底区域。
|
||||
GlobalRegionCode = "GLOBAL"
|
||||
|
||||
// RegionStatusActive 表示区域映射参与国家到区域的实时解析。
|
||||
RegionStatusActive = "active"
|
||||
// RegionStatusDisabled 表示区域停止解析并释放 active 国家归属。
|
||||
@ -30,6 +33,15 @@ var (
|
||||
phoneCountryCodePattern = regexp.MustCompile(`^\+[1-9][0-9]{0,2}$`)
|
||||
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
||||
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。
|
||||
@ -223,3 +235,14 @@ func NormalizeRegionCode(code string) string {
|
||||
func ValidRegionCode(code string) bool {
|
||||
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
|
||||
// Country 是 active countries.country_code 中的规范国家码。
|
||||
Country string
|
||||
// RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示进入 GLOBAL。
|
||||
// RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示 GLOBAL 兜底桶。
|
||||
RegionID int64
|
||||
// CompletedAtMs 是资料完成时间,同时作为 updated_at_ms。
|
||||
CompletedAtMs int64
|
||||
@ -241,7 +241,7 @@ type CountryChangeCommand struct {
|
||||
UserID int64
|
||||
// NewCountry 是新的国家或地区。
|
||||
NewCountry string
|
||||
// NewRegionID 是按 NewCountry 解析出的区域快照,0 表示无区域。
|
||||
// NewRegionID 是按 NewCountry 解析出的区域快照,0 表示 GLOBAL 兜底桶。
|
||||
NewRegionID int64
|
||||
// RequestID 是入口请求 ID,写入国家修改日志。
|
||||
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 {
|
||||
return userdomain.User{}, err
|
||||
} else if ok {
|
||||
// 国家可能暂时没有区域映射,region_id=0 时由房间列表落到 GLOBAL。
|
||||
// 国家未命中显式区域时,user-service 会返回 region_id=0 的 GLOBAL 兜底桶。
|
||||
regionID = region.RegionID
|
||||
}
|
||||
|
||||
|
||||
@ -176,6 +176,9 @@ func validateRegionPayload(regionCode string, name string) error {
|
||||
if !userdomain.ValidRegionCode(regionCode) {
|
||||
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 {
|
||||
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)
|
||||
// ListRegistrationCountries 返回 App 注册页可选国家列表。
|
||||
ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error)
|
||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域;无映射返回 ok=false。
|
||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域;无显式映射时返回 GLOBAL 兜底区域。
|
||||
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) {
|
||||
// 必填字段和展示资料格式在 service 层稳定返回 INVALID_ARGUMENT,不能依赖 MySQL 约束报错。
|
||||
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)})
|
||||
}
|
||||
|
||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无映射返回 ok=false。
|
||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无显式映射时由 regions.go 返回 GLOBAL。
|
||||
|
||||
// UpdateCountry 修改国家展示字段;country_code 不允许原地修改。
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ import (
|
||||
"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) {
|
||||
country, ok, err := resolveCountryByCode(ctx, r.db, countryCode, true)
|
||||
if err != nil || !ok {
|
||||
@ -27,6 +29,7 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
||||
FROM region_countries rc
|
||||
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 = ?
|
||||
AND rg.region_code NOT IN (`+retiredRegionCodeSQLList+`)
|
||||
LIMIT 1
|
||||
`, appcode.FromContext(ctx), country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive).Scan(
|
||||
®ion.AppCode,
|
||||
@ -41,7 +44,7 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
||||
®ion.UpdatedAtMs,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return userdomain.Region{}, false, nil
|
||||
return globalRegionFromContext(ctx), true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return userdomain.Region{}, false, err
|
||||
@ -71,6 +74,12 @@ func (r *Repository) UpdateRegion(ctx context.Context, command userdomain.Update
|
||||
if err != nil {
|
||||
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, `
|
||||
UPDATE regions
|
||||
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 = ?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")")
|
||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||
query += " ORDER BY sort_order ASC, region_code ASC"
|
||||
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()
|
||||
|
||||
regions := []userdomain.Region{}
|
||||
hasGlobal := false
|
||||
for rows.Next() {
|
||||
region, err := scanRegion(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
||||
hasGlobal = true
|
||||
}
|
||||
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
region.Countries = countries
|
||||
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
||||
region.RegionID = 0
|
||||
region.Countries = []string{}
|
||||
}
|
||||
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 国家列表。
|
||||
@ -142,6 +167,9 @@ func (r *Repository) GetRegion(ctx context.Context, regionID int64) (userdomain.
|
||||
if err != nil {
|
||||
return userdomain.Region{}, err
|
||||
}
|
||||
if userdomain.IsRetiredRegionCode(region.RegionCode) {
|
||||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||||
}
|
||||
|
||||
return region, nil
|
||||
}
|
||||
@ -162,6 +190,12 @@ func (r *Repository) ReplaceRegionCountries(ctx context.Context, command userdom
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return userdomain.Region{}, err
|
||||
}
|
||||
@ -248,6 +285,17 @@ func scanRegion(scanner interface{ Scan(dest ...any) error }) (userdomain.Region
|
||||
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) {
|
||||
rows, err := queryRows(ctx, q, `
|
||||
SELECT country_code
|
||||
@ -272,6 +320,21 @@ func activeCountriesForRegion(ctx context.Context, q queryer, regionID int64) ([
|
||||
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 {
|
||||
beforeJSON, err := marshalNullableJSON(before)
|
||||
if err != nil {
|
||||
|
||||
@ -32,15 +32,49 @@ const userSelectColumns = `
|
||||
COALESCE(username, ''),
|
||||
COALESCE(gender, ''),
|
||||
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_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.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 rg.region_code FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), ''),
|
||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), ''),
|
||||
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.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(register_ip, ''),
|
||||
COALESCE(register_user_agent, ''),
|
||||
|
||||
@ -104,7 +104,7 @@ func toProtoRegion(region userdomain.Region) *userv1.Region {
|
||||
RegionCode: region.RegionCode,
|
||||
Name: region.Name,
|
||||
Status: region.Status,
|
||||
Countries: append([]string(nil), region.Countries...),
|
||||
Countries: append([]string{}, region.Countries...),
|
||||
SortOrder: region.SortOrder,
|
||||
CreatedAtMs: region.CreatedAtMs,
|
||||
UpdatedAtMs: region.UpdatedAtMs,
|
||||
|
||||
@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS wallet_gift_prices (
|
||||
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,
|
||||
@ -192,15 +193,65 @@ CREATE TABLE IF NOT EXISTS gift_configs (
|
||||
name VARCHAR(128) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
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,
|
||||
updated_by_user_id BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, gift_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;
|
||||
|
||||
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 (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
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;
|
||||
|
||||
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
|
||||
) VALUES
|
||||
('lalu', 'rose', 'v1', 'active', 10, 10, 10, 0, 0, 0),
|
||||
('lalu', 'rocket', 'v1', 'active', 100, 100, 100, 0, 0, 0);
|
||||
('lalu', 'rose', 'v1', 'active', 'COIN', 10, 10, 10, 0, 0, 0),
|
||||
('lalu', 'rocket', 'v1', 'active', 'COIN', 100, 100, 100, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO resources (
|
||||
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
|
||||
@ -293,18 +344,24 @@ INSERT IGNORE INTO resources (
|
||||
|
||||
INSERT IGNORE INTO gift_configs (
|
||||
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
|
||||
)
|
||||
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
|
||||
WHERE app_code = 'lalu' AND resource_code = 'gift_rose'
|
||||
LIMIT 1;
|
||||
|
||||
INSERT IGNORE INTO gift_configs (
|
||||
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
|
||||
)
|
||||
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
|
||||
WHERE app_code = 'lalu' AND resource_code = 'gift_rocket'
|
||||
LIMIT 1;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package ledger
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// AssetCoin 是用户送礼消费资产。
|
||||
AssetCoin = "COIN"
|
||||
@ -31,6 +33,8 @@ type Receipt struct {
|
||||
BillingReceiptID string
|
||||
TransactionID string
|
||||
CoinSpent int64
|
||||
ChargeAssetType string
|
||||
ChargeAmount int64
|
||||
GiftPointAdded int64
|
||||
HeatValue int64
|
||||
PriceVersion string
|
||||
@ -63,6 +67,43 @@ type CoinSellerTransferReceipt struct {
|
||||
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 是账户当前可用/冻结余额投影。
|
||||
type AssetBalance struct {
|
||||
AppCode string
|
||||
@ -95,3 +136,21 @@ func ValidAssetType(assetType string) bool {
|
||||
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"
|
||||
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 {
|
||||
@ -102,6 +117,11 @@ type GiftConfig struct {
|
||||
CoinPrice int64
|
||||
GiftPointAmount int64
|
||||
HeatValue int64
|
||||
GiftTypeCode string
|
||||
ChargeAssetType string
|
||||
EffectiveFromMS int64
|
||||
EffectiveToMS int64
|
||||
EffectTypes []string
|
||||
CreatedByUserID int64
|
||||
UpdatedByUserID int64
|
||||
CreatedAtMS int64
|
||||
@ -247,6 +267,11 @@ type GiftConfigCommand struct {
|
||||
GiftPointAmount int64
|
||||
HeatValue int64
|
||||
EffectiveAtMS int64
|
||||
GiftTypeCode string
|
||||
ChargeAssetType string
|
||||
EffectiveFromMS int64
|
||||
EffectiveToMS int64
|
||||
EffectTypes []string
|
||||
OperatorUserID int64
|
||||
RegionIDs []int64
|
||||
}
|
||||
@ -367,6 +392,36 @@ func NormalizeWalletAssetType(value string) string {
|
||||
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 {
|
||||
return NormalizeGrantStrategy(resource.GrantStrategy) == GrantStrategyWalletCredit
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ type Repository interface {
|
||||
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
|
||||
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, 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)
|
||||
GetResource(ctx context.Context, resourceID int64) (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)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/xerr"
|
||||
"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 兜底区域。
|
||||
func TestGiftConfigRegionFilter(t *testing.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 {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
coinSpent, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
|
||||
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
@ -122,11 +122,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
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 {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
if sender.AvailableAmount < coinSpent {
|
||||
if sender.AvailableAmount < chargeAmount {
|
||||
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
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),
|
||||
GiftCount: command.GiftCount,
|
||||
PriceVersion: price.PriceVersion,
|
||||
CoinSpent: coinSpent,
|
||||
ChargeAssetType: price.ChargeAssetType,
|
||||
ChargeAmount: chargeAmount,
|
||||
CoinSpent: chargeAmount,
|
||||
GiftPointAdded: giftPointAdded,
|
||||
HeatValue: heatValue,
|
||||
BalanceAfter: sender.AvailableAmount - coinSpent,
|
||||
BalanceAfter: sender.AvailableAmount - chargeAmount,
|
||||
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
|
||||
SenderUserID: command.SenderUserID,
|
||||
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 {
|
||||
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
|
||||
}
|
||||
senderAfter := sender.AvailableAmount - coinSpent
|
||||
senderAfter := sender.AvailableAmount - chargeAmount
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.SenderUserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableDelta: -coinSpent,
|
||||
AssetType: price.ChargeAssetType,
|
||||
AvailableDelta: -chargeAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: senderAfter,
|
||||
FrozenAfter: sender.FrozenAmount,
|
||||
@ -195,9 +197,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
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),
|
||||
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 {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
@ -590,6 +592,7 @@ func (r *Repository) applyAccountDelta(ctx context.Context, tx *sql.Tx, account
|
||||
type giftPrice struct {
|
||||
GiftID string
|
||||
PriceVersion string
|
||||
ChargeAssetType string
|
||||
CoinPrice int64
|
||||
GiftPointAmount int64
|
||||
HeatValue int64
|
||||
@ -609,14 +612,14 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st
|
||||
var row *sql.Row
|
||||
if strings.TrimSpace(priceVersion) != "" {
|
||||
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
|
||||
WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`,
|
||||
appcode.FromContext(ctx), giftID, priceVersion, nowMs,
|
||||
)
|
||||
} else {
|
||||
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
|
||||
WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
||||
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
|
||||
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) {
|
||||
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 {
|
||||
return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid")
|
||||
}
|
||||
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
|
||||
return price, nil
|
||||
}
|
||||
|
||||
@ -849,6 +853,8 @@ type giftMetadata struct {
|
||||
CoinPrice int64 `json:"coin_price"`
|
||||
GiftPointAmount int64 `json:"gift_point_amount"`
|
||||
HeatUnitValue int64 `json:"heat_unit_value"`
|
||||
ChargeAssetType string `json:"charge_asset_type"`
|
||||
ChargeAmount int64 `json:"charge_amount"`
|
||||
CoinSpent int64 `json:"coin_spent"`
|
||||
GiftPointAdded int64 `json:"gift_point_added"`
|
||||
HeatValue int64 `json:"heat_value"`
|
||||
@ -890,10 +896,17 @@ type coinSellerTransferMetadata struct {
|
||||
}
|
||||
|
||||
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{
|
||||
BillingReceiptID: metadata.BillingReceipt,
|
||||
TransactionID: transactionID,
|
||||
CoinSpent: metadata.CoinSpent,
|
||||
ChargeAssetType: chargeAssetType,
|
||||
ChargeAmount: chargeAmount,
|
||||
GiftPointAdded: metadata.GiftPointAdded,
|
||||
HeatValue: metadata.HeatValue,
|
||||
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)
|
||||
if priceErr == nil {
|
||||
item.PriceVersion = price.PriceVersion
|
||||
item.ChargeAssetType = price.ChargeAssetType
|
||||
item.CoinPrice = price.CoinPrice
|
||||
item.GiftPointAmount = price.GiftPointAmount
|
||||
item.HeatValue = price.HeatValue
|
||||
@ -898,13 +899,19 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
presentation := normalizeJSONObject(command.PresentationJSON)
|
||||
effectTypesJSON, err := json.Marshal(command.EffectTypes)
|
||||
if err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
}
|
||||
if !update {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO gift_configs (
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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,
|
||||
); err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
@ -913,9 +920,11 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE gift_configs
|
||||
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 = ?
|
||||
WHERE app_code = ? AND gift_id = ?`,
|
||||
command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
|
||||
command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
|
||||
command.OperatorUserID, nowMs, command.AppCode, command.GiftID,
|
||||
)
|
||||
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 {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
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
|
||||
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
charge_asset_type = VALUES(charge_asset_type),
|
||||
coin_price = VALUES(coin_price),
|
||||
gift_point_amount = VALUES(gift_point_amount),
|
||||
heat_value = VALUES(heat_value),
|
||||
effective_at_ms = VALUES(effective_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,
|
||||
)
|
||||
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) {
|
||||
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
|
||||
WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
||||
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,
|
||||
)
|
||||
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) {
|
||||
return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
|
||||
}
|
||||
return giftPrice{}, err
|
||||
}
|
||||
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
|
||||
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())
|
||||
if priceErr == nil {
|
||||
gift.PriceVersion = price.PriceVersion
|
||||
gift.ChargeAssetType = price.ChargeAssetType
|
||||
gift.CoinPrice = price.CoinPrice
|
||||
gift.GiftPointAmount = price.GiftPointAmount
|
||||
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) {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
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'
|
||||
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 (
|
||||
SELECT 1
|
||||
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 = ?)
|
||||
)
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx), giftID, regionID,
|
||||
appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID,
|
||||
)
|
||||
gift, err := scanGiftConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@ -1709,6 +1724,7 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
||||
var gift resourcedomain.GiftConfig
|
||||
var resource resourcedomain.Resource
|
||||
var scopesJSON string
|
||||
var effectTypesJSON string
|
||||
if err := scanner.Scan(
|
||||
&gift.AppCode,
|
||||
&gift.GiftID,
|
||||
@ -1717,6 +1733,10 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
||||
&gift.Name,
|
||||
&gift.SortOrder,
|
||||
&gift.PresentationJSON,
|
||||
&gift.GiftTypeCode,
|
||||
&gift.EffectiveFromMS,
|
||||
&gift.EffectiveToMS,
|
||||
&effectTypesJSON,
|
||||
&gift.CreatedByUserID,
|
||||
&gift.UpdatedByUserID,
|
||||
&gift.CreatedAtMS,
|
||||
@ -1746,6 +1766,7 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
||||
}
|
||||
resource.UsageScopes = parseStringArray(scopesJSON)
|
||||
gift.Resource = resource
|
||||
gift.EffectTypes = parseStringArray(effectTypesJSON)
|
||||
return gift, nil
|
||||
}
|
||||
|
||||
@ -1848,6 +1869,8 @@ func giftConfigSelectSQL() string {
|
||||
return `
|
||||
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(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,
|
||||
` + resourceColumnsWithAlias("r") + `
|
||||
FROM gift_configs gc
|
||||
@ -1916,7 +1939,11 @@ func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []an
|
||||
where := `WHERE gc.app_code = ?`
|
||||
args := []any{query.AppCode}
|
||||
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 != "" {
|
||||
where += ` AND gc.status = ?`
|
||||
args = append(args, query.Status)
|
||||
@ -2107,9 +2134,21 @@ func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resour
|
||||
command.PresentationJSON = normalizeJSONObject(command.PresentationJSON)
|
||||
command.PriceVersion = strings.TrimSpace(command.PriceVersion)
|
||||
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 {
|
||||
command.EffectiveAtMS = 0
|
||||
}
|
||||
if command.EffectiveFromMS < 0 {
|
||||
command.EffectiveFromMS = 0
|
||||
}
|
||||
if command.EffectiveToMS < 0 {
|
||||
command.EffectiveToMS = 0
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
@ -2123,6 +2162,20 @@ func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error {
|
||||
if command.CoinPrice <= 0 || command.GiftPointAmount < 0 || command.HeatValue < 0 {
|
||||
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 {
|
||||
return xerr.New(xerr.InvalidArgument, "gift regions are required")
|
||||
}
|
||||
@ -2264,6 +2317,24 @@ func normalizeRegionIDs(values []int64) []int64 {
|
||||
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 {
|
||||
var out []string
|
||||
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.
|
||||
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()
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
_, 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)
|
||||
VALUES (?, 'COIN', ?, 0, 1, ?, ?)
|
||||
VALUES (?, ?, ?, 0, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
available_amount = VALUES(available_amount),
|
||||
frozen_amount = VALUES(frozen_amount),
|
||||
version = version + 1,
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, userID, balance, nowMs, nowMs)
|
||||
`, userID, assetType, balance, nowMs, nowMs)
|
||||
if err != nil {
|
||||
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(),
|
||||
HeatValue: req.GetHeatValue(),
|
||||
EffectiveAtMS: req.GetEffectiveAtMs(),
|
||||
GiftTypeCode: req.GetGiftTypeCode(),
|
||||
ChargeAssetType: req.GetChargeAssetType(),
|
||||
EffectiveFromMS: req.GetEffectiveFromMs(),
|
||||
EffectiveToMS: req.GetEffectiveToMs(),
|
||||
EffectTypes: req.GetEffectTypes(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
RegionIDs: req.GetRegionIds(),
|
||||
}
|
||||
@ -358,6 +363,11 @@ func giftConfigCommandFromUpdate(req *walletv1.UpdateGiftConfigRequest) resource
|
||||
GiftPointAmount: req.GetGiftPointAmount(),
|
||||
HeatValue: req.GetHeatValue(),
|
||||
EffectiveAtMS: req.GetEffectiveAtMs(),
|
||||
GiftTypeCode: req.GetGiftTypeCode(),
|
||||
ChargeAssetType: req.GetChargeAssetType(),
|
||||
EffectiveFromMS: req.GetEffectiveFromMs(),
|
||||
EffectiveToMS: req.GetEffectiveToMs(),
|
||||
EffectTypes: req.GetEffectTypes(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
RegionIDs: req.GetRegionIds(),
|
||||
}
|
||||
@ -452,9 +462,14 @@ func giftConfigToProto(gift resourcedomain.GiftConfig) *walletv1.GiftConfig {
|
||||
SortOrder: gift.SortOrder,
|
||||
PresentationJson: gift.PresentationJSON,
|
||||
PriceVersion: gift.PriceVersion,
|
||||
ChargeAssetType: gift.ChargeAssetType,
|
||||
CoinPrice: gift.CoinPrice,
|
||||
GiftPointAmount: gift.GiftPointAmount,
|
||||
HeatValue: gift.HeatValue,
|
||||
GiftTypeCode: gift.GiftTypeCode,
|
||||
EffectiveFromMs: gift.EffectiveFromMS,
|
||||
EffectiveToMs: gift.EffectiveToMS,
|
||||
EffectTypes: gift.EffectTypes,
|
||||
CreatedByUserId: gift.CreatedByUserID,
|
||||
UpdatedByUserId: gift.UpdatedByUserID,
|
||||
CreatedAtMs: gift.CreatedAtMS,
|
||||
|
||||
@ -45,6 +45,8 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
|
||||
BalanceAfter: receipt.BalanceAfter,
|
||||
TransactionId: receipt.TransactionID,
|
||||
CoinSpent: receipt.CoinSpent,
|
||||
ChargeAssetType: receipt.ChargeAssetType,
|
||||
ChargeAmount: receipt.ChargeAmount,
|
||||
GiftPointAdded: receipt.GiftPointAdded,
|
||||
HeatValue: receipt.HeatValue,
|
||||
PriceVersion: receipt.PriceVersion,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user