完善主播侧

This commit is contained in:
z 2026-05-05 03:05:06 +08:00
parent 199ced57fc
commit d9fdb70016
56 changed files with 3657 additions and 2487 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View File

@ -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",

View File

@ -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:

View File

@ -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:

View File

@ -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-servicewallet-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-servicewallet-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`,客户端只能用于展示和选择,不能在送礼时回传这些值作为结算依据。
装备资源的边界:

View File

@ -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 | 客户端去重和展示策略未验收 |

View File

@ -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),

View File

@ -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(),

View File

@ -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)
}

View File

@ -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"`

View File

@ -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 ""
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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:

View File

@ -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 {

View 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(),
}
}

View 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 ""
}

View 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)
}

View File

@ -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(),

View File

@ -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,
}

View 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
}

View File

@ -65,6 +65,7 @@ func (s *Store) AutoMigrate() error {
&model.Permission{},
&model.Menu{},
&model.AppConfig{},
&model.AppBanner{},
&model.RefreshToken{},
&model.LoginLog{},
&model.OperationLog{},

View File

@ -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
}

View File

@ -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)

View 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;

View File

@ -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))
}

View File

@ -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
}

View File

@ -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(),

View File

@ -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&region_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{

View File

@ -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))

View File

@ -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),

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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 不允许原地修改。

View File

@ -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(
&region.AppCode,
@ -41,7 +44,7 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
&region.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 {

View File

@ -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, ''),

View File

@ -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,

View File

@ -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;

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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,

View File

@ -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 {

View File

@ -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)
}

View 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,
}
}

View File

@ -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,

View File

@ -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,