Compare commits

...

34 Commits

Author SHA1 Message Date
zhx
7515233227 房间贡献md 2026-07-06 12:53:36 +08:00
zhx
5a03a3d49a 资源撤回,系统消息 2026-07-06 12:43:40 +08:00
zhx
ccebdcb28a 去掉kpi相关 2026-07-06 11:46:58 +08:00
zhx
ff9321a1e2 登录时间,以及发货 2026-07-06 11:10:19 +08:00
zhx
f839e44375 aslan币商数据 2026-07-05 23:47:00 +08:00
zhx
15810e891d 批量删除 2026-07-05 14:55:37 +08:00
zhx
6c3841248e 添加权限 2026-07-05 14:16:12 +08:00
zhx
ff5df240dd 增加备注 2026-07-05 13:45:39 +08:00
zhx
4bd22c61d6 相关功能修复 2026-07-05 01:55:03 +08:00
zhx
dc44b0bf08 漏斗数据 2026-07-05 00:08:30 +08:00
zhx
b666a5ad99 fix room owner list scan columns 2026-07-04 22:23:59 +08:00
zhx
e5cd2a5ce7 修复数据以及上麦问题 2026-07-04 22:02:28 +08:00
zhx
7a2d292c36 取消vip门槛 2026-07-04 02:17:43 +08:00
zhx
0a072894ec 切换播报群 2026-07-04 01:53:10 +08:00
zhx
ec35fcad5e room_outbox 增加 delivered 历史事件定期清理并移除重复索引
- outbox_worker.retention 配置块(默认关闭,max_age<24h 拒绝启动)
- 清理走预留的 idx_room_outbox_retention 索引,先选主键再小批量 DELETE
- 移除与 idx_room_outbox_pending 完全重复的 idx_room_outbox_retry,
  auto_migrate=true 时 Migrate 自动 DROP
- 开启清理前需和数据侧确认保留窗口覆盖统计重放/补数工具

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:12:25 +08:00
zhx
41a65c244f 增加新的房间快照 2026-07-04 00:09:06 +08:00
zhx
b83c3f38d4 修复上麦失败问题 2026-07-03 22:55:56 +08:00
zhx
026a354b04 修改kpi制定规则 2026-07-03 22:42:48 +08:00
zhx
e8d2fd37a2 修复数据问题 2026-07-03 22:04:39 +08:00
zhx
ea347d2921 优化链路 2026-07-03 21:03:23 +08:00
zhx
d7894c5825 新数据指标 2026-07-03 19:52:20 +08:00
zhx
a2252a7115 数据打通 yumi aslan 2026-07-03 18:49:44 +08:00
zhx
c53b710539 Merge branch 'test' 2026-07-03 17:38:41 +08:00
zhx
786b36afb2 Yumi和 Aslan的财务数据 2026-07-03 17:35:47 +08:00
zhx
27773290b5 fix(admin): include timezone data in image 2026-07-03 16:07:20 +08:00
zhx
3d3ebb19a0 aslan数据 2026-07-03 15:53:36 +08:00
zhx
87081a6324 增加yaml文件 2026-07-03 15:06:36 +08:00
zhx
d8c07b07ac 修复了问题 2026-07-03 14:54:21 +08:00
zhx
0cd30e222d 添加埋点以及Yumi数据 2026-07-03 14:43:43 +08:00
zhx
0480304483 财务模块 2026-07-03 13:09:01 +08:00
zhx
a1e7514af3 先不启动 2026-07-03 11:35:23 +08:00
zhx
9e46ce0b02 币种汇率改变 2026-07-03 11:34:28 +08:00
zhx
c6ec9fe6aa 阿斯兰数据 2026-07-02 19:23:36 +08:00
zhx
7f2387ba95 fix(activity): sort achievements by priority 2026-07-02 15:46:27 +08:00
238 changed files with 28821 additions and 8666 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ redis-data/
.tmp/
/tmp/
.claude/settings.local.json

View File

@ -96,10 +96,10 @@
- 默认使用 Go 标准库和仓库已有小包;不要为了轻量逻辑引入重框架。
- 手写代码保持高密度有效注释,所有业务逻辑、状态流转、权限判断、支付链路、异步流程都要解释工程语义和失败分支,不复述语法。
- 新增配置必须同时考虑本地 `config.yaml`、Docker `config.docker.yaml`、以及线上配置示例。
- 所有逻辑功能配置默认开启;密钥、地址、三方账号等必须由运行环境提供的敏感配置只能使用占位符或环境配置,不能写入真实值,也不能伪造可用凭证
- 所有逻辑功能配置默认开启;密钥、地址、三方账号等敏感值只能由运行环境提供。可提交 YAML/示例配置只能写占位符;真实值只能写入已被 git 忽略的本地运行时 env 文件(例如 `.env.prod`)或部署平台环境变量,不能写入可提交文件
- 新增服务依赖必须在 `docker-compose.yml` 中体现本地可运行形态。
- 数据库表结构变更必须同步更新对应 owner service 的 `deploy/mysql/initdb`
- 不要把线上密钥、云数据库密码、Redis 密码写进真实配置。只能写占位示例
- 不要把线上密钥、云数据库密码、Redis 密码写进 YAML、示例配置、代码、测试或任何会进入 git 的文件;需要落地到文件时,只能写入 `.env.*` 这类已忽略的运行时 env 文件
- 保持 `gofmt`,不要手调 Go import 顺序。
## Test Expectations

View File

@ -32,7 +32,7 @@ App 系统/活动消息:
- Flutter 消息页固定展示腾讯云 IM 用户消息、后端系统消息和后端活动消息三个入口;系统/活动页通过 `gateway-service` 读取 `activity-service` 的 inbox只按当前客户端能力展示标题、摘要/正文和时间。
- `activity-service/internal/service/message` 是系统/活动消息 owner。单人消息使用 `CreateSystemNotice` / `CreateActivityNotice`,全服或范围通知使用 `CreateNoticeFanout` 创建 `message_fanout_jobs`,由 cron 触发 fanout worker 分批写入 `user_inbox_messages`
- 活动业务发通知时传 `producer_event_id``command_id` 作为幂等键,`message_type` 只允许 `system``activity`;跳转参数使用已有 `action_type` 白名单,例如 `activity_detail``room_detail``user_profile``app_h5`
- `server/admin`运营管理/全服通知只通过 `MessageInboxService.CreateFanoutJob` 创建后台 fanout 命令,不直接写 `hyapp_activity` 表,也不在 HTTP 请求里同步遍历用户。
- `server/admin` APP配置/系统消息推送只通过 `MessageInboxService.CreateFanoutJob` 创建后台 fanout 命令,不直接写 `hyapp_activity` 表,也不在 HTTP 请求里同步遍历用户。
## Time Model

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,15 @@ message RankItem {
int64 updated_at_ms = 4;
}
// RoomContributionRankItem
message RoomContributionRankItem {
int64 rank = 1;
int64 user_id = 2;
int64 score = 3;
int64 gift_value = 4;
int64 updated_at_ms = 5;
}
// LuckyGiftDrawResult SendGift
message LuckyGiftDrawResult {
bool enabled = 1;
@ -1276,6 +1285,26 @@ message ListRoomGiftLeaderboardResponse {
int64 server_time_ms = 6;
}
// ListRoomContributionRankRequest
message ListRoomContributionRankRequest {
RequestMeta meta = 1;
string room_id = 2;
string period = 3;
int32 limit = 4;
int64 viewer_user_id = 5;
}
// ListRoomContributionRankResponse
message ListRoomContributionRankResponse {
string room_id = 1;
string period = 2;
int64 start_at_ms = 3;
int64 end_at_ms = 4;
repeated RoomContributionRankItem items = 5;
int64 total = 6;
int64 server_time_ms = 7;
}
// GetMyRoomRequest
// gateway owner_user_id ID
message GetMyRoomRequest {
@ -1475,6 +1504,7 @@ service RoomQueryService {
rpc ListRoomsByOwners(ListRoomsByOwnersRequest) returns (ListRoomsResponse);
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
rpc ListRoomGiftLeaderboard(ListRoomGiftLeaderboardRequest) returns (ListRoomGiftLeaderboardResponse);
rpc ListRoomContributionRank(ListRoomContributionRankRequest) returns (ListRoomContributionRankResponse);
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);

View File

@ -1573,6 +1573,7 @@ const (
RoomQueryService_ListRoomsByOwners_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomsByOwners"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
RoomQueryService_ListRoomGiftLeaderboard_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomGiftLeaderboard"
RoomQueryService_ListRoomContributionRank_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomContributionRank"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
@ -1600,6 +1601,7 @@ type RoomQueryServiceClient interface {
ListRoomsByOwners(ctx context.Context, in *ListRoomsByOwnersRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
ListRoomGiftLeaderboard(ctx context.Context, in *ListRoomGiftLeaderboardRequest, opts ...grpc.CallOption) (*ListRoomGiftLeaderboardResponse, error)
ListRoomContributionRank(ctx context.Context, in *ListRoomContributionRankRequest, opts ...grpc.CallOption) (*ListRoomContributionRankResponse, error)
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
@ -1737,6 +1739,16 @@ func (c *roomQueryServiceClient) ListRoomGiftLeaderboard(ctx context.Context, in
return out, nil
}
func (c *roomQueryServiceClient) ListRoomContributionRank(ctx context.Context, in *ListRoomContributionRankRequest, opts ...grpc.CallOption) (*ListRoomContributionRankResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomContributionRankResponse)
err := c.cc.Invoke(ctx, RoomQueryService_ListRoomContributionRank_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyRoomResponse)
@ -1825,6 +1837,7 @@ type RoomQueryServiceServer interface {
ListRoomsByOwners(context.Context, *ListRoomsByOwnersRequest) (*ListRoomsResponse, error)
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error)
ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error)
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
@ -1878,6 +1891,9 @@ func (UnimplementedRoomQueryServiceServer) ListRoomFeeds(context.Context, *ListR
func (UnimplementedRoomQueryServiceServer) ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRoomGiftLeaderboard not implemented")
}
func (UnimplementedRoomQueryServiceServer) ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRoomContributionRank not implemented")
}
func (UnimplementedRoomQueryServiceServer) GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMyRoom not implemented")
}
@ -2136,6 +2152,24 @@ func _RoomQueryService_ListRoomGiftLeaderboard_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_ListRoomContributionRank_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomContributionRankRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).ListRoomContributionRank(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_ListRoomContributionRank_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).ListRoomContributionRank(ctx, req.(*ListRoomContributionRankRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_GetMyRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMyRoomRequest)
if err := dec(in); err != nil {
@ -2317,6 +2351,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRoomGiftLeaderboard",
Handler: _RoomQueryService_ListRoomGiftLeaderboard_Handler,
},
{
MethodName: "ListRoomContributionRank",
Handler: _RoomQueryService_ListRoomContributionRank_Handler,
},
{
MethodName: "GetMyRoom",
Handler: _RoomQueryService_GetMyRoom_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -228,6 +228,7 @@ message AdminCreditAssetRequest {
string reason = 6;
string evidence_ref = 7;
string app_code = 8;
string remark = 9;
}
message AdminCreditAssetResponse {
@ -671,6 +672,24 @@ message SetResourceStatusRequest {
int64 operator_user_id = 5;
}
message DeleteResourceRequest {
string request_id = 1;
string app_code = 2;
int64 resource_id = 3;
int64 operator_user_id = 4;
}
message BatchDeleteResourcesRequest {
string request_id = 1;
string app_code = 2;
repeated int64 resource_ids = 3;
int64 operator_user_id = 4;
}
message BatchDeleteResourcesResponse {
repeated Resource resources = 1;
}
message ResourceResponse {
Resource resource = 1;
}
@ -806,6 +825,17 @@ message CreateGiftConfigRequest {
string cp_relation_type = 21;
}
message BatchCreateGiftConfigsRequest {
string request_id = 1;
string app_code = 2;
repeated CreateGiftConfigRequest items = 3;
int64 operator_user_id = 4;
}
message BatchCreateGiftConfigsResponse {
repeated GiftConfig gifts = 1;
}
message UpdateGiftConfigRequest {
string request_id = 1;
string app_code = 2;
@ -1037,6 +1067,17 @@ message RechargeBill {
int64 exchange_usd_minor_amount = 17;
int64 created_at_ms = 18;
int64 provider_amount_minor = 19;
// provider_code google_play/mifapay/v5pay/usdt_trc20
string provider_code = 20;
// user_paid_* Google Play Orders API
string user_paid_currency_code = 21;
int64 user_paid_amount_micro = 22;
// provider_fee/tax/net 0
int64 provider_fee_micro = 23;
int64 provider_tax_micro = 24;
int64 provider_net_micro = 25;
// paid_synced_at_ms Google Orders API 0
int64 paid_synced_at_ms = 26;
}
message ListRechargeBillsRequest {
@ -1052,6 +1093,8 @@ message ListRechargeBillsRequest {
int64 end_at_ms = 10;
int32 page = 11;
int32 page_size = 12;
// paid_state=unsynced
string paid_state = 13;
}
message ListRechargeBillsResponse {
@ -1059,6 +1102,108 @@ message ListRechargeBillsResponse {
int64 total = 2;
}
message GetRechargeBillSummaryRequest {
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;
}
message RechargeBillSummaryBucket {
int64 bill_count = 1;
int64 coin_amount = 2;
int64 usd_minor_amount = 3;
}
message GetRechargeBillSummaryResponse {
RechargeBillSummaryBucket total = 1;
RechargeBillSummaryBucket google_play = 2;
RechargeBillSummaryBucket third_party = 3;
// coin_seller coin_seller_stock_records counts_as_seller_recharge
RechargeBillSummaryBucket coin_seller = 4;
}
message GetRechargeBillOverviewRequest {
string request_id = 1;
string app_code = 2;
int64 region_id = 3;
string recharge_type = 4;
string status = 5;
int64 start_at_ms = 6;
int64 end_at_ms = 7;
// tz_offset_minutes 480
int32 tz_offset_minutes = 8;
}
message RechargeBillDailyBucket {
// date tz_offset_minutes YYYY-MM-DD
string date = 1;
int64 google_usd_minor = 2;
int64 third_party_usd_minor = 3;
int64 coin_seller_usd_minor = 4;
int64 google_coin_amount = 5;
int64 third_party_coin_amount = 6;
int64 coin_seller_coin_amount = 7;
}
message RechargeBillRegionBucket {
int64 region_id = 1;
int64 bill_count = 2;
int64 usd_minor_amount = 3;
}
// RechargeBillGooglePaidStats
// est_* × USD
message RechargeBillGooglePaidStats {
int64 google_bill_count = 1;
int64 synced_count = 2;
int64 unsynced_count = 3;
int64 covered_usd_minor = 4;
int64 est_fee_usd_minor = 5;
int64 est_tax_usd_minor = 6;
int64 est_net_usd_minor = 7;
}
// RechargeBillSalaryTransferStats
// USDT salary_transfer_to_coin_seller
message RechargeBillSalaryTransferStats {
int64 transfer_count = 1;
int64 transfer_usd_minor = 2;
}
message GetRechargeBillOverviewResponse {
repeated RechargeBillDailyBucket daily = 1;
repeated RechargeBillRegionBucket regions = 2;
RechargeBillGooglePaidStats google_paid = 3;
RechargeBillSalaryTransferStats salary_transfer = 4;
}
message RefreshGooglePaymentPricesRequest {
string request_id = 1;
string app_code = 2;
repeated string transaction_ids = 3;
}
message GooglePaymentPrice {
string transaction_id = 1;
string currency_code = 2;
int64 paid_amount_micro = 3;
int64 tax_micro = 4;
int64 net_micro = 5;
int64 synced_at_ms = 6;
string error = 7;
}
message RefreshGooglePaymentPricesResponse {
repeated GooglePaymentPrice prices = 1;
}
// WalletFeatureFlags 使 wallet-service
message WalletFeatureFlags {
bool recharge_enabled = 1;
@ -2107,6 +2252,15 @@ message ListResourceShopPurchaseOrdersResponse {
int64 total = 2;
}
message GetGiftCatalogVersionRequest {
string request_id = 1;
string app_code = 2;
}
message GetGiftCatalogVersionResponse {
int64 version = 1;
}
// WalletCronService cron-service wallet-service owner
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -2137,6 +2291,8 @@ service WalletService {
rpc CreateResource(CreateResourceRequest) returns (ResourceResponse);
rpc UpdateResource(UpdateResourceRequest) returns (ResourceResponse);
rpc SetResourceStatus(SetResourceStatusRequest) returns (ResourceResponse);
rpc DeleteResource(DeleteResourceRequest) returns (ResourceResponse);
rpc BatchDeleteResources(BatchDeleteResourcesRequest) returns (BatchDeleteResourcesResponse);
rpc ListResourceGroups(ListResourceGroupsRequest) returns (ListResourceGroupsResponse);
rpc GetResourceGroup(GetResourceGroupRequest) returns (GetResourceGroupResponse);
rpc CreateResourceGroup(CreateResourceGroupRequest) returns (ResourceGroupResponse);
@ -2144,7 +2300,9 @@ service WalletService {
rpc SetResourceGroupStatus(SetResourceGroupStatusRequest) returns (ResourceGroupResponse);
rpc ListGiftConfigs(ListGiftConfigsRequest) returns (ListGiftConfigsResponse);
rpc ListGiftTypeConfigs(ListGiftTypeConfigsRequest) returns (ListGiftTypeConfigsResponse);
rpc GetGiftCatalogVersion(GetGiftCatalogVersionRequest) returns (GetGiftCatalogVersionResponse);
rpc CreateGiftConfig(CreateGiftConfigRequest) returns (GiftConfigResponse);
rpc BatchCreateGiftConfigs(BatchCreateGiftConfigsRequest) returns (BatchCreateGiftConfigsResponse);
rpc UpdateGiftConfig(UpdateGiftConfigRequest) returns (GiftConfigResponse);
rpc SetGiftConfigStatus(SetGiftConfigStatusRequest) returns (GiftConfigResponse);
rpc DeleteGiftConfig(DeleteGiftConfigRequest) returns (GiftConfigResponse);
@ -2163,6 +2321,9 @@ service WalletService {
rpc ListResourceShopPurchaseOrders(ListResourceShopPurchaseOrdersRequest) returns (ListResourceShopPurchaseOrdersResponse);
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetRechargeBillSummary(GetRechargeBillSummaryRequest) returns (GetRechargeBillSummaryResponse);
rpc GetRechargeBillOverview(GetRechargeBillOverviewRequest) returns (GetRechargeBillOverviewResponse);
rpc RefreshGooglePaymentPrices(RefreshGooglePaymentPricesRequest) returns (RefreshGooglePaymentPricesResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);

View File

@ -222,6 +222,8 @@ const (
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource"
WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus"
WalletService_DeleteResource_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteResource"
WalletService_BatchDeleteResources_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDeleteResources"
WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups"
WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup"
WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup"
@ -229,7 +231,9 @@ const (
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
WalletService_ListGiftTypeConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftTypeConfigs"
WalletService_GetGiftCatalogVersion_FullMethodName = "/hyapp.wallet.v1.WalletService/GetGiftCatalogVersion"
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
WalletService_BatchCreateGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchCreateGiftConfigs"
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
WalletService_DeleteGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteGiftConfig"
@ -248,6 +252,9 @@ const (
WalletService_ListResourceShopPurchaseOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopPurchaseOrders"
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetRechargeBillSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillSummary"
WalletService_GetRechargeBillOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillOverview"
WalletService_RefreshGooglePaymentPrices_FullMethodName = "/hyapp.wallet.v1.WalletService/RefreshGooglePaymentPrices"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
@ -324,6 +331,8 @@ type WalletServiceClient interface {
CreateResource(ctx context.Context, in *CreateResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error)
UpdateResource(ctx context.Context, in *UpdateResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error)
SetResourceStatus(ctx context.Context, in *SetResourceStatusRequest, opts ...grpc.CallOption) (*ResourceResponse, error)
DeleteResource(ctx context.Context, in *DeleteResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error)
BatchDeleteResources(ctx context.Context, in *BatchDeleteResourcesRequest, opts ...grpc.CallOption) (*BatchDeleteResourcesResponse, error)
ListResourceGroups(ctx context.Context, in *ListResourceGroupsRequest, opts ...grpc.CallOption) (*ListResourceGroupsResponse, error)
GetResourceGroup(ctx context.Context, in *GetResourceGroupRequest, opts ...grpc.CallOption) (*GetResourceGroupResponse, error)
CreateResourceGroup(ctx context.Context, in *CreateResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGroupResponse, error)
@ -331,7 +340,9 @@ type WalletServiceClient interface {
SetResourceGroupStatus(ctx context.Context, in *SetResourceGroupStatusRequest, opts ...grpc.CallOption) (*ResourceGroupResponse, error)
ListGiftConfigs(ctx context.Context, in *ListGiftConfigsRequest, opts ...grpc.CallOption) (*ListGiftConfigsResponse, error)
ListGiftTypeConfigs(ctx context.Context, in *ListGiftTypeConfigsRequest, opts ...grpc.CallOption) (*ListGiftTypeConfigsResponse, error)
GetGiftCatalogVersion(ctx context.Context, in *GetGiftCatalogVersionRequest, opts ...grpc.CallOption) (*GetGiftCatalogVersionResponse, error)
CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
BatchCreateGiftConfigs(ctx context.Context, in *BatchCreateGiftConfigsRequest, opts ...grpc.CallOption) (*BatchCreateGiftConfigsResponse, error)
UpdateGiftConfig(ctx context.Context, in *UpdateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
SetGiftConfigStatus(ctx context.Context, in *SetGiftConfigStatusRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
DeleteGiftConfig(ctx context.Context, in *DeleteGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
@ -350,6 +361,9 @@ type WalletServiceClient interface {
ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(ctx context.Context, in *GetRechargeBillSummaryRequest, opts ...grpc.CallOption) (*GetRechargeBillSummaryResponse, error)
GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(ctx context.Context, in *RefreshGooglePaymentPricesRequest, opts ...grpc.CallOption) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error)
@ -617,6 +631,26 @@ func (c *walletServiceClient) SetResourceStatus(ctx context.Context, in *SetReso
return out, nil
}
func (c *walletServiceClient) DeleteResource(ctx context.Context, in *DeleteResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResourceResponse)
err := c.cc.Invoke(ctx, WalletService_DeleteResource_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) BatchDeleteResources(ctx context.Context, in *BatchDeleteResourcesRequest, opts ...grpc.CallOption) (*BatchDeleteResourcesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchDeleteResourcesResponse)
err := c.cc.Invoke(ctx, WalletService_BatchDeleteResources_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListResourceGroups(ctx context.Context, in *ListResourceGroupsRequest, opts ...grpc.CallOption) (*ListResourceGroupsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourceGroupsResponse)
@ -687,6 +721,16 @@ func (c *walletServiceClient) ListGiftTypeConfigs(ctx context.Context, in *ListG
return out, nil
}
func (c *walletServiceClient) GetGiftCatalogVersion(ctx context.Context, in *GetGiftCatalogVersionRequest, opts ...grpc.CallOption) (*GetGiftCatalogVersionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetGiftCatalogVersionResponse)
err := c.cc.Invoke(ctx, WalletService_GetGiftCatalogVersion_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GiftConfigResponse)
@ -697,6 +741,16 @@ func (c *walletServiceClient) CreateGiftConfig(ctx context.Context, in *CreateGi
return out, nil
}
func (c *walletServiceClient) BatchCreateGiftConfigs(ctx context.Context, in *BatchCreateGiftConfigsRequest, opts ...grpc.CallOption) (*BatchCreateGiftConfigsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchCreateGiftConfigsResponse)
err := c.cc.Invoke(ctx, WalletService_BatchCreateGiftConfigs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateGiftConfig(ctx context.Context, in *UpdateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GiftConfigResponse)
@ -877,6 +931,36 @@ func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRec
return out, nil
}
func (c *walletServiceClient) GetRechargeBillSummary(ctx context.Context, in *GetRechargeBillSummaryRequest, opts ...grpc.CallOption) (*GetRechargeBillSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRechargeBillSummaryResponse)
err := c.cc.Invoke(ctx, WalletService_GetRechargeBillSummary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRechargeBillOverviewResponse)
err := c.cc.Invoke(ctx, WalletService_GetRechargeBillOverview_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) RefreshGooglePaymentPrices(ctx context.Context, in *RefreshGooglePaymentPricesRequest, opts ...grpc.CallOption) (*RefreshGooglePaymentPricesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RefreshGooglePaymentPricesResponse)
err := c.cc.Invoke(ctx, WalletService_RefreshGooglePaymentPrices_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWalletOverviewResponse)
@ -1374,6 +1458,8 @@ type WalletServiceServer interface {
CreateResource(context.Context, *CreateResourceRequest) (*ResourceResponse, error)
UpdateResource(context.Context, *UpdateResourceRequest) (*ResourceResponse, error)
SetResourceStatus(context.Context, *SetResourceStatusRequest) (*ResourceResponse, error)
DeleteResource(context.Context, *DeleteResourceRequest) (*ResourceResponse, error)
BatchDeleteResources(context.Context, *BatchDeleteResourcesRequest) (*BatchDeleteResourcesResponse, error)
ListResourceGroups(context.Context, *ListResourceGroupsRequest) (*ListResourceGroupsResponse, error)
GetResourceGroup(context.Context, *GetResourceGroupRequest) (*GetResourceGroupResponse, error)
CreateResourceGroup(context.Context, *CreateResourceGroupRequest) (*ResourceGroupResponse, error)
@ -1381,7 +1467,9 @@ type WalletServiceServer interface {
SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error)
ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error)
ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error)
GetGiftCatalogVersion(context.Context, *GetGiftCatalogVersionRequest) (*GetGiftCatalogVersionResponse, error)
CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error)
BatchCreateGiftConfigs(context.Context, *BatchCreateGiftConfigsRequest) (*BatchCreateGiftConfigsResponse, error)
UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error)
SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error)
DeleteGiftConfig(context.Context, *DeleteGiftConfigRequest) (*GiftConfigResponse, error)
@ -1400,6 +1488,9 @@ type WalletServiceServer interface {
ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error)
GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
@ -1520,6 +1611,12 @@ func (UnimplementedWalletServiceServer) UpdateResource(context.Context, *UpdateR
func (UnimplementedWalletServiceServer) SetResourceStatus(context.Context, *SetResourceStatusRequest) (*ResourceResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetResourceStatus not implemented")
}
func (UnimplementedWalletServiceServer) DeleteResource(context.Context, *DeleteResourceRequest) (*ResourceResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteResource not implemented")
}
func (UnimplementedWalletServiceServer) BatchDeleteResources(context.Context, *BatchDeleteResourcesRequest) (*BatchDeleteResourcesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchDeleteResources not implemented")
}
func (UnimplementedWalletServiceServer) ListResourceGroups(context.Context, *ListResourceGroupsRequest) (*ListResourceGroupsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListResourceGroups not implemented")
}
@ -1541,9 +1638,15 @@ func (UnimplementedWalletServiceServer) ListGiftConfigs(context.Context, *ListGi
func (UnimplementedWalletServiceServer) ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListGiftTypeConfigs not implemented")
}
func (UnimplementedWalletServiceServer) GetGiftCatalogVersion(context.Context, *GetGiftCatalogVersionRequest) (*GetGiftCatalogVersionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetGiftCatalogVersion not implemented")
}
func (UnimplementedWalletServiceServer) CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateGiftConfig not implemented")
}
func (UnimplementedWalletServiceServer) BatchCreateGiftConfigs(context.Context, *BatchCreateGiftConfigsRequest) (*BatchCreateGiftConfigsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchCreateGiftConfigs not implemented")
}
func (UnimplementedWalletServiceServer) UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateGiftConfig not implemented")
}
@ -1598,6 +1701,15 @@ func (UnimplementedWalletServiceServer) PurchaseResourceShopItem(context.Context
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeBills not implemented")
}
func (UnimplementedWalletServiceServer) GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillSummary not implemented")
}
func (UnimplementedWalletServiceServer) GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillOverview not implemented")
}
func (UnimplementedWalletServiceServer) RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RefreshGooglePaymentPrices not implemented")
}
func (UnimplementedWalletServiceServer) GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWalletOverview not implemented")
}
@ -2138,6 +2250,42 @@ func _WalletService_SetResourceStatus_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_DeleteResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteResourceRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DeleteResource(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DeleteResource_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DeleteResource(ctx, req.(*DeleteResourceRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_BatchDeleteResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchDeleteResourcesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).BatchDeleteResources(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_BatchDeleteResources_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).BatchDeleteResources(ctx, req.(*BatchDeleteResourcesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListResourceGroups_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourceGroupsRequest)
if err := dec(in); err != nil {
@ -2264,6 +2412,24 @@ func _WalletService_ListGiftTypeConfigs_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetGiftCatalogVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetGiftCatalogVersionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetGiftCatalogVersion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetGiftCatalogVersion_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetGiftCatalogVersion(ctx, req.(*GetGiftCatalogVersionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreateGiftConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateGiftConfigRequest)
if err := dec(in); err != nil {
@ -2282,6 +2448,24 @@ func _WalletService_CreateGiftConfig_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _WalletService_BatchCreateGiftConfigs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchCreateGiftConfigsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).BatchCreateGiftConfigs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_BatchCreateGiftConfigs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).BatchCreateGiftConfigs(ctx, req.(*BatchCreateGiftConfigsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateGiftConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateGiftConfigRequest)
if err := dec(in); err != nil {
@ -2606,6 +2790,60 @@ func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRechargeBillSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRechargeBillSummaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetRechargeBillSummary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetRechargeBillSummary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetRechargeBillSummary(ctx, req.(*GetRechargeBillSummaryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRechargeBillOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRechargeBillOverviewRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetRechargeBillOverview(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetRechargeBillOverview_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetRechargeBillOverview(ctx, req.(*GetRechargeBillOverviewRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_RefreshGooglePaymentPrices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RefreshGooglePaymentPricesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).RefreshGooglePaymentPrices(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_RefreshGooglePaymentPrices_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).RefreshGooglePaymentPrices(ctx, req.(*RefreshGooglePaymentPricesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetWalletOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWalletOverviewRequest)
if err := dec(in); err != nil {
@ -3543,6 +3781,14 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetResourceStatus",
Handler: _WalletService_SetResourceStatus_Handler,
},
{
MethodName: "DeleteResource",
Handler: _WalletService_DeleteResource_Handler,
},
{
MethodName: "BatchDeleteResources",
Handler: _WalletService_BatchDeleteResources_Handler,
},
{
MethodName: "ListResourceGroups",
Handler: _WalletService_ListResourceGroups_Handler,
@ -3571,10 +3817,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListGiftTypeConfigs",
Handler: _WalletService_ListGiftTypeConfigs_Handler,
},
{
MethodName: "GetGiftCatalogVersion",
Handler: _WalletService_GetGiftCatalogVersion_Handler,
},
{
MethodName: "CreateGiftConfig",
Handler: _WalletService_CreateGiftConfig_Handler,
},
{
MethodName: "BatchCreateGiftConfigs",
Handler: _WalletService_BatchCreateGiftConfigs_Handler,
},
{
MethodName: "UpdateGiftConfig",
Handler: _WalletService_UpdateGiftConfig_Handler,
@ -3647,6 +3901,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRechargeBills",
Handler: _WalletService_ListRechargeBills_Handler,
},
{
MethodName: "GetRechargeBillSummary",
Handler: _WalletService_GetRechargeBillSummary_Handler,
},
{
MethodName: "GetRechargeBillOverview",
Handler: _WalletService_GetRechargeBillOverview_Handler,
},
{
MethodName: "RefreshGooglePaymentPrices",
Handler: _WalletService_RefreshGooglePaymentPrices_Handler,
},
{
MethodName: "GetWalletOverview",
Handler: _WalletService_GetWalletOverview_Handler,

View File

@ -1,12 +0,0 @@
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
# Local RocketMQ clients run in two places: Docker containers and host go run
# processes. Advertising the Docker host address keeps both paths reachable.
brokerIP1 = host.docker.internal
listenPort = 10911

View File

@ -32,7 +32,7 @@
| 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer |
| 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
| 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`region/country/all_active_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`region/country/all_active_users/all_registered_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
@ -238,6 +238,7 @@ message_fanout_jobs(
| `region` | 区域活动、区域公告 | user-service `users.region_id` |
| `country` | 国家定向通知 | user-service `users.country` |
| `all_active_users` | 全 App 活跃用户 | user-service active users cursor |
| `all_registered_users` | 全 App 注册用户 | user-service registered users cursor |
### Global Broadcast State
@ -527,7 +528,7 @@ tasks:
3. 在 `activity-service` 增加 inbox domain/repository/service先实现单用户入箱、列表、未读、已读、删除。
4. 在 `gateway-service` 增加 activity/message client 和 App HTTP`/api/v1/messages/tabs``/api/v1/messages``/api/v1/messages/{id}/read``/api/v1/messages/read-all``DELETE /api/v1/messages/{id}`
5. 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。
6. 增加 fanout job 和 `ActivityCronService.ProcessMessageFanoutBatch`,由 cron-service 调度,先支持 `region``country``user_ids``all_active_users`
6. 增加 fanout job 和 `ActivityCronService.ProcessMessageFanoutBatch`,由 cron-service 调度,先支持 `region``country``user_ids``all_active_users``all_registered_users`
7. 增加 `/api/v1/users/profiles:batch`,给腾讯云 IM 用户会话列表补头像、昵称、短号。
8. 增加 Redis 未读数缓存和缓存失效逻辑。
9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。

View File

@ -272,7 +272,7 @@ sequenceDiagram
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
| 无 | 无 | 解除关系成功不发 IMApp 根据解除接口返回刷新 |
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A拒绝为 B同意会发 `B -> A``A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_<region_id>`,本地测试可通过 `group_id_prefix` 加前缀。
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A拒绝为 B同意会发 `B -> A``A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_v2_<region_id>`v1 旧群 `hy_<app_code>_bc_r_<region_id>` 仅保留过渡期双发),本地测试可通过 `group_id_prefix` 加前缀。
## 12. 错误码

View File

@ -57,8 +57,15 @@ GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID
| Group Type | GroupID | Meaning |
| --- | --- | --- |
| room | `<room_id>` | 现有房间群,必须满足 `pkg/roomid` 规则 |
| global_broadcast | `hy_<app_code>_bc_g` | App 内全局播报群 |
| region_broadcast | `hy_<app_code>_bc_r_<region_id>` | App 内指定区域播报群 |
| global_broadcast | `hy_<app_code>_bc_g_v2`v1: `hy_<app_code>_bc_g` | App 内全局播报群 |
| region_broadcast | `hy_<app_code>_bc_r_v2_<region_id>`v1: `hy_<app_code>_bc_r_<region_id>` | App 内指定区域播报群 |
v1 → v2 迁移2026-07
- v1 播报群是 ChatRoom 类型,受套餐成员上限约束(标准版 2000 人),线上已触发 10038 拒绝加群;全局群与区域群先后都迁移到 AVChatRoom 类型的 v2 群(成员无上限、免审批)。
- `imgroup.GlobalBroadcastGroupID*` / `RegionBroadcastGroupID*` 只生成 v2 IDv1 ID 仅能由 `Legacy*` 变体生成,用于过渡期双发和旧群成员移除。
- 解析层对 v1/v2 返回同一个 Kind调用方不感知版本reconciler 只 ensure v2 群v1 旧群不再维护。
- 过渡期 outbox 投递成功后会向对应 v1 旧群补发一份 `<event_id>_legacy` 副本,覆盖尚未重新拉取 join 列表的存量会话;老客户端全部换代后可移除双发。
规则:
@ -123,8 +130,8 @@ sequenceDiagram
U-->>G: user.region_id, profile_completed
G-->>C: UserSig + join_groups
C->>IM: login(user_id, user_sig)
C->>IM: joinGroup(hy_<app>_bc_g)
C->>IM: joinGroup(hy_<app>_bc_r_<region_id>)
C->>IM: joinGroup(hy_<app>_bc_g_v2)
C->>IM: joinGroup(hy_<app>_bc_r_v2_<region_id>)
```
`/api/v1/im/usersig` response 增加 `join_groups`,客户端必须按返回列表加入播报群:
@ -137,11 +144,11 @@ sequenceDiagram
"expire_at_ms": 1770000000000,
"join_groups": [
{
"group_id": "hy_lalu_bc_g",
"group_id": "hy_lalu_bc_g_v2",
"type": "global_broadcast"
},
{
"group_id": "hy_lalu_bc_r_1001",
"group_id": "hy_lalu_bc_r_v2_1001",
"type": "region_broadcast",
"region_id": 1001
}

View File

@ -0,0 +1,108 @@
# App 埋点 Flutter 对接
## 地址
```http
POST /api/v1/app/events
Content-Type: application/json
```
可登录也可未登录调用。已登录时建议带:
```http
Authorization: Bearer <access_token>
```
未登录时必须传 `device_id` 或 Header
```http
X-Device-ID: <device_id>
```
## 参数
Body
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `events` | array | 是 | 埋点列表,单次最多 50 条。 |
`events[]`
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `event_id` | string | 是 | 客户端生成的唯一事件 ID。同一事件重试必须复用同一个值。 |
| `event_name` | string | 是 | 事件名,例如 `banner_view``banner_click``page_open`。 |
| `event_type` | string | 否 | 事件类型,例如 `view``click``submit`。 |
| `screen` | string | 否 | 页面或模块,例如 `home``room``me`。 |
| `target_type` | string | 否 | 目标类型,例如 `banner``button``room`。 |
| `target_id` | string | 否 | 目标 ID例如 banner ID、房间 ID。 |
| `device_id` | string | 未登录必填 | 设备 ID。也可以放到 `X-Device-ID`。 |
| `session_id` | string | 否 | 客户端本地会话 ID。登录态下服务端优先使用 token 里的 session。 |
| `platform` | string | 否 | `android``ios`。不传时读取 `X-App-Platform` / `X-Platform`。 |
| `app_version` | string | 否 | App 版本。不传时读取 `X-App-Version` / `X-Client-Version`。 |
| `language` | string | 否 | 语言。不传时读取 `X-App-Language` / `X-Language` / `Accept-Language`。 |
| `timezone` | string | 否 | 客户端时区,只做属性记录,不参与业务切日。 |
| `duration_ms` | int64 | 否 | 客户端记录的耗时毫秒。 |
| `success` | bool | 否 | 动作是否成功。 |
| `error_code` | string | 否 | 失败时的客户端错误码。 |
| `properties` | object | 否 | 扩展属性,最大 4KB。 |
| `occurred_at_ms` | int64 | 否 | 事件发生时间UTC epoch milliseconds。不传时使用服务端接收时间。 |
服务端会自己识别 `app_code``user_id``country_id``region_id`,客户端不要传这些字段。
示例:
```json
{
"events": [
{
"event_id": "evt_20260703_100001_banner_17_view",
"event_name": "banner_view",
"event_type": "view",
"screen": "home",
"target_type": "banner",
"target_id": "17",
"device_id": "device_xxx",
"platform": "android",
"app_version": "1.2.3",
"language": "en-US",
"timezone": "Asia/Shanghai",
"success": true,
"properties": {
"position": "top"
},
"occurred_at_ms": 1778000005000
}
]
}
```
## 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"accepted": true,
"received": 1,
"stored": 1,
"duplicated": 0,
"server_time_ms": 1778000005000
}
}
```
| 字段 | 说明 |
| --- | --- |
| `data.accepted` | 是否接受本次上报。 |
| `data.received` | 本次收到的事件数。 |
| `data.stored` | 本次新写入的事件数。 |
| `data.duplicated` | 已存在的事件数。通常是重试导致的重复 `event_id`。 |
| `data.server_time_ms` | 服务端时间UTC epoch milliseconds。 |
## 相关 IM
无。

View File

@ -206,7 +206,7 @@ Headers
"session_id": "sess_xxx",
"access_token": "access_token",
"refresh_token": "refresh_token",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer",
"is_new_user": true,
"profile_completed": true,

View File

@ -0,0 +1,277 @@
# 房内贡献榜 Flutter 对接
本文描述语音房内 day/week/month 三个 tab 的贡献榜接口。客户端按当前 tab 独立请求,不再从进房、心跳或送礼接口里复用同一份榜单数据。
## 接口地址
本地开发地址:
```text
http://127.0.0.1:13000
```
```http
GET /api/v1/rooms/{room_id}/contribution-rank?period=day&limit=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```
说明:
| 项 | 说明 |
| --- | --- |
| 鉴权 | 必须登录。gateway 使用 token 里的 `user_id` 作为 viewer。 |
| 房间校验 | viewer 必须仍在当前房间内;不在房间时返回 403。 |
| 响应外壳 | 统一使用 `{code,message,request_id,data}`。 |
| 时间单位 | 所有 `*_ms` 都是 Unix epoch milliseconds统计窗口按 UTC 计算。 |
## 参数
Path 参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID。只支持字母、数字、下划线和中横线最长 48 位。 |
Query 参数:
| 字段 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `period` | string | 否 | `day` | 榜单周期。可选值:`day``week``month`。 |
| `limit` | int32 | 否 | `20` | 返回条数。必须大于 0服务端最大返回 `100`。 |
周期含义:
| `period` | 统计窗口 |
| --- | --- |
| `day` | UTC 当日 00:00:00 到次日 00:00:00区间为 `[start_at_ms, end_at_ms)`。 |
| `week` | UTC 自然周,周一 00:00:00 到下周一 00:00:00区间为 `[start_at_ms, end_at_ms)`。 |
| `month` | UTC 自然月1 日 00:00:00 到下月 1 日 00:00:00区间为 `[start_at_ms, end_at_ms)`。 |
请求示例:
```http
GET /api/v1/rooms/room_123/contribution-rank?period=week&limit=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```
## 返回值
统一外壳:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
成功返回示例:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_room_rank",
"data": {
"room_id": "room_123",
"period": "week",
"start_at_ms": 1780329600000,
"end_at_ms": 1780934400000,
"total": 2,
"contribution_rank": [
{
"rank": 1,
"user_id": "43",
"score": 900,
"gift_value": 900,
"updated_at_ms": 1780400001000,
"profile": {
"user_id": "43",
"username": "Luna",
"avatar": "https://cdn.example/avatar/43.png",
"display_user_id": "10043",
"pretty_id": "888888",
"pretty_display_user_id": "888888",
"gender": "female",
"age": 22,
"country": "US",
"country_name": "United States",
"country_display_name": "United States",
"country_flag": "https://cdn.example/flags/us.png",
"vip": {},
"level": {},
"level_badges": {},
"badges": [],
"avatar_frame": {},
"profile_card": {},
"vehicle": {},
"mic_seat_animation": {},
"charm": 0
}
},
{
"rank": 2,
"user_id": "44",
"score": 520,
"gift_value": 520,
"updated_at_ms": 1780390001000,
"profile": {
"user_id": "44",
"username": "Mia",
"avatar": "https://cdn.example/avatar/44.png",
"display_user_id": "10044",
"gender": "",
"age": 0,
"country": "",
"country_name": "",
"country_display_name": "",
"country_flag": "",
"vip": {},
"level": {},
"level_badges": {},
"badges": [],
"avatar_frame": {},
"profile_card": {},
"vehicle": {},
"mic_seat_animation": {},
"charm": 0
}
}
],
"profiles": [
{
"user_id": "43",
"username": "Luna",
"avatar": "https://cdn.example/avatar/43.png",
"display_user_id": "10043",
"gender": "female",
"age": 22,
"country": "US",
"country_name": "United States",
"country_display_name": "United States",
"country_flag": "https://cdn.example/flags/us.png",
"vip": {},
"level": {},
"level_badges": {},
"badges": [],
"avatar_frame": {},
"profile_card": {},
"vehicle": {},
"mic_seat_animation": {},
"charm": 0
},
{
"user_id": "44",
"username": "Mia",
"avatar": "https://cdn.example/avatar/44.png",
"display_user_id": "10044",
"gender": "",
"age": 0,
"country": "",
"country_name": "",
"country_display_name": "",
"country_flag": "",
"vip": {},
"level": {},
"level_badges": {},
"badges": [],
"avatar_frame": {},
"profile_card": {},
"vehicle": {},
"mic_seat_animation": {},
"charm": 0
}
],
"server_time_ms": 1780400005000
}
}
```
`data` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `room_id` | string | 房间 ID。 |
| `period` | string | 服务端归一化后的周期:`day``week``month`。 |
| `start_at_ms` | int64 | 本次榜单统计窗口开始时间UTC。 |
| `end_at_ms` | int64 | 本次榜单统计窗口结束时间UTC。 |
| `total` | int64 | 当前房间、当前周期内有贡献的总用户数。 |
| `contribution_rank` | array | 当前页榜单项。空数组表示当前周期暂无贡献数据。 |
| `profiles` | array | 榜单用户的展示资料列表,顺序和 `contribution_rank` 一致。 |
| `server_time_ms` | int64 | 服务端当前时间,可用于展示更新时间。 |
`contribution_rank[]` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `rank` | int64 | 排名,从 1 开始。 |
| `user_id` | string | 上榜用户 IDHTTP JSON 固定按字符串解析。 |
| `score` | int64 | 榜单分值;当前等于 `gift_value`。 |
| `gift_value` | int64 | 统计窗口内该用户在本房间送礼贡献总值。 |
| `updated_at_ms` | int64 | 该用户本周期贡献记录更新时间。 |
| `profile` | object? | 当前用户展示资料。资料服务异常或用户资料缺失时可能为空。 |
`profile` / `profiles[]` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户长 ID。 |
| `username` | string | 昵称。 |
| `avatar` | string | 头像 URL。 |
| `display_user_id` | string | 展示 ID。 |
| `pretty_id` | string | 靓号 ID没有时为空或不返回。 |
| `pretty_display_user_id` | string | 靓号展示 ID没有时为空或不返回。 |
| `gender` | string | 性别展示值。 |
| `age` | int32 | 年龄。 |
| `country` | string | 国家码。 |
| `country_name` | string | 国家名称。 |
| `country_display_name` | string | 国家展示名。 |
| `country_flag` | string | 国家旗帜。 |
| `vip` | object | VIP 展示资料。 |
| `level` | object | 等级展示资料。 |
| `level_badges` | object | 等级徽章资料。 |
| `badges` | array | 当前佩戴徽章。 |
| `avatar_frame` | object | 当前佩戴头像框。 |
| `profile_card` | object | 当前佩戴资料卡。 |
| `vehicle` | object | 当前佩戴座驾。 |
| `mic_seat_animation` | object | 当前佩戴麦位动画。 |
| `charm` | int64 | 魅力值。 |
## 错误码
| HTTP | `code` | 场景 |
| --- | --- | --- |
| 400 | `INVALID_ARGUMENT` | `room_id` 格式错误、`period` 不是 `day/week/month``limit<=0` 或不是数字。 |
| 401 | `UNAUTHORIZED` | 未登录或 token 无效。 |
| 403 | `PROFILE_REQUIRED` | 登录用户未完成资料流程gateway profile gate 拦截。 |
| 403 | `PERMISSION_DENIED` | viewer 当前不在该房间,不能读取房内贡献榜。 |
| 502 | `UPSTREAM_ERROR` | room-service 或 user-service 调用失败。 |
失败返回示例:
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_bad_period"
}
```
## Flutter 调用规则
| 场景 | 处理 |
| --- | --- |
| 进入房间后首次展示 | `POST /api/v1/rooms/join` 成功、房间页建立后,请求当前默认 tab例如 `period=day`。 |
| tab 切换 | 按 `room_id + period` 单独请求本接口;不要把 day 的数据复制给 week/month。 |
| 送礼成功后 | 刷新当前展示的 `period`,或收到本地送礼成功事件后延迟刷新;不要依赖送礼接口返回榜单。 |
| 心跳 | `POST /api/v1/rooms/heartbeat` 只维护在线状态,不用它刷新榜单。 |
| 进房首包 | 新版进房继续传 `response_mode=lite`;贡献榜由本接口独立拉取。 |
| 空榜 | `contribution_rank=[]``profiles=[]``total=0` 时展示空状态。 |
## 相关 IM
本接口没有独立 IM 消息。房间内送礼动效、公屏礼物消息仍按现有送礼链路和房间 IM 事件处理;本接口只用于 Flutter 在面板打开、tab 切换或送礼后重拉榜单快照。

View File

@ -550,6 +550,7 @@ definitions:
- region
- country
- all_active_users
- all_registered_users
target_filter_json:
type: string
template_snapshot_json:

View File

@ -1223,6 +1223,56 @@ paths:
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/{room_id}/contribution-rank:
get:
tags:
- rooms
summary: 查询房内用户贡献周期榜
operationId: getRoomContributionRank
description: 房间内 day/week/month 三个 tab 使用的用户贡献榜gateway 使用 access token user_id 作为 viewerroom-service 要求 viewer 仍在该房间。查询只读周期统计表,不刷新 presence。
security:
- BearerAuth: []
parameters:
- name: room_id
in: path
required: true
type: string
pattern: "^[A-Za-z0-9_-]{1,48}$"
maxLength: 48
- name: period
in: query
required: false
type: string
default: day
enum:
- day
- week
- month
- name: limit
in: query
required: false
type: integer
format: int32
default: 20
minimum: 1
maximum: 100
responses:
"200":
description: 返回当前房间指定周期的用户贡献榜和榜单用户展示资料。
schema:
$ref: "#/definitions/RoomContributionRankEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/{room_id}/follow:
post:
tags:
@ -4893,6 +4943,25 @@ definitions:
updated_at_ms:
type: integer
format: int64
RoomContributionRankItemData:
type: object
properties:
rank:
type: integer
format: int64
user_id:
type: string
score:
type: integer
format: int64
gift_value:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
profile:
$ref: "#/definitions/RoomUserProfileData"
RoomUserProfileData:
type: object
properties:
@ -4983,6 +5052,48 @@ definitions:
server_time_ms:
type: integer
format: int64
RoomContributionRankData:
type: object
required:
- room_id
- period
- start_at_ms
- end_at_ms
- total
- contribution_rank
- profiles
- server_time_ms
properties:
room_id:
type: string
period:
type: string
enum:
- day
- week
- month
start_at_ms:
type: integer
format: int64
description: 周期开始 UTC epoch ms包含。
end_at_ms:
type: integer
format: int64
description: 周期结束 UTC epoch ms不包含。
total:
type: integer
format: int64
contribution_rank:
type: array
items:
$ref: "#/definitions/RoomContributionRankItemData"
profiles:
type: array
items:
$ref: "#/definitions/RoomUserProfileData"
server_time_ms:
type: integer
format: int64
RoomFollowData:
type: object
required:
@ -5491,6 +5602,13 @@ definitions:
properties:
data:
$ref: "#/definitions/RoomSnapshotData"
RoomContributionRankEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RoomContributionRankData"
RoomFollowEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -1039,6 +1039,7 @@ definitions:
type: string
enum:
- all_active_users
- all_registered_users
- region
- country
cursor_user_id:

View File

@ -91,7 +91,7 @@ mysql_auto_migrate: false
```yaml
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800
access_token_ttl_sec: 604800
refresh_token_ttl_sec: 2592000
signing_alg: "HS256"
signing_secret: "dev-secret"

View File

@ -111,7 +111,7 @@ Response `data`:
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"refresh_token": "refresh_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer",
"app_code": "lalu",
"is_new_user": true,
@ -313,7 +313,7 @@ Response `data`:
"user_id": "918274650129384448",
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer",
"display_user_id": "163000",
"profile_completed": true,
@ -441,7 +441,7 @@ Response `data`:
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"refresh_token": "refresh_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer"
}
```
@ -479,7 +479,7 @@ Response `data`:
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"refresh_token": "refresh_new_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer"
}
```
@ -955,9 +955,11 @@ Refresh 返回当前 `display_user_id`,避免客户端持有旧短号。临时
v1 使用:
- `access_token`: JWT短有效期,默认 30 分钟
- `access_token`: JWT默认 168 小时,即 604800 秒
- `refresh_token`: opaque random token默认 30 天,只保存 hash。
- `session_id`: 服务端会话 ID用于 logout、审计和多端管理。
- access token 重签时不得超过对应 refresh session 的 `expires_at_ms`;靠近 30 天 session 到期时,`expires_in_sec` 会按剩余 session 生命周期缩短。
- refresh 轮换和 logout 成功前必须把旧 `session_id` 写入 gateway 可读的 Redis denylist缺少 denylist 写入能力时不能返回成功,否则旧 JWT 会在 168 小时窗口内继续可用。
JWT claims
@ -974,7 +976,7 @@ JWT claims
"app_code": "lalu",
"sid": "sess_xxx",
"iat": 1777000000,
"exp": 1777001800,
"exp": 1777604800,
"typ": "access"
}
```

View File

@ -410,11 +410,11 @@ Response `data`:
"expire_at_ms": 1778086400000,
"join_groups": [
{
"group_id": "hy_lalu_bc_g",
"group_id": "hy_lalu_bc_g_v2",
"type": "global_broadcast"
},
{
"group_id": "hy_lalu_bc_r_1001",
"group_id": "hy_lalu_bc_r_v2_1001",
"type": "region_broadcast",
"region_id": 1001
}

1
go.mod
View File

@ -20,6 +20,7 @@ replace hyapp.local/api => ./api
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect

3
go.sum
View File

@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/apache/rocketmq-client-go/v2 v2.1.2 h1:yt73olKe5N6894Dbm+ojRf/JPiP0cxfDNNffKwhpJVg=
github.com/apache/rocketmq-client-go/v2 v2.1.2/go.mod h1:6I6vgxHR3hzrvn+6n/4mrhS+UTulzK/X9LB2Vk1U5gE=
@ -67,6 +69,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=

View File

@ -22,8 +22,16 @@ const (
KindRegionBroadcast Kind = "region_broadcast"
// globalSuffix/regionToken 是可解析协议的一部分;不要在业务代码里重复拼接这些片段。
// globalSuffix 是 v1 全局播报群后缀ChatRoom 类型受套餐成员上限约束(线上已触发 10038
// 仅保留解析与过渡期双发能力,新群一律生成 v2。
globalSuffix = "_bc_g"
regionToken = "_bc_r_"
// globalSuffixV2 是 v2 全局播报群后缀AVChatRoom 类型,成员数不受套餐上限约束。
globalSuffixV2 = "_bc_g_v2"
// regionToken 是 v1 区域播报群片段:与 v1 全局群同样受 ChatRoom 成员上限约束,
// 仅保留解析与过渡期双发能力,新群一律生成 v2。
regionToken = "_bc_r_"
// regionTokenV2 是 v2 区域播报群片段AVChatRoom 类型,成员数不受套餐上限约束。
regionTokenV2 = "_bc_r_v2_"
)
// Kind 是回调分流和客户端 join 列表使用的稳定分类,不直接暴露腾讯云原始字符串判断。
@ -37,14 +45,24 @@ type ParsedGroup struct {
RoomID string
}
// GlobalBroadcastGroupID 生成 App 全局播报群 ID
// GlobalBroadcastGroupID 生成 App 全局播报群 IDv2AVChatRoom
// app_code 先 Normalize 再进入 GroupID确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
func GlobalBroadcastGroupID(value string) (string, error) {
return GlobalBroadcastGroupIDWithPrefix("", value)
}
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 ID
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 IDv2AVChatRoom
func GlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, error) {
return globalBroadcastGroupIDWithSuffix(prefix, value, globalSuffixV2)
}
// LegacyGlobalBroadcastGroupIDWithPrefix 生成 v1 全局播报群 ID。
// 仅供过渡期双发使用:老客户端在拉取新 join 列表前仍停留在 v1 群里。
func LegacyGlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, error) {
return globalBroadcastGroupIDWithSuffix(prefix, value, globalSuffix)
}
func globalBroadcastGroupIDWithSuffix(prefix string, value string, suffix string) (string, error) {
prefix = strings.TrimSpace(prefix)
if err := validateGroupIDPrefix(prefix); err != nil {
return "", err
@ -53,21 +71,31 @@ func GlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, erro
if err := validateAppCodeForGroupID(app); err != nil {
return "", err
}
groupID := prefix + "hy_" + app + globalSuffix
groupID := prefix + "hy_" + app + suffix
if len(groupID) > roomid.MaxStringIDLength {
return "", fmt.Errorf("global broadcast group_id is too long")
}
return groupID, nil
}
// RegionBroadcastGroupID 生成区域播报群 ID
// RegionBroadcastGroupID 生成区域播报群 IDv2AVChatRoom
// region_id <= 0 被拒绝GLOBAL/未知区域不能变成一个可 join 的区域群。
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
}
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 IDv2AVChatRoom
func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) {
return regionBroadcastGroupIDWithToken(prefix, value, regionID, regionTokenV2)
}
// LegacyRegionBroadcastGroupIDWithPrefix 生成 v1 区域播报群 ID。
// 仅供过渡期双发和旧群成员移除使用:老客户端在拉取新 join 列表前仍停留在 v1 群里。
func LegacyRegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) {
return regionBroadcastGroupIDWithToken(prefix, value, regionID, regionToken)
}
func regionBroadcastGroupIDWithToken(prefix string, value string, regionID int64, token string) (string, error) {
prefix = strings.TrimSpace(prefix)
if err := validateGroupIDPrefix(prefix); err != nil {
return "", err
@ -79,7 +107,7 @@ func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int6
if regionID <= 0 {
return "", fmt.Errorf("region_id must be positive")
}
groupID := prefix + "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
groupID := prefix + "hy_" + app + token + strconv.FormatInt(regionID, 10)
if len(groupID) > roomid.MaxStringIDLength {
return "", fmt.Errorf("region broadcast group_id is too long")
}
@ -168,6 +196,14 @@ func RoomIDFromRoomGroupID(groupID string) (string, bool) {
}
func parseBroadcastGroupID(groupID string) ParsedGroup {
// v2 后缀必须先于 v1 判断v2 群和 v1 群解析为同一个 Kind调用方不感知版本。
if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffixV2) {
app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffixV2)
if validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindGlobalBroadcast, AppCode: app}
}
return ParsedGroup{Kind: KindUnknown}
}
if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) {
app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix)
if validateAppCodeForGroupID(app) == nil {
@ -178,21 +214,33 @@ func parseBroadcastGroupID(groupID string) ParsedGroup {
if after, ok := strings.CutPrefix(groupID, "hy_"); ok {
body := after
tokenIndex := strings.LastIndex(body, regionToken)
if tokenIndex >= 0 {
app := body[:tokenIndex]
rawRegion := body[tokenIndex+len(regionToken):]
regionID, err := strconv.ParseInt(rawRegion, 10, 64)
if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}
}
return ParsedGroup{Kind: KindUnknown}
// v2 片段必须先于 v1 判断v1 的 `_bc_r_` 是 v2 `_bc_r_v2_` 的前缀,
// 先按 v1 解析会把 v2 群的 `v2_<id>` 当成非法区域。两个版本解析为同一个 Kind调用方不感知版本。
if parsed, matched := parseRegionBroadcastBody(body, regionTokenV2); matched {
return parsed
}
if parsed, matched := parseRegionBroadcastBody(body, regionToken); matched {
return parsed
}
}
return ParsedGroup{Kind: KindUnknown}
}
func parseRegionBroadcastBody(body string, token string) (ParsedGroup, bool) {
tokenIndex := strings.LastIndex(body, token)
if tokenIndex < 0 {
return ParsedGroup{Kind: KindUnknown}, false
}
app := body[:tokenIndex]
rawRegion := body[tokenIndex+len(token):]
regionID, err := strconv.ParseInt(rawRegion, 10, 64)
if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}, true
}
return ParsedGroup{Kind: KindUnknown}, true
}
func validateGroupIDPrefix(prefix string) error {
if prefix == "" {
return nil

View File

@ -7,7 +7,7 @@ func TestBroadcastGroupIDGenerateAndParse(t *testing.T) {
if err != nil {
t.Fatalf("GlobalBroadcastGroupID failed: %v", err)
}
if global != "hy_lalu_bc_g" {
if global != "hy_lalu_bc_g_v2" {
t.Fatalf("global group mismatch: %s", global)
}
parsedGlobal := Parse(global)
@ -15,17 +15,43 @@ func TestBroadcastGroupIDGenerateAndParse(t *testing.T) {
t.Fatalf("global parse mismatch: %+v", parsedGlobal)
}
// v1 旧群在过渡期仍要能解析成同一 Kind双发与腾讯回调都依赖这一点。
legacy, err := LegacyGlobalBroadcastGroupIDWithPrefix("", "lalu")
if err != nil {
t.Fatalf("LegacyGlobalBroadcastGroupIDWithPrefix failed: %v", err)
}
if legacy != "hy_lalu_bc_g" {
t.Fatalf("legacy global group mismatch: %s", legacy)
}
parsedLegacy := Parse(legacy)
if parsedLegacy.Kind != KindGlobalBroadcast || parsedLegacy.AppCode != "lalu" {
t.Fatalf("legacy global parse mismatch: %+v", parsedLegacy)
}
region, err := RegionBroadcastGroupID("lalu", 1001)
if err != nil {
t.Fatalf("RegionBroadcastGroupID failed: %v", err)
}
if region != "hy_lalu_bc_r_1001" {
if region != "hy_lalu_bc_r_v2_1001" {
t.Fatalf("region group mismatch: %s", region)
}
parsedRegion := Parse(region)
if parsedRegion.Kind != KindRegionBroadcast || parsedRegion.AppCode != "lalu" || parsedRegion.RegionID != 1001 {
t.Fatalf("region parse mismatch: %+v", parsedRegion)
}
// v1 旧区域群在过渡期仍要能解析成同一 Kind双发、切区移除和腾讯回调都依赖这一点。
legacyRegion, err := LegacyRegionBroadcastGroupIDWithPrefix("", "lalu", 1001)
if err != nil {
t.Fatalf("LegacyRegionBroadcastGroupIDWithPrefix failed: %v", err)
}
if legacyRegion != "hy_lalu_bc_r_1001" {
t.Fatalf("legacy region group mismatch: %s", legacyRegion)
}
parsedLegacyRegion := Parse(legacyRegion)
if parsedLegacyRegion.Kind != KindRegionBroadcast || parsedLegacyRegion.AppCode != "lalu" || parsedLegacyRegion.RegionID != 1001 {
t.Fatalf("legacy region parse mismatch: %+v", parsedLegacyRegion)
}
}
func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
@ -35,9 +61,15 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
if parsed := Parse("hy_lalu_bc_r_0"); parsed.Kind != KindUnknown {
t.Fatalf("zero region must be rejected: %+v", parsed)
}
if parsed := Parse("hy_lalu_bc_r_v2_0"); parsed.Kind != KindUnknown {
t.Fatalf("zero v2 region must be rejected: %+v", parsed)
}
if _, err := RegionBroadcastGroupID("lalu", 0); err == nil {
t.Fatal("zero region must not generate a region broadcast group")
}
if _, err := LegacyRegionBroadcastGroupIDWithPrefix("", "lalu", 0); err == nil {
t.Fatal("zero region must not generate a legacy region broadcast group")
}
if _, err := GlobalBroadcastGroupID("bad app"); err == nil {
t.Fatal("app_code with spaces must be rejected")
}
@ -63,16 +95,26 @@ func TestBroadcastGroupIDWithPrefix(t *testing.T) {
if err != nil {
t.Fatalf("GlobalBroadcastGroupIDWithPrefix failed: %v", err)
}
if global != "test_hy_lalu_bc_g" {
if global != "test_hy_lalu_bc_g_v2" {
t.Fatalf("prefixed global group mismatch: %s", global)
}
region, err := RegionBroadcastGroupIDWithPrefix("test_", "lalu", 1001)
if err != nil {
t.Fatalf("RegionBroadcastGroupIDWithPrefix failed: %v", err)
}
if region != "test_hy_lalu_bc_r_1001" {
if region != "test_hy_lalu_bc_r_v2_1001" {
t.Fatalf("prefixed region group mismatch: %s", region)
}
legacyRegion, err := LegacyRegionBroadcastGroupIDWithPrefix("test_", "lalu", 1001)
if err != nil {
t.Fatalf("LegacyRegionBroadcastGroupIDWithPrefix failed: %v", err)
}
if legacyRegion != "test_hy_lalu_bc_r_1001" {
t.Fatalf("prefixed legacy region group mismatch: %s", legacyRegion)
}
if parsedLegacy := ParseWithPrefix(legacyRegion, "test_"); parsedLegacy.Kind != KindRegionBroadcast || parsedLegacy.RegionID != 1001 {
t.Fatalf("prefixed legacy region parse mismatch: %+v", parsedLegacy)
}
parsedGlobal := ParseWithPrefix(global, "test_")
if parsedGlobal.Kind != KindGlobalBroadcast || parsedGlobal.AppCode != "lalu" {

View File

@ -21,6 +21,9 @@ const (
DefaultEndpoint = "console.tim.qq.com"
// DefaultGroupType 使用 ChatRoom保留消息历史能力AVChatRoom 不适合需要补偿和漫游的房间消息。
DefaultGroupType = "ChatRoom"
// BroadcastGroupType 使用 AVChatRoom直播群ChatRoom 受套餐成员上限约束(标准版 2000 人,
// 已在线上触发 10038 拒绝加群AVChatRoom 成员无上限、进出免审批,播报横幅也不需要漫游补偿。
BroadcastGroupType = "AVChatRoom"
// createGroupCommand 是建群 REST 命令;房间群、全局播报群、区域播报群都复用这一个能力。
createGroupCommand = "v4/group_open_http_svc/create_group"
// sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。
@ -218,7 +221,10 @@ func (c *RESTClient) EnsureGroup(ctx context.Context, spec GroupSpec) error {
if spec.Type == "" {
spec.Type = c.cfg.GroupType
}
if spec.ApplyJoinOption == "" {
if spec.Type == BroadcastGroupType {
// AVChatRoom 不支持 ApplyJoinOption带上会被腾讯以非法参数拒绝。
spec.ApplyJoinOption = ""
} else if spec.ApplyJoinOption == "" {
spec.ApplyJoinOption = "FreeAccess"
}

View File

@ -1,92 +0,0 @@
{"at_ms":1780381127156,"mode":"dry-run","to_account":"424242","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"12345678","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585934","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585935","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586014","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586015","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586332","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586333","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971589","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971590","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971617","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971618","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971718","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971719","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651195","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651196","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651197","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651198","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935471","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935472","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935473","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935474","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936181","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936182","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936183","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936184","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912277","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912278","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864464","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864465","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047904","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047905","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064123","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064124","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778061358","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778062287","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"991205120003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991803","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991804","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196761","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196762","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865153","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865154","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865155","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865156","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865157","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865158","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865159","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865160","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865161","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865162","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318705991371722752","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707093894860800","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707097803952128","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707100643495936","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707103256547328","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707105987039232","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"320078559995498496","status":"dry_run"}

View File

@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送座驾',
can_grant_badge TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送徽章',
can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送 VIP',
can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
@ -68,6 +69,15 @@ 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 = 'manager_profiles' AND COLUMN_NAME = 'can_grant_vip') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送 VIP'' AFTER can_grant_badge',
'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 = 'manager_profiles' AND COLUMN_NAME = 'can_add_bd_leader') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 BD Leader'' AFTER can_update_user_level',
@ -115,7 +125,7 @@ DEALLOCATE PREPARE stmt;
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
)
SELECT
@ -132,6 +142,7 @@ SELECT
1,
1,
1,
1,
0,
MIN(bl.created_at_ms),
MAX(bl.updated_at_ms)

View File

@ -0,0 +1,12 @@
-- 财务系统 APP 充值详情payment_orders 补 Google Play Orders API 实付明细列。
-- 与 wallet-service 启动迁移 ensureGooglePaymentPaidDetailsSchema 等价,供手工升级线上库使用。
ALTER TABLE payment_orders
ADD COLUMN paid_currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '用户实付币种,来自 Google Orders API' AFTER amount_micro,
ADD COLUMN paid_amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '用户实付总额微单位(实付币种)' AFTER paid_currency_code,
ADD COLUMN paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)' AFTER paid_amount_micro,
ADD COLUMN paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)' AFTER paid_tax_micro,
ADD COLUMN paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间UTC epoch ms0 表示未同步' AFTER paid_net_micro;
ALTER TABLE payment_orders
ADD INDEX idx_payment_orders_wallet_tx (app_code, wallet_transaction_id);

View File

@ -0,0 +1,6 @@
USE hyapp_wallet;
-- VIP 购买已改为所有等级直接按金币购买;保留字段只为兼容旧协议,线上已有累充门槛统一清零。
UPDATE vip_levels
SET required_recharge_coin_amount = 0
WHERE required_recharge_coin_amount <> 0;

View File

@ -0,0 +1,92 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- apps 同时按 app_code 和 package_name/platform 唯一;包名若已属于别的 app必须中止避免 upsert 误改非 Yumi 行。
CREATE TEMPORARY TABLE yumi_app_package_conflict_guard (
guard_id TINYINT NOT NULL PRIMARY KEY
) ENGINE=Memory;
INSERT INTO yumi_app_package_conflict_guard (guard_id) VALUES (1);
INSERT INTO yumi_app_package_conflict_guard (guard_id)
SELECT 1
FROM apps
WHERE package_name = 'com.org.yumi'
AND platform = ''
AND app_code <> 'yumi'
LIMIT 1;
DROP TEMPORARY TABLE yumi_app_package_conflict_guard;
-- App 注册表是 gateway 按 app_code 或包名解析租户的事实来源Yumi 本地联调必须先注册为 active。
INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms)
VALUES ('yumi', 'Yumi', 'com.org.yumi', '', 'active', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name),
package_name = VALUES(package_name),
platform = VALUES(platform),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
-- 快捷建号和三方注册都会读取 app 级风控配置;补齐默认值避免 app 支持后注册链路再失败。
INSERT INTO auth_risk_configs (app_code, max_accounts_per_device, updated_by_admin_id, created_at_ms, updated_at_ms)
VALUES ('yumi', 1, 0, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
app_code = app_code;
-- 注册资料会校验 countries本地 Yumi 没有国家主数据时快捷建号会被 country is not supported 拦住。
INSERT INTO countries (
app_code, country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT
'yumi', country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, @now_ms, @now_ms
FROM countries
WHERE app_code = 'lalu'
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
iso_alpha3 = VALUES(iso_alpha3),
iso_numeric = VALUES(iso_numeric),
country_display_name = VALUES(country_display_name),
phone_country_code = VALUES(phone_country_code),
flag = VALUES(flag),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms);
-- 区域映射用于注册时把国家收敛到服务端 region_id复制 Lalu 目录即可满足本地联调。
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
)
SELECT
'yumi', region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, @now_ms, @now_ms
FROM regions
WHERE app_code = 'lalu'
ON DUPLICATE KEY UPDATE
name = VALUES(name),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO region_countries (
app_code, region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT
'yumi', target_region.region_id, source_mapping.country_code, source_mapping.status,
source_mapping.created_by_user_id, source_mapping.updated_by_user_id, @now_ms, @now_ms
FROM region_countries source_mapping
JOIN regions source_region
ON source_region.app_code = source_mapping.app_code
AND source_region.region_id = source_mapping.region_id
JOIN regions target_region
ON target_region.app_code = 'yumi'
AND target_region.region_code = source_region.region_code
WHERE source_mapping.app_code = 'lalu'
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms);

View File

@ -0,0 +1,12 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_grant_vip') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送 VIP'' AFTER can_grant_badge',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -30,7 +30,7 @@ ENV TZ=UTC
WORKDIR /app
RUN apk add --no-cache ca-certificates wget && adduser -D -g '' appuser
RUN apk add --no-cache ca-certificates wget tzdata && adduser -D -g '' appuser
COPY --from=builder /out/server /app/server
COPY server/admin/configs/config.yaml /app/config.yaml

View File

@ -64,9 +64,9 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
- 数据权限通过角色的 `data-scopes` 保存,并在后台用户列表/导出查询中执行。
- 任务产物默认写入 `storage/exports`,通过 `/api/v1/jobs/:id/artifact` 下载。
## Full Server Notice
## System Message Push
菜单入口:运营管理 / 全服通知,菜单 code 为 `operation-full-server-notice`,页面路径为 `/operations/full-server-notices`。
菜单入口:APP配置 / 系统消息推送,菜单 code 为 `operation-full-server-notice`,页面路径为 `/app-config/system-message-push`。
接口:
@ -74,7 +74,7 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
POST /api/v1/admin/operations/full-server-notices/fanout
```
参数只传后台需要的命令字段:`message_type``system``activity``target_scope` 支持 `all_active_users`、`single_user``user_ids``region``country`,内容字段为 `title``summary``body`,跳转字段为 `action_type``action_param`,扩展字段为 `metadata_json``command_id` 是幂等键,不传时由 admin-server 按管理员和当前时间生成。
参数只传后台需要的命令字段:`message_type``system``activity``target_scope` 支持 `all_registered_users`、`all_active_users`、`single_user``user_ids``region``country`,内容字段为 `title``summary``body`,跳转字段为 `action_type``action_param`,扩展字段为 `metadata_json``command_id` 是幂等键,不传时由 admin-server 按管理员和当前时间生成。
返回值包含 `job_id``status``created``command_id``message_type``target_scope`。接口只创建 activity-service 的 `message_fanout_jobs`,实际逐用户写入由 message fanout worker 分批完成。

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"flag"
"fmt"
"log"
"log/slog"
"net/http"
@ -19,6 +20,7 @@ import (
"hyapp-admin-server/internal/integration/activityclient"
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/googleorders"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/integration/userclient"
@ -40,6 +42,7 @@ import (
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi"
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
@ -189,6 +192,12 @@ func main() {
fatalRuntime("connect_dashboard_external_sources_failed", err)
}
defer closeDashboardExternalSources()
financeBillSources, closeFinanceBillSources, err := connectFinanceBillSources(context.Background(), cfg.FinanceBillSources, moneyRegionSources, dashboardExternalSourceIndex(dashboardExternalSources))
if err != nil {
closeDashboardExternalSources()
fatalRuntime("connect_finance_bill_sources_failed", err)
}
defer closeFinanceBillSources(context.Background())
store := repository.New(db)
if cfg.MySQLAutoMigrate {
@ -196,6 +205,8 @@ func main() {
fatalRuntime("auto_migrate_failed", err)
}
}
// legacy 账单源的谷歌实付同步依赖 admin 库缓存表store 建好后再注入;凭证异常只降级该 App不阻断启动。
wireLegacyGooglePaidSync(financeBillSources, cfg.FinanceBillSources, store)
if *runBootstrap || cfg.Bootstrap.Enabled {
if err := store.Seed(cfg); err != nil {
fatalRuntime("seed_database_failed", err)
@ -289,6 +300,16 @@ func main() {
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
)
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
dashboardService := dashboardmodule.NewService(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...))
databiService := databimodule.NewService(
store,
dashboardService,
appregistrymodule.NewService(userDB),
userclient.NewGRPC(userConn),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
)
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
@ -304,7 +325,8 @@ func main() {
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...)),
Dashboard: dashboardmodule.NewWithService(dashboardService),
Databi: databimodule.New(databiService, store, auditHandler),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
@ -327,7 +349,7 @@ func main() {
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...)),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...), paymentmodule.WithRechargeBillSources(financeBillSources...)),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
@ -432,6 +454,127 @@ func connectMoneyRegionSources(ctx context.Context, configs []config.MoneyRegion
return sources, cleanup, nil
}
// connectFinanceBillSources 连接 legacy App 的账单 Mongo启动阶段 ping 失败直接退出,避免财务明细静默漏掉 Yumi 账单。
func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBillSourceConfig, regionSources []paymentmodule.MoneyRegionSource, dashboardSources map[string]dashboardmodule.ExternalDashboardSource) ([]paymentmodule.RechargeBillSource, func(context.Context), error) {
sources := []paymentmodule.RechargeBillSource{}
clients := []*mongo.Client{}
cleanup := func(closeCtx context.Context) {
for _, client := range clients {
if client != nil {
_ = client.Disconnect(closeCtx)
}
}
}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
continue
}
connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
if err != nil {
cancel()
cleanup(ctx)
return nil, nil, err
}
if err := client.Ping(connectCtx, readpref.Primary()); err != nil {
cancel()
_ = client.Disconnect(ctx)
cleanup(ctx)
return nil, nil, err
}
cancel()
clients = append(clients, client)
source := paymentmodule.NewMongoRechargeBillSource(client.Database(sourceConfig.MongoDatabase), sourceConfig, regionSources...)
if source != nil {
dashboardSource := dashboardSources[source.AppCode()]
if dashboardSource == nil {
cleanup(ctx)
return nil, nil, fmt.Errorf("finance bill source %s requires matching dashboard_external_sources entry for coin seller stats", source.AppCode())
}
source.SetDashboardCoinSellerSource(dashboardSource)
sources = append(sources, source)
}
}
return sources, cleanup, nil
}
func dashboardExternalSourceIndex(sources []dashboardmodule.ExternalDashboardSource) map[string]dashboardmodule.ExternalDashboardSource {
index := map[string]dashboardmodule.ExternalDashboardSource{}
for _, source := range sources {
if source == nil {
continue
}
appCode := strings.ToLower(strings.TrimSpace(source.AppCode()))
if appCode == "" {
continue
}
index[appCode] = source
}
return index
}
// wireLegacyGooglePaidSync 为配置了 Play Console 包名与服务账号的 legacy 账单源开启谷歌实付同步。
func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, configs []config.FinanceBillSourceConfig, store *repository.Store) {
configsByApp := map[string]config.FinanceBillSourceConfig{}
for _, sourceConfig := range configs {
configsByApp[strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))] = sourceConfig
}
for _, source := range sources {
mongoSource, ok := source.(*paymentmodule.MongoRechargeBillSource)
if !ok {
continue
}
sourceConfig := configsByApp[source.AppCode()]
if sourceConfig.GooglePackageName == "" ||
(sourceConfig.GoogleServiceAccountJSON == "" && sourceConfig.GoogleServiceAccountFile == "") {
continue
}
client, err := googleorders.New(googleorders.Config{
PackageName: sourceConfig.GooglePackageName,
ServiceAccountJSON: sourceConfig.GoogleServiceAccountJSON,
ServiceAccountFile: sourceConfig.GoogleServiceAccountFile,
})
if err != nil {
slog.Error("legacy_google_paid_sync_disabled", "app_code", source.AppCode(), "error", err.Error())
continue
}
mongoSource.SetGooglePaidSync(store, client)
slog.Info("legacy_google_paid_sync_enabled", "app_code", source.AppCode(), "package_name", sourceConfig.GooglePackageName)
}
}
// databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录;
// 同一 App 配多个源(如 cdc MySQL + Mongo 补充)时只取一次。
func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimodule.LegacyAppDescriptor {
out := []databimodule.LegacyAppDescriptor{}
seen := map[string]struct{}{}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
continue
}
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
if appCode == "" {
continue
}
if _, ok := seen[appCode]; ok {
continue
}
seen[appCode] = struct{}{}
out = append(out, databimodule.LegacyAppDescriptor{AppCode: appCode, AppName: strings.TrimSpace(sourceConfig.AppName)})
}
return out
}
func databiLegacyRegionCatalogs(sources []paymentmodule.MoneyRegionSource) []databimodule.LegacyRegionCatalogSource {
out := []databimodule.LegacyRegionCatalogSource{}
for _, source := range sources {
if catalog, ok := source.(databimodule.LegacyRegionCatalogSource); ok && catalog != nil {
out = append(out, catalog)
}
}
return out
}
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
for _, source := range sources {
@ -446,12 +589,20 @@ func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashb
func connectDashboardExternalSources(ctx context.Context, configs []config.DashboardExternalSourceConfig, regionResolvers ...dashboardmodule.ExternalDashboardRegionResolver) ([]dashboardmodule.ExternalDashboardSource, func(), error) {
sources := []dashboardmodule.ExternalDashboardSource{}
dbs := []*sql.DB{}
mongoClients := []*mongo.Client{}
cleanup := func() {
for _, db := range dbs {
if db != nil {
_ = db.Close()
}
}
closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, client := range mongoClients {
if client != nil {
_ = client.Disconnect(closeCtx)
}
}
}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
@ -469,6 +620,63 @@ func connectDashboardExternalSources(ctx context.Context, configs []config.Dashb
sources = append(sources, source)
}
continue
case "aslan_mongo":
// Aslan 当前要求 admin-server 直接读外接项目 Mongo 聚合事实;启动期先 ping
// 避免前端选择 Aslan 后才暴露连接或凭证错误。
client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
if err != nil {
cleanup()
return nil, nil, err
}
pingCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
if err := client.Ping(pingCtx, readpref.PrimaryPreferred()); err != nil {
cancel()
_ = client.Disconnect(ctx)
cleanup()
return nil, nil, err
}
cancel()
var legacyDB *sql.DB
if strings.TrimSpace(sourceConfig.LegacyMySQLDSN) != "" {
// 金币代理发货充值金额只存在 legacy RDS它是 Aslan Mongo 大屏的补充事实源,失败应在启动期暴露。
legacyDB, err = sql.Open("mysql", sourceConfig.LegacyMySQLDSN)
if err != nil {
_ = client.Disconnect(ctx)
cleanup()
return nil, nil, fmt.Errorf("open dashboard legacy mysql for app %s: %w", sourceConfig.AppCode, err)
}
legacyCtx, legacyCancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
if err := legacyDB.PingContext(legacyCtx); err != nil {
legacyCancel()
_ = legacyDB.Close()
_ = client.Disconnect(ctx)
cleanup()
return nil, nil, fmt.Errorf("ping dashboard legacy mysql for app %s: %w", sourceConfig.AppCode, err)
}
legacyCancel()
}
source, err := dashboardmodule.NewAslanMongoDashboardSourceWithLegacyDB(client.Database(sourceConfig.MongoDatabase), legacyDB, sourceConfig, regionResolvers...)
if err != nil {
if legacyDB != nil {
_ = legacyDB.Close()
}
_ = client.Disconnect(ctx)
cleanup()
return nil, nil, err
}
if source == nil {
if legacyDB != nil {
_ = legacyDB.Close()
}
_ = client.Disconnect(ctx)
continue
}
if legacyDB != nil {
dbs = append(dbs, legacyDB)
}
mongoClients = append(mongoClients, client)
sources = append(sources, source)
continue
case "", "mysql":
default:
cleanup()

View File

@ -19,10 +19,42 @@ money_region_sources:
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: "mongodb://REPLACE_ME"
mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000"
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
- enabled: true
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou"
mongo_collection: "sys_region_config"
request_timeout: "5s"
# Yumi 的充值账单在利雅得内网 likei Mongo与 hyapp-server 同 VPC启用后 APP 充值详情直接读该库。
finance_bill_sources:
- enabled: true
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000"
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: "com.org.yumiparty"
google_service_account_json: |
{"type":"service_account","project_id":"pc-api-4773779885716259569-760","private_key_id":"29f9ea87a8e161c362dd5bdffc3988c4b7e1ef39","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDaaggeBncPZlq6\nKLYlUEKV5eSmu9pf6TcMUHwKm9Nyzd+pAyUPe52Ar8G7B+7My2INIimh+kQ9hCZG\ndF95gn2JNFa0AZksjtZcwOcz7YxmhRpYAdE6keSlJTE8u1a6Z2Oou+tU2okyXuEp\ngXDH1ao0S2chfFB9U8z36uy67vG2txnwwjZbXqiroDuwN59I4JrjcMRL/hGP9uu+\ntC5ekIjyatu4Jn6EJlXxc5pS9Gv74ZezUWqRLeRMiKJBbWtOJeQv+1ulF9qL7K6B\n11o/gaqphnt+llgTVVVzQ9Eh7eHeRj8XgHRgLC2NWeMpi7h3Jz2SKMg0ArNMqdgn\nhygY1tAXAgMBAAECggEAMy9fGJ4+P6smfvL0gLkW6acXFyX17r0qS+X+s8PB4Wky\nzZpxkHfROOu3dHvO8EqHf3lulUmfvWTfTWqPR1wXzFQqL4QiX+lXfiQs6qP0X8A4\npMBERrwS/8rAB7IFiKibF9t2MowGU/odPUta4VIG0buL/zJxcHV3lvAEq2g82CsR\nAV+RcQM8CMlZNe7XdAnPjgReUodSQW+NIV0X5iZ9vsgLYZRwGXmslt8/4VHi+qR/\nOX+4yO7BVReVCpfNNxOm4TALmjw7aLvQQAwWhwD9+maA66l9ElQdsHRtseZEzkY/\nh0S8EODgw5gTFfEr9rOp79Im9Flnk9SjxfuIp0QF/QKBgQDuKcYu0j2rEU4Vv7kb\n9grGm5XUIJk3x9bCruRc9ODqHF3vovaDyndPSq0ViuZRha1vlP6COxo40W86OdFv\nY7lN0OnN2ZGPlyTgXqckOcb0Rk0vCLGaBKNOdGmc02r2hDXA4h44Obgy37IXuXEP\nNlTN3bF2OIgHWoImcGkmcYh9jQKBgQDqxZ3QoyUoLFyzA+MKlrqqq9D3AVZi38ob\n9j9Ki6ysAgRAgW8w36yst4ef/+f2GW/OcW7QWzfk2w08fT12Gsw7FPAFUZUETuTX\ndzdE0F+yDj2m7upJ/8Yv9Ohi9K2g4I05oogbq48DR5b/lZqADSGqsb1FwZFKsNp3\n39Dp0BlBMwKBgQCZeOH1GhYTPruK2Fl44zxeb7RFVhxmDakfG4SdQlANjOobmnAw\nzS/FMOIIl9GDhxkUZnb7hQqIwq1iYA/OL/0hYBbKSAG8/jENRPGALps+nm7ueDO6\nhHKYA/xqyvKKmPfqq8u9f7RrVCt3jlCE9QYBA3NwM021L2XfT2DzHQZPoQKBgFvf\nBUjV7v5vjb8H8Fr+bQHIxrdCMLn0dTTIAjB7xBBzoZJUlFx9yyazk0FLdUxa2+Pf\ng8vJRnAqQF3BbMHA7tbX9K1AJZ5P+UFQB7LIEAqvg/TFXa2jh7zQi/fdY+ymst0w\n+y5IzmgsJazSsGkXumr/rt+TRfYCixuJ3EkDBD79AoGBAJzrUkc748L/Cx8/FHOc\nR6OOedNFpQBM+6OfodBmv2rQzM5IUsANzlVXLctuZkIev63SmhrQ/Zl1WEQHsfwu\nz35wdrc+KT/FcjgswJmCAbg5U1D+dp3Up753Bm5uariI6b+S8oi+7hC4IQMpWmBR\npeYgkq63zzxK+0ZbnnWytuia\n-----END PRIVATE KEY-----\n","client_email":"aswat-service-pay@pc-api-4773779885716259569-760.iam.gserviceaccount.com","client_id":"112164268081938208775","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/aswat-service-pay%40pc-api-4773779885716259569-760.iam.gserviceaccount.com","universe_domain":"googleapis.com"}
- enabled: true
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: "com.chat.auu"
google_service_account_json: |
{"type":"service_account","project_id":"aslan-494908","private_key_id":"f3ab2df01b3f92b9f71f4b7d1ebdba3cff5ac93e","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDkCOxtBRVNg5CF\nIfgkT5gv1f2hSRnf6/MSTDYkgXy1sVSbLEXbsZGQdnsYSfAxNXURyJ0QXJOZV551\n+0XOlRIb+HLN++37olcFVm4GPLxQiVCCEbFOBwGPZTMi4RYgriDHEp2umY9Cns8Z\nHYlkYgbLxKLICpzyDJWDsyT3Tc4rBhRGv7+dulxSuPpF5AaLzbV/AjQTwee6qnV4\n8VEPHx/r+3IAJMVLt+VrgFwujIdzJGk8TIZKSSmZ4J6iN5dKgly1PyVu66bppbX9\nHkPDEFg8nxYK5F2/yjo7/VIFurfd5bGKGS3sqf0siRTXLsj0X/wUgt0RlWOpKPeX\nbZq9jzznAgMBAAECggEAASLUPrTMRt8VbLxfFps46GAaC+An21g7FUfA60yj2Onh\nwIYncPFBBuW4NkZEBpK8GxMTST4U1Co+FVtjnSRb+zyxIbqUFHFaGqI0GR7bV1Ff\nz84Two5BYTwBVbamXBJSAnviwjhsoMnWwUrG4POmEgTQRMvcvU33vri5Qewmz0sN\njXTNh/GIUxA+IbOUTaaTDBLSshp7JBOPjuF0ttsd/0zG1AFbaWWtZ1h8YCOhIw+g\np7/CdS3gO/uttgI8PiLj8AImlOcRz5FnFGTcHZqR7pk08W7Olm89ik3NZg+5IozP\nrJ8LOU7uQ7F50zpWRNvpBNaa4m35Ns6bOHTLEV4BXQKBgQD0q8hn1bddwC92RzMw\naBb0Qt0Najg2R/SDWaeSO6Tz3KTflGZXDFtalAOq5KXT47N7QdoIjlXGP50B5iV7\nK/I25f+GONcACPtnnLWyNvjq2OuMz0FIGEoUxY9DFP6aNkyNR5FiE2t2y2XiKmrV\nCRt+P4x/b/KTvdVI+caUSfXezQKBgQDul/F46Uj1evZZToA4XENXRN8oSb4I08cX\nBQK2WUs+KJsT9sWNaT1V5hQ8wXfuN2rhI0kqR0EURY3ycaA41rdLIeYqvAnmR5qC\nSO8HlC2+zQzjSrr7lyt8dcTughW8/9lCf+jANBNYBZrBN2ta3vlQ8eA8rFUThYnC\n8+PFo8MigwKBgFbbGJSL0MFONUsWsXxQpz1k8xYNDBFw78MlM5B87ezH+huIkd/6\n+f8opjinXJrgrVlnIiCBbr+m23TOH6YfDqggc9pRGTng9mZswi+WxjyQbuYYuQL/\n5GSFUXst28gg2IIa0uhvHmoYgH2OM0iXKBRkONsQgZui+zEhwjXoH4lNAoGAKVMJ\n0M5fA52Lg4ZUMO7R/xB/skOrdW3wwqzsflbS8G4qBfgs2URMCk+yW5+KvSi+C0aI\nSplSzUcKwd4qSQ3va0Twz6AH+umV+lDVjbN9hNmRDOEJp7/UGVdwh3ridvy9TYZH\n8tpSK2G1HxgRMQkDl6B9HSUgCySK6shBQB8QEi8CgYBAbUxCJn20RFmwU4bgRASe\ngoDGkN5Dw1SdauuWn+WySTyrsxdxvRcuyy4pQSDBadhnv24/30mza1ToeJeEsyJ/\nJuysBhWBjJKoYVC/qikgS6EuAnqlAKKg8jLIqERmNaFol0Squ03kTvMYx2aGa1dt\nwwMCwljNodGSgZRA3RlwOA==\n-----END PRIVATE KEY-----\n","client_email":"pay-purchase-verify@aslan-494908.iam.gserviceaccount.com","client_id":"100290296879529721706","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/pay-purchase-verify%40aslan-494908.iam.gserviceaccount.com","universe_domain":"googleapis.com"}
mysql_auto_migrate: false
migrations:
enabled: true
@ -78,13 +110,19 @@ dashboard_external_sources:
stat_timezone: "Asia/Riyadh"
request_timeout: "5s"
- enabled: true
type: "aslan_http"
type: "aslan_mongo"
app_code: "aslan"
app_name: "Aslan"
base_url: "https://console.atuchat.com"
overview_path: "/console/datav/aslan/region-country/statistics"
sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou"
# 金币代理发货充值金额来自 legacy RDS真实密码通过 HYAPP_ADMIN_ASLAN_DASHBOARD_LEGACY_MYSQL_DSN 注入覆盖。
legacy_mysql_dsn: "aslan:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai&timeout=10s&readTimeout=30s&writeTimeout=30s"
legacy_wallet_database: "atyou_wallet"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
# aslan_mongo 源按整段区间做聚合(注册/日活/充值/礼物 + 留存 cohort30 天区间比逐日查询重,
# 5s 会在长区间超时;线上建议 30s 起。
request_timeout: "30s"
finance_notifications:
dingtalk:
enabled: true

View File

@ -23,6 +23,38 @@ money_region_sources:
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
- enabled: false
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "atyou"
mongo_collection: "sys_region_config"
request_timeout: "5s"
# legacy App 的财务充值账单外部源:命中 app_code 后APP 充值详情直接读 likei Mongo 的内购明细。
finance_bill_sources:
- enabled: false
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: ""
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: ""
google_service_account_json: ""
google_service_account_file: ""
- enabled: false
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "atyou"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: ""
google_service_account_json: ""
google_service_account_file: ""
mysql_auto_migrate: true
migrations:
enabled: true
@ -77,11 +109,15 @@ dashboard_external_sources:
stat_timezone: "Asia/Riyadh"
request_timeout: "5s"
- enabled: false
type: "aslan_http"
type: "aslan_mongo"
app_code: "aslan"
app_name: "Aslan"
base_url: "https://console.atuchat.com"
overview_path: "/console/datav/aslan/region-country/statistics"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "test"
# 金币代理发货充值金额来自 legacy RDS真实密码通过环境配置注入不能写入仓库 YAML。
legacy_mysql_dsn: "aslan:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai&timeout=10s&readTimeout=30s&writeTimeout=30s"
legacy_wallet_database: "atyou_wallet"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
finance_notifications:

View File

@ -23,6 +23,7 @@ type Config struct {
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
@ -89,16 +90,20 @@ type StatisticsServiceConfig struct {
}
type DashboardExternalSourceConfig struct {
Enabled bool `yaml:"enabled"`
SourceType string `yaml:"type"`
AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MySQLDSN string `yaml:"mysql_dsn"`
BaseURL string `yaml:"base_url"`
OverviewPath string `yaml:"overview_path"`
StatTimezone string `yaml:"stat_timezone"`
RequestTimeout time.Duration `yaml:"request_timeout"`
Enabled bool `yaml:"enabled"`
SourceType string `yaml:"type"`
AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MySQLDSN string `yaml:"mysql_dsn"`
LegacyMySQLDSN string `yaml:"legacy_mysql_dsn"`
LegacyWalletDatabase string `yaml:"legacy_wallet_database"`
MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"`
BaseURL string `yaml:"base_url"`
OverviewPath string `yaml:"overview_path"`
StatTimezone string `yaml:"stat_timezone"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type FinanceNotificationsConfig struct {
@ -130,6 +135,25 @@ type MoneyRegionSourceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// FinanceBillSourceConfig 描述 legacy AppYumi/Aslan 等 likei 平台)的充值账单外部 Mongo 源;
// 财务系统 APP 充值详情按 app_code 命中后直接读 in_app_purchase_details不经过 wallet-service。
type FinanceBillSourceConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"`
MongoCollection string `yaml:"mongo_collection"`
RequestTimeout time.Duration `yaml:"request_timeout"`
// Google 实付同步(可选):配置该 App 在 Play Console 的包名与服务账号后,
// 财务页“查询谷歌实付”按钮即可对 legacy 谷歌账单拉取实付币种/总额/税/净收入。
// 服务账号需要对应 Play Console 的“查看财务数据”权限JSON 优先于文件路径。
GooglePackageName string `yaml:"google_package_name"`
GoogleServiceAccountJSON string `yaml:"google_service_account_json"`
GoogleServiceAccountFile string `yaml:"google_service_account_file"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -198,6 +222,27 @@ func Default() Config {
RequestTimeout: 5 * time.Second,
},
},
FinanceBillSources: []FinanceBillSourceConfig{
{
Enabled: false,
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
MongoDatabase: "tarab_all",
MongoCollection: "in_app_purchase_details",
RequestTimeout: 5 * time.Second,
},
{
Enabled: false,
AppCode: "aslan",
AppName: "Aslan",
// Aslanlikei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。
SysOrigin: "ATYOU",
MongoDatabase: "atyou",
MongoCollection: "in_app_purchase_details",
RequestTimeout: 5 * time.Second,
},
},
MySQLAutoMigrate: true,
Migrations: MigrationConfig{
Enabled: true,
@ -262,11 +307,11 @@ func Default() Config {
},
{
Enabled: false,
SourceType: "aslan_http",
SourceType: "aslan_mongo",
AppCode: "aslan",
AppName: "Aslan",
BaseURL: "https://console.atuchat.com",
OverviewPath: "/console/datav/aslan/region-country/statistics",
SysOrigin: "ATYOU",
MongoDatabase: "test",
StatTimezone: "Asia/Shanghai",
RequestTimeout: 5 * time.Second,
},
@ -411,21 +456,34 @@ func (cfg *Config) Normalize() {
source.AppName = strings.TrimSpace(source.AppName)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MySQLDSN = strings.TrimSpace(source.MySQLDSN)
source.LegacyMySQLDSN = strings.TrimSpace(source.LegacyMySQLDSN)
source.LegacyWalletDatabase = strings.Trim(strings.TrimSpace(source.LegacyWalletDatabase), "/")
source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/")
source.OverviewPath = "/" + strings.TrimLeft(strings.TrimSpace(source.OverviewPath), "/")
if source.OverviewPath == "/" {
source.OverviewPath = ""
}
if source.SourceType == "" {
if source.BaseURL != "" || source.OverviewPath != "" {
if source.MongoURI != "" || source.MongoDatabase != "" {
source.SourceType = "aslan_mongo"
} else if source.BaseURL != "" || source.OverviewPath != "" {
source.SourceType = "aslan_http"
} else {
source.SourceType = "mysql"
}
}
if source.SourceType == "aslan_mongo" && source.MongoDatabase == "" {
source.MongoDatabase = "test"
}
if source.SourceType == "aslan_mongo" && source.LegacyWalletDatabase == "" && source.MongoDatabase != "" {
// Aslan legacy RDS 把用户资料放在 atyou把金币代理钱包流水放在 atyou_wallet默认按主库名派生。
source.LegacyWalletDatabase = source.MongoDatabase + "_wallet"
}
source.StatTimezone = strings.TrimSpace(source.StatTimezone)
if source.StatTimezone == "" {
if source.SourceType == "aslan_http" {
if source.SourceType == "aslan_http" || source.SourceType == "aslan_mongo" {
source.StatTimezone = "Asia/Shanghai"
} else {
source.StatTimezone = "Asia/Riyadh"
@ -435,6 +493,7 @@ func (cfg *Config) Normalize() {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyDashboardExternalSourceEnvOverrides()
cfg.applyFinanceNotificationEnvOverrides()
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
@ -465,6 +524,31 @@ func (cfg *Config) Normalize() {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyMoneyRegionSourceEnvOverrides()
for index := range cfg.FinanceBillSources {
source := &cfg.FinanceBillSources[index]
// legacy 账单源只服务财务充值明细配置层统一大小写和默认集合handler 只按 app_code 命中。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
if source.MongoDatabase == "" {
// 线上 likei Mongo10.2.21.9)的账单和区域目录都在 tarab_all 库,与 money_region_sources 保持一致。
source.MongoDatabase = "tarab_all"
}
source.MongoCollection = strings.TrimSpace(source.MongoCollection)
if source.MongoCollection == "" {
source.MongoCollection = "in_app_purchase_details"
}
source.GooglePackageName = strings.TrimSpace(source.GooglePackageName)
source.GoogleServiceAccountJSON = strings.TrimSpace(source.GoogleServiceAccountJSON)
source.GoogleServiceAccountFile = strings.TrimSpace(source.GoogleServiceAccountFile)
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyFinanceBillSourceEnvOverrides()
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -605,8 +689,30 @@ func (cfg Config) Validate() error {
if strings.TrimSpace(source.OverviewPath) == "" {
return fmt.Errorf("dashboard_external_sources[%d].overview_path is required when aslan_http source is enabled", index)
}
case "aslan_mongo":
if strings.TrimSpace(source.SysOrigin) == "" {
return fmt.Errorf("dashboard_external_sources[%d].sys_origin is required when aslan_mongo source is enabled", index)
}
if strings.TrimSpace(source.MongoURI) == "" {
return fmt.Errorf("dashboard_external_sources[%d].mongo_uri is required when aslan_mongo source is enabled", index)
}
if strings.TrimSpace(source.MongoDatabase) == "" {
return fmt.Errorf("dashboard_external_sources[%d].mongo_database is required when aslan_mongo source is enabled", index)
}
if strings.TrimSpace(source.LegacyMySQLDSN) == "" {
return fmt.Errorf("dashboard_external_sources[%d].legacy_mysql_dsn is required when aslan_mongo source is enabled", index)
}
if containsConfigPlaceholder(source.LegacyMySQLDSN) {
return fmt.Errorf("dashboard_external_sources[%d].legacy_mysql_dsn must be injected by environment, not left as a placeholder", index)
}
if strings.TrimSpace(source.LegacyWalletDatabase) == "" {
return fmt.Errorf("dashboard_external_sources[%d].legacy_wallet_database is required when aslan_mongo source is enabled", index)
}
if !isMySQLIdentifier(source.LegacyWalletDatabase) {
return fmt.Errorf("dashboard_external_sources[%d].legacy_wallet_database must be a mysql identifier", index)
}
default:
return fmt.Errorf("dashboard_external_sources[%d].type must be one of mysql, aslan_http", index)
return fmt.Errorf("dashboard_external_sources[%d].type must be one of mysql, aslan_http, aslan_mongo", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
@ -650,6 +756,30 @@ func (cfg Config) Validate() error {
return fmt.Errorf("money_region_sources[%d].request_timeout must be greater than 0", index)
}
}
for index, source := range cfg.FinanceBillSources {
if !source.Enabled {
continue
}
// legacy 账单源启用但配置不全时必须启动失败,避免财务充值明细静默漏掉 Yumi 账单。
if strings.TrimSpace(source.AppCode) == "" {
return fmt.Errorf("finance_bill_sources[%d].app_code is required when enabled", index)
}
if strings.TrimSpace(source.SysOrigin) == "" {
return fmt.Errorf("finance_bill_sources[%d].sys_origin is required when enabled", index)
}
if strings.TrimSpace(source.MongoURI) == "" {
return fmt.Errorf("finance_bill_sources[%d].mongo_uri is required when enabled", index)
}
if strings.TrimSpace(source.MongoDatabase) == "" {
return fmt.Errorf("finance_bill_sources[%d].mongo_database is required when enabled", index)
}
if strings.TrimSpace(source.MongoCollection) == "" {
return fmt.Errorf("finance_bill_sources[%d].mongo_collection is required when enabled", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("finance_bill_sources[%d].request_timeout must be greater than 0", index)
}
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")
@ -699,6 +829,111 @@ func (cfg *Config) applyFinanceNotificationEnvOverrides() {
}
}
func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_DASHBOARD_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// Mongo URI 含线上密码;仓库 YAML 只放占位,真实值由运行环境注入后覆盖。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_DASHBOARD_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
previousMongoDatabase := source.MongoDatabase
usesDerivedWalletDatabase := source.LegacyWalletDatabase == "" || source.LegacyWalletDatabase == previousMongoDatabase+"_wallet"
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
if source.SourceType == "aslan_mongo" && usesDerivedWalletDatabase && source.MongoDatabase != "" {
source.LegacyWalletDatabase = source.MongoDatabase + "_wallet"
}
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_DASHBOARD_LEGACY_MYSQL_DSN",
"HYAPP_ADMIN_"+appKey+"_LEGACY_MYSQL_DSN",
)); value != "" {
// Aslan Mongo 大屏只把 legacy MySQL 作为补充事实源,真实 likei RDS 凭证由运行环境注入。
source.LegacyMySQLDSN = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_DASHBOARD_LEGACY_WALLET_DATABASE",
"HYAPP_ADMIN_"+appKey+"_LEGACY_WALLET_DATABASE",
)); value != "" {
source.LegacyWalletDatabase = strings.Trim(value, "/")
}
}
}
func (cfg *Config) applyMoneyRegionSourceEnvOverrides() {
for index := range cfg.MoneyRegionSources {
source := &cfg.MoneyRegionSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_MONEY_REGION_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// legacy 区域源和 Aslan 大屏可以共享同一个 Mongo 集群;只在运行环境注入真实密码。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_MONEY_REGION_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
}
}
func (cfg *Config) applyFinanceBillSourceEnvOverrides() {
for index := range cfg.FinanceBillSources {
source := &cfg.FinanceBillSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_FINANCE_BILL_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// legacy 账单源与区域源/大屏共享同一个 likei Mongo 集群;真实密码只由运行环境注入。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_FINANCE_BILL_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_PACKAGE_NAME")); value != "" {
source.GooglePackageName = value
}
// 服务账号 JSON 含私钥;生产优先用环境变量注入,仓库 YAML 只放文件路径或占位。
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_SERVICE_ACCOUNT_JSON")); value != "" {
source.GoogleServiceAccountJSON = value
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_SERVICE_ACCOUNT_FILE")); value != "" {
source.GoogleServiceAccountFile = value
}
}
}
func envAppKey(appCode string) string {
appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" {
return ""
}
return strings.NewReplacer("-", "_", ".", "_").Replace(appCode)
}
func firstEnv(keys ...string) string {
for _, key := range keys {
if value := os.Getenv(key); strings.TrimSpace(value) != "" {
@ -727,6 +962,24 @@ func compactStrings(values []string) []string {
return out
}
func containsConfigPlaceholder(value string) bool {
upper := strings.ToUpper(strings.TrimSpace(value))
return strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "<")
}
func isMySQLIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r == '_' || r == '$' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
return false
}
return true
}
func validEnvironment(value string) bool {
switch value {
case "local", "dev", "staging", "prod":

View File

@ -2,6 +2,7 @@ package config
import (
"reflect"
"strings"
"testing"
"time"
)
@ -71,3 +72,163 @@ func TestFinanceDingTalkEnvOverride(t *testing.T) {
t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles)
}
}
func TestAslanMongoEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_LEGACY_MYSQL_DSN", "likei:secret@tcp(example:3306)/likei?parseTime=true&loc=Asia%2FShanghai")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_LEGACY_WALLET_DATABASE", "test_wallet")
t.Setenv("HYAPP_ADMIN_ASLAN_MONEY_REGION_MONGO_DATABASE", "tarab_region")
cfg := Default()
cfg.Normalize()
var dashboardSource DashboardExternalSourceConfig
for _, source := range cfg.DashboardExternalSources {
if source.AppCode == "aslan" {
dashboardSource = source
break
}
}
if dashboardSource.MongoURI != "mongodb://mongouser:secret@example:27017/test?authSource=admin" {
t.Fatalf("dashboard mongo uri mismatch: %q", dashboardSource.MongoURI)
}
if dashboardSource.MongoDatabase != "test_dashboard" {
t.Fatalf("dashboard mongo database mismatch: %q", dashboardSource.MongoDatabase)
}
if dashboardSource.LegacyMySQLDSN != "likei:secret@tcp(example:3306)/likei?parseTime=true&loc=Asia%2FShanghai" {
t.Fatalf("dashboard legacy mysql dsn mismatch: %q", dashboardSource.LegacyMySQLDSN)
}
if dashboardSource.LegacyWalletDatabase != "test_wallet" {
t.Fatalf("dashboard legacy wallet database mismatch: %q", dashboardSource.LegacyWalletDatabase)
}
var regionSource MoneyRegionSourceConfig
for _, source := range cfg.MoneyRegionSources {
if source.AppCode == "aslan" {
regionSource = source
break
}
}
if regionSource.MongoURI != "mongodb://mongouser:secret@example:27017/test?authSource=admin" {
t.Fatalf("region mongo uri mismatch: %q", regionSource.MongoURI)
}
if regionSource.MongoDatabase != "tarab_region" {
t.Fatalf("region mongo database mismatch: %q", regionSource.MongoDatabase)
}
}
func TestAslanMongoLegacyMySQLFallbackEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_LEGACY_MYSQL_DSN", "likei:secret@tcp(example:3306)/likei?parseTime=true&loc=Asia%2FShanghai")
t.Setenv("HYAPP_ADMIN_ASLAN_LEGACY_WALLET_DATABASE", "atyou_wallet")
cfg := Default()
cfg.Normalize()
for _, source := range cfg.DashboardExternalSources {
if source.AppCode != "aslan" {
continue
}
if source.LegacyMySQLDSN != "likei:secret@tcp(example:3306)/likei?parseTime=true&loc=Asia%2FShanghai" {
t.Fatalf("dashboard legacy mysql fallback dsn mismatch: %q", source.LegacyMySQLDSN)
}
if source.LegacyWalletDatabase != "atyou_wallet" {
t.Fatalf("dashboard legacy wallet database fallback mismatch: %q", source.LegacyWalletDatabase)
}
return
}
t.Fatal("aslan dashboard source not found")
}
func TestTencentExampleRequiresRuntimeLegacyMySQLDSN(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_LEGACY_MYSQL_DSN", "")
t.Setenv("HYAPP_ADMIN_ASLAN_LEGACY_MYSQL_DSN", "")
_, err := Load("../../configs/config.tencent.example.yaml")
if err == nil || !strings.Contains(err.Error(), "legacy_mysql_dsn must be injected by environment") {
t.Fatalf("Load() error = %v, want legacy mysql placeholder rejection", err)
}
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_LEGACY_MYSQL_DSN", "aslan:runtime-secret@tcp(example:3306)/atyou?parseTime=true&loc=Asia%2FShanghai")
cfg, err := Load("../../configs/config.tencent.example.yaml")
if err != nil {
t.Fatalf("Load() with runtime legacy dsn error = %v", err)
}
for _, source := range cfg.DashboardExternalSources {
if source.AppCode != "aslan" {
continue
}
if source.LegacyMySQLDSN != "aslan:runtime-secret@tcp(example:3306)/atyou?parseTime=true&loc=Asia%2FShanghai" {
t.Fatalf("dashboard legacy mysql dsn mismatch: %q", source.LegacyMySQLDSN)
}
if source.LegacyWalletDatabase != "atyou_wallet" {
t.Fatalf("dashboard legacy wallet database mismatch: %q", source.LegacyWalletDatabase)
}
return
}
t.Fatal("aslan dashboard source not found")
}
func TestAslanMongoLegacyWalletDatabaseFollowsMongoDatabaseEnv(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "atyou")
cfg := Default()
cfg.Normalize()
for _, source := range cfg.DashboardExternalSources {
if source.AppCode != "aslan" {
continue
}
if source.MongoDatabase != "atyou" {
t.Fatalf("dashboard mongo database mismatch: %q", source.MongoDatabase)
}
if source.LegacyWalletDatabase != "atyou_wallet" {
t.Fatalf("dashboard legacy wallet database should follow mongo database, got %q", source.LegacyWalletDatabase)
}
return
}
t.Fatal("aslan dashboard source not found")
}
func TestAslanMongoRequiresLegacyMySQLDSNWhenEnabled(t *testing.T) {
cfg := Default()
for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index]
if source.AppCode != "aslan" {
continue
}
source.Enabled = true
source.MongoURI = "mongodb://example:27017/test"
source.MongoDatabase = "atyou"
source.LegacyMySQLDSN = ""
break
}
cfg.Normalize()
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "legacy_mysql_dsn is required") {
t.Fatalf("Validate() error = %v, want legacy mysql requirement", err)
}
}
func TestAslanMongoRejectsPlaceholderLegacyMySQLDSN(t *testing.T) {
cfg := Default()
for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index]
if source.AppCode != "aslan" {
continue
}
source.Enabled = true
source.MongoURI = "mongodb://example:27017/test"
source.MongoDatabase = "atyou"
source.LegacyMySQLDSN = "aslan:REPLACE_ME@tcp(example:3306)/atyou?parseTime=true&loc=Asia%2FShanghai"
source.LegacyWalletDatabase = "atyou_wallet"
break
}
cfg.Normalize()
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "not left as a placeholder") {
t.Fatalf("Validate() error = %v, want placeholder rejection", err)
}
}

View File

@ -0,0 +1,258 @@
// Package googleorders 提供 Play Developer API Orders 资源的最小只读客户端。
// hyapp 自有 AppLalu 等)的实付同步走 wallet-service这里只服务 legacy 账单源Yumi/Aslan
// 它们的支付发生在外部平台admin 需要用各自 Play Console 的服务账号直接查订单实付。
package googleorders
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"
defaultAPIBaseURL = "https://androidpublisher.googleapis.com"
defaultTokenURL = "https://oauth2.googleapis.com/token"
)
// Order 是 orders.get 的实付快照;金额统一为实付币种微单位。
type Order struct {
OrderID string
State string
BuyerCountry string
CurrencyCode string
TotalAmountMicro int64
TaxAmountMicro int64
// NetAmountMicro 是 Google 扣除服务费和代缴税后的开发者净收入;接口未返回时为 0。
NetAmountMicro int64
}
// Config 描述一个 Play Console 服务账号ServiceAccountJSON 优先于 ServiceAccountFile。
type Config struct {
PackageName string
ServiceAccountJSON string
ServiceAccountFile string
HTTPTimeout time.Duration
}
type serviceAccount struct {
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
TokenURI string `json:"token_uri"`
}
// Client 按服务账号缓存 OAuth token一个 legacy App 一个实例。
type Client struct {
httpClient *http.Client
packageName string
tokenURL string
email string
privateKey *rsa.PrivateKey
mu sync.Mutex
accessToken string
expiresAt time.Time
}
// New 创建 legacy Google Orders 客户端;凭证不完整时返回错误,由调用方决定是否降级为“未配置”。
func New(cfg Config) (*Client, error) {
body := strings.TrimSpace(cfg.ServiceAccountJSON)
if body == "" && strings.TrimSpace(cfg.ServiceAccountFile) != "" {
fileBody, err := os.ReadFile(strings.TrimSpace(cfg.ServiceAccountFile))
if err != nil {
return nil, err
}
body = string(fileBody)
}
if body == "" {
return nil, errors.New("google service account is not configured")
}
var account serviceAccount
if err := json.Unmarshal([]byte(body), &account); err != nil {
return nil, fmt.Errorf("parse google service account: %w", err)
}
account.ClientEmail = strings.TrimSpace(account.ClientEmail)
account.PrivateKey = strings.TrimSpace(account.PrivateKey)
if account.ClientEmail == "" || account.PrivateKey == "" {
return nil, errors.New("google service account is incomplete")
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey))
if err != nil {
return nil, fmt.Errorf("parse google private key: %w", err)
}
tokenURL := strings.TrimSpace(account.TokenURI)
if tokenURL == "" {
tokenURL = defaultTokenURL
}
timeout := cfg.HTTPTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
packageName := strings.TrimSpace(cfg.PackageName)
if packageName == "" {
return nil, errors.New("google package name is required")
}
return &Client{
httpClient: &http.Client{Timeout: timeout},
packageName: packageName,
tokenURL: tokenURL,
email: account.ClientEmail,
privateKey: privateKey,
}, nil
}
// GetOrder 查询单笔订单实付明细;服务账号需要该 Play Console 的“查看财务数据”权限。
func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) {
if c == nil {
return Order{}, errors.New("google orders client is not configured")
}
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return Order{}, errors.New("google order id is required")
}
token, err := c.token(ctx)
if err != nil {
return Order{}, err
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders/%s",
defaultAPIBaseURL, url.PathEscape(c.packageName), url.PathEscape(orderID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Order{}, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return Order{}, errors.New("google play api request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Order{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
switch resp.StatusCode {
case http.StatusNotFound, http.StatusBadRequest:
return Order{}, errors.New("google order is not found")
case http.StatusUnauthorized, http.StatusForbidden:
return Order{}, errors.New("google play api is not authorized (需要查看财务数据权限)")
default:
return Order{}, fmt.Errorf("google play api returned %d", resp.StatusCode)
}
}
var parsed orderResource
if err := json.Unmarshal(body, &parsed); err != nil {
return Order{}, err
}
order := Order{
OrderID: parsed.OrderID,
State: parsed.State,
BuyerCountry: parsed.BuyerAddress.BuyerCountry,
CurrencyCode: parsed.Total.CurrencyCode,
TotalAmountMicro: parsed.Total.micro(),
TaxAmountMicro: parsed.Tax.micro(),
NetAmountMicro: parsed.DeveloperRevenueInBuyerCurrency.micro(),
}
if order.CurrencyCode == "" {
order.CurrencyCode = parsed.Tax.CurrencyCode
}
return order, nil
}
func (c *Client) token(ctx context.Context) (string, error) {
c.mu.Lock()
if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
token := c.accessToken
c.mu.Unlock()
return token, nil
}
c.mu.Unlock()
now := time.Now()
claims := jwt.MapClaims{
"iss": c.email,
"scope": androidPublisherScope,
"aud": c.tokenURL,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
assertion, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(c.privateKey)
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", errors.New("google oauth token request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", errors.New("google oauth token request failed")
}
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", err
}
if tokenResp.AccessToken == "" {
return "", errors.New("google oauth token response is incomplete")
}
expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second
if expiresIn <= 0 {
expiresIn = time.Hour
}
c.mu.Lock()
c.accessToken = tokenResp.AccessToken
c.expiresAt = now.Add(expiresIn)
c.mu.Unlock()
return tokenResp.AccessToken, nil
}
type orderResource struct {
OrderID string `json:"orderId"`
State string `json:"state"`
BuyerAddress buyerAddress `json:"buyerAddress"`
Total moneyAmount `json:"total"`
Tax moneyAmount `json:"tax"`
DeveloperRevenueInBuyerCurrency moneyAmount `json:"developerRevenueInBuyerCurrency"`
}
type buyerAddress struct {
BuyerCountry string `json:"buyerCountry"`
}
// moneyAmount 是 google.type.Money 的 JSON 形态units 是字符串形式的整数金额nanos 是 10^-9 小数部分。
type moneyAmount struct {
CurrencyCode string `json:"currencyCode"`
Units json.Number `json:"units"`
Nanos int64 `json:"nanos"`
}
func (m moneyAmount) micro() int64 {
units, _ := m.Units.Int64()
return units*1_000_000 + m.Nanos/1_000
}

View File

@ -15,6 +15,8 @@ type Client interface {
CreateResource(ctx context.Context, req *walletv1.CreateResourceRequest) (*walletv1.ResourceResponse, error)
UpdateResource(ctx context.Context, req *walletv1.UpdateResourceRequest) (*walletv1.ResourceResponse, error)
SetResourceStatus(ctx context.Context, req *walletv1.SetResourceStatusRequest) (*walletv1.ResourceResponse, error)
DeleteResource(ctx context.Context, req *walletv1.DeleteResourceRequest) (*walletv1.ResourceResponse, error)
BatchDeleteResources(ctx context.Context, req *walletv1.BatchDeleteResourcesRequest) (*walletv1.BatchDeleteResourcesResponse, error)
ListResourceGroups(ctx context.Context, req *walletv1.ListResourceGroupsRequest) (*walletv1.ListResourceGroupsResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
CreateResourceGroup(ctx context.Context, req *walletv1.CreateResourceGroupRequest) (*walletv1.ResourceGroupResponse, error)
@ -23,6 +25,7 @@ type Client interface {
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
CreateGiftConfig(ctx context.Context, req *walletv1.CreateGiftConfigRequest) (*walletv1.GiftConfigResponse, error)
BatchCreateGiftConfigs(ctx context.Context, req *walletv1.BatchCreateGiftConfigsRequest) (*walletv1.BatchCreateGiftConfigsResponse, error)
UpdateGiftConfig(ctx context.Context, req *walletv1.UpdateGiftConfigRequest) (*walletv1.GiftConfigResponse, error)
SetGiftConfigStatus(ctx context.Context, req *walletv1.SetGiftConfigStatusRequest) (*walletv1.GiftConfigResponse, error)
DeleteGiftConfig(ctx context.Context, req *walletv1.DeleteGiftConfigRequest) (*walletv1.GiftConfigResponse, error)
@ -41,6 +44,9 @@ type Client interface {
SettleSalaryWithdrawal(ctx context.Context, req *walletv1.SettleSalaryWithdrawalRequest) (*walletv1.SettleSalaryWithdrawalResponse, error)
ReleaseSalaryWithdrawal(ctx context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error)
GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
@ -90,6 +96,14 @@ func (c *GRPCClient) SetResourceStatus(ctx context.Context, req *walletv1.SetRes
return c.client.SetResourceStatus(ctx, req)
}
func (c *GRPCClient) DeleteResource(ctx context.Context, req *walletv1.DeleteResourceRequest) (*walletv1.ResourceResponse, error) {
return c.client.DeleteResource(ctx, req)
}
func (c *GRPCClient) BatchDeleteResources(ctx context.Context, req *walletv1.BatchDeleteResourcesRequest) (*walletv1.BatchDeleteResourcesResponse, error) {
return c.client.BatchDeleteResources(ctx, req)
}
func (c *GRPCClient) ListResourceGroups(ctx context.Context, req *walletv1.ListResourceGroupsRequest) (*walletv1.ListResourceGroupsResponse, error) {
return c.client.ListResourceGroups(ctx, req)
}
@ -122,6 +136,10 @@ func (c *GRPCClient) CreateGiftConfig(ctx context.Context, req *walletv1.CreateG
return c.client.CreateGiftConfig(ctx, req)
}
func (c *GRPCClient) BatchCreateGiftConfigs(ctx context.Context, req *walletv1.BatchCreateGiftConfigsRequest) (*walletv1.BatchCreateGiftConfigsResponse, error) {
return c.client.BatchCreateGiftConfigs(ctx, req)
}
func (c *GRPCClient) UpdateGiftConfig(ctx context.Context, req *walletv1.UpdateGiftConfigRequest) (*walletv1.GiftConfigResponse, error) {
return c.client.UpdateGiftConfig(ctx, req)
}
@ -194,6 +212,18 @@ func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRe
return c.client.ListRechargeBills(ctx, req)
}
func (c *GRPCClient) GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error) {
return c.client.GetRechargeBillSummary(ctx, req)
}
func (c *GRPCClient) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
return c.client.GetRechargeBillOverview(ctx, req)
}
func (c *GRPCClient) RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error) {
return c.client.RefreshGooglePaymentPrices(ctx, req)
}
func (c *GRPCClient) ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
return c.client.ListThirdPartyPaymentChannels(ctx, req)
}

View File

@ -417,6 +417,28 @@ func (TemporaryPaymentLinkOwner) TableName() string {
return "admin_temporary_payment_link_owners"
}
// LegacyGooglePaidDetail 缓存 legacy AppYumi/Aslan谷歌账单经 Play Orders API 同步的实付明细;
// legacy 平台的 Mongo 对 admin 只读,实付事实只能落在 admin 自己的库里,按账单交易号回填展示。
type LegacyGooglePaidDetail struct {
AppCode string `gorm:"size:32;primaryKey;index:idx_legacy_google_paid_bill_time,priority:1" json:"appCode"`
TransactionID string `gorm:"size:96;primaryKey;column:transaction_id" json:"transactionId"`
ProviderOrderID string `gorm:"size:128;not null;default:''" json:"providerOrderId"`
PaidCurrencyCode string `gorm:"size:8;not null;default:''" json:"paidCurrencyCode"`
PaidAmountMicro int64 `gorm:"not null;default:0" json:"paidAmountMicro"`
PaidTaxMicro int64 `gorm:"not null;default:0" json:"paidTaxMicro"`
PaidNetMicro int64 `gorm:"not null;default:0" json:"paidNetMicro"`
// BillUSDMinor/BillCreatedAtMS 是同步时从 legacy 账单带过来的快照,让概览统计不用再回查 Mongo。
BillUSDMinor int64 `gorm:"column:bill_usd_minor;not null;default:0" json:"billUsdMinor"`
BillCreatedAtMS int64 `gorm:"column:bill_created_at_ms;not null;default:0;index:idx_legacy_google_paid_bill_time,priority:2" json:"billCreatedAtMs"`
SyncedAtMS int64 `gorm:"column:synced_at_ms;not null" json:"syncedAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (LegacyGooglePaidDetail) TableName() string {
return "admin_legacy_google_paid_details"
}
type FinanceApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`

View File

@ -72,6 +72,7 @@ type coinAdjustmentDTO struct {
AvailableDelta int64 `json:"availableDelta"`
AvailableAfter int64 `json:"availableAfter"`
Reason string `json:"reason"`
Remark string `json:"remark"`
OperatorUserID string `json:"operatorUserId"`
Operator coinAdjustmentOperatorDTO `json:"operator"`
CreatedAtMS int64 `json:"createdAtMs"`
@ -84,4 +85,5 @@ type coinAdjustmentCreateDTO struct {
Amount int64 `json:"amount"`
AvailableDelta int64 `json:"availableDelta"`
Reason string `json:"reason"`
Remark string `json:"remark"`
}

View File

@ -125,7 +125,7 @@ func (h *Handler) CreateCoinAdjustment(c *gin.Context) {
return
}
shared.OperationLogWithResourceID(c, h.audit, "coin-adjustment-create", "wallet_entries", result.TransactionID, "success",
fmt.Sprintf("target_user_id=%v amount=%d transaction_id=%s reason=%q", req.TargetUserID, req.Amount, result.TransactionID, strings.TrimSpace(req.Reason)))
fmt.Sprintf("target_user_id=%v amount=%d transaction_id=%s reason=%q remark=%q", req.TargetUserID, req.Amount, result.TransactionID, strings.TrimSpace(req.Reason), strings.TrimSpace(req.Remark)))
response.Created(c, result)
}

View File

@ -28,4 +28,5 @@ type coinAdjustmentRequest struct {
TargetUserID any `json:"targetUserId"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
Remark string `json:"remark"`
}

View File

@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/modules/shared"
@ -419,6 +420,7 @@ func (s *Service) ListCoinAdjustments(ctx context.Context, appCode string, query
item.Direction = directionForDelta(item.AvailableDelta)
item.Amount = item.AvailableDelta
item.Reason = metadataString(metadata, "reason")
item.Remark = metadataString(metadata, "remark")
item.OperatorUserID = formatOptionalID(operatorID)
items = append(items, item)
itemUserIDs = append(itemUserIDs, userID)
@ -509,9 +511,16 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto
if reason == "" {
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is required")
}
if len(reason) > 512 {
if utf8.RuneCountInString(reason) > 512 {
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is too long")
}
remark := strings.TrimSpace(req.Remark)
if remark == "" {
return coinAdjustmentCreateDTO{}, fmt.Errorf("remark is required")
}
if utf8.RuneCountInString(remark) > 512 {
return coinAdjustmentCreateDTO{}, fmt.Errorf("remark is too long")
}
user, err := s.LookupCoinAdjustmentTarget(ctx, appCode, strconv.FormatInt(targetUserID, 10))
if err != nil {
return coinAdjustmentCreateDTO{}, err
@ -533,6 +542,7 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto
Reason: reason,
EvidenceRef: evidenceRef,
AppCode: appCode,
Remark: remark,
})
if err != nil {
return coinAdjustmentCreateDTO{}, err
@ -548,6 +558,7 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto
Amount: req.Amount,
AvailableDelta: req.Amount,
Reason: reason,
Remark: remark,
}, nil
}

View File

@ -1,6 +1,118 @@
package coinledger
import "testing"
import (
"context"
"regexp"
"strings"
"testing"
"hyapp-admin-server/internal/integration/walletclient"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/DATA-DOG/go-sqlmock"
)
type fakeCoinAdjustmentWallet struct {
walletclient.Client
req *walletv1.AdminCreditAssetRequest
}
func (f *fakeCoinAdjustmentWallet) AdminCreditAsset(_ context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
f.req = req
return &walletv1.AdminCreditAssetResponse{
TransactionId: "tx-admin-coin-adjustment",
Balance: &walletv1.AssetBalance{AvailableAmount: 880},
}, nil
}
func TestCreateCoinAdjustmentRequiresRemark(t *testing.T) {
service := NewService(nil, nil, nil, &fakeCoinAdjustmentWallet{})
_, err := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
TargetUserID: "20001",
Amount: 100,
Reason: "manual adjustment",
Remark: " ",
})
if err == nil || !strings.Contains(err.Error(), "remark is required") {
t.Fatalf("expected required remark error, got %v", err)
}
}
func TestCreateCoinAdjustmentRejectsLongRemark(t *testing.T) {
service := NewService(nil, nil, nil, &fakeCoinAdjustmentWallet{})
_, err := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
TargetUserID: "20001",
Amount: 100,
Reason: "manual adjustment",
Remark: strings.Repeat("r", 513),
})
if err == nil || !strings.Contains(err.Error(), "remark is too long") {
t.Fatalf("expected long remark error, got %v", err)
}
}
func TestCreateCoinAdjustmentRemarkLengthUsesCharacters(t *testing.T) {
service := NewService(nil, nil, nil, &fakeCoinAdjustmentWallet{})
_, validErr := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
TargetUserID: "20001",
Amount: 100,
Reason: "manual adjustment",
Remark: strings.Repeat("补", 512),
})
if validErr == nil || strings.Contains(validErr.Error(), "remark is too long") {
t.Fatalf("512 multibyte characters should pass length validation, got %v", validErr)
}
_, longErr := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
TargetUserID: "20001",
Amount: 100,
Reason: "manual adjustment",
Remark: strings.Repeat("补", 513),
})
if longErr == nil || !strings.Contains(longErr.Error(), "remark is too long") {
t.Fatalf("expected long multibyte remark error, got %v", longErr)
}
}
func TestCreateCoinAdjustmentTrimsAndSendsRemark(t *testing.T) {
userDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("new sqlmock failed: %v", err)
}
defer userDB.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '')")).
WithArgs("lalu", "20001", "20001", "20001", sqlmock.AnyArg(), "20001", "%20001%", "20001", "20001", "20001", sqlmock.AnyArg(), "20001").
WillReturnRows(sqlmock.NewRows([]string{"user_id", "current_display_user_id", "username", "avatar"}).AddRow(int64(20001), "6688", "target", "avatar.png"))
wallet := &fakeCoinAdjustmentWallet{}
service := NewService(userDB, nil, nil, wallet)
result, err := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
CommandID: " cmd-admin-coin ",
TargetUserID: "20001",
Amount: 120,
Reason: " manual adjustment ",
Remark: " ticket approved by finance ",
})
if err != nil {
t.Fatalf("CreateCoinAdjustment failed: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
if wallet.req == nil {
t.Fatalf("wallet request was not sent")
}
if wallet.req.GetRemark() != "ticket approved by finance" || wallet.req.GetReason() != "manual adjustment" {
t.Fatalf("wallet audit fields mismatch: reason=%q remark=%q", wallet.req.GetReason(), wallet.req.GetRemark())
}
if wallet.req.GetCommandId() != "cmd-admin-coin" || wallet.req.GetTargetUserId() != 20001 || wallet.req.GetAmount() != 120 || wallet.req.GetOperatorUserId() != 90001 {
t.Fatalf("wallet request mismatch: %+v", wallet.req)
}
if result.Remark != "ticket approved by finance" || result.Reason != "manual adjustment" || result.BalanceAfter != 880 {
t.Fatalf("create dto mismatch: %+v", result)
}
}
func TestNormalizeListQueryCapsPageSize(t *testing.T) {
query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, UserKeyword: " 10001 "})

View File

@ -0,0 +1,183 @@
package dashboard
import (
"context"
"fmt"
"strings"
"time"
)
// CoinSellerRechargeBillQuery 是 finance legacy 列表需要的币商进货明细筛选。
// 它只服务只读展示;金额口径必须与 StatisticsOverview 的 coin_seller_recharge_usd_minor 保持一致。
type CoinSellerRechargeBillQuery struct {
Keyword string
RegionID int64
StatTZ string
StartMS int64
EndMS int64
Page int
PageSize int
}
type CoinSellerRechargeBill struct {
TransactionID string
Source string
UserID int64
CountryCode string
USDMinorAmount int64
CreatedAtMS int64
}
type CoinSellerRechargeBillSource interface {
ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error)
}
func (s *AslanMongoDashboardSource) ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error) {
if s == nil || s.legacyDB == nil {
return nil, 0, fmt.Errorf("aslan legacy mysql is not configured")
}
countryFilter, err := s.countryFilter(ctx, StatisticsQuery{RegionID: query.RegionID})
if err != nil {
return nil, 0, err
}
if countryFilter.Applied && len(countryFilter.Codes) == 0 {
return []CoinSellerRechargeBill{}, 0, nil
}
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize < 1 {
pageSize = 10
}
unionSQL, args := s.coinSellerRechargeBillUnionSQL(query, countryFilter)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
var total int64
if err := s.legacyDB.QueryRowContext(queryCtx, `SELECT COUNT(*) FROM (`+unionSQL+`) bills`, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count aslan coin seller recharge bills: %w", err)
}
if total == 0 {
return []CoinSellerRechargeBill{}, 0, nil
}
rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT transaction_id, source, user_id, country_code, amount, created_at
FROM (`+unionSQL+`) bills
ORDER BY created_at DESC, transaction_id DESC
LIMIT ? OFFSET ?`, append(args, pageSize, (page-1)*pageSize)...)
if err != nil {
return nil, 0, fmt.Errorf("query aslan coin seller recharge bills: %w", err)
}
defer rows.Close()
items := make([]CoinSellerRechargeBill, 0, pageSize)
for rows.Next() {
var item CoinSellerRechargeBill
var amountText string
var createdAt time.Time
if err := rows.Scan(&item.TransactionID, &item.Source, &item.UserID, &item.CountryCode, &amountText, &createdAt); err != nil {
return nil, 0, fmt.Errorf("scan aslan coin seller recharge bill: %w", err)
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return nil, 0, fmt.Errorf("parse aslan coin seller recharge bill %s amount: %w", item.TransactionID, err)
}
item.CountryCode = aslanMongoCountryCode(item.CountryCode)
item.USDMinorAmount = amount
item.CreatedAtMS = createdAt.UnixMilli()
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate aslan coin seller recharge bills: %w", err)
}
return items, total, nil
}
func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSellerRechargeBillQuery, countryFilter externalDashboardCountryFilter) (string, []any) {
goldWhere := []string{
"ugcr.sys_origin = ?",
"ugcr.gold_coin_source = 2",
"ugcr.recharge_amount > 0",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
goldArgs := []any{s.sysOrigin}
freightWhere := []string{
"fbrw.sys_origin = ?",
"fbrw.origin = 'PURCHASE'",
"fbrw.type = 0",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
freightArgs := []any{s.sysOrigin}
if query.StartMS > 0 {
start := time.UnixMilli(query.StartMS)
goldWhere = append(goldWhere, "ugcr.create_time >= ?")
goldArgs = append(goldArgs, start)
freightWhere = append(freightWhere, "fbrw.create_time >= ?")
freightArgs = append(freightArgs, start)
}
if query.EndMS > 0 {
end := time.UnixMilli(query.EndMS)
goldWhere = append(goldWhere, "ugcr.create_time < ?")
goldArgs = append(goldArgs, end)
freightWhere = append(freightWhere, "fbrw.create_time < ?")
freightArgs = append(freightArgs, end)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
goldWhere = append(goldWhere, "(CAST(ugcr.id AS CHAR) = ? OR CONCAT('aslan-gold-coin-', ugcr.id) = ? OR CAST(ugcr.user_id AS CHAR) = ?)")
goldArgs = append(goldArgs, keyword, keyword, keyword)
freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-purchase-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)")
freightArgs = append(freightArgs, keyword, keyword, keyword)
}
if countryFilter.Applied {
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
normalized := aslanMongoCountryCode(code)
if normalized == "" {
continue
}
placeholders = append(placeholders, "?")
goldArgs = append(goldArgs, normalized)
freightArgs = append(freightArgs, normalized)
}
if len(placeholders) == 0 {
placeholders = append(placeholders, "?")
goldArgs = append(goldArgs, "__NO_COUNTRY__")
freightArgs = append(freightArgs, "__NO_COUNTRY__")
}
condition := "UPPER(ubi.country_code) IN (" + strings.Join(placeholders, ",") + ")"
goldWhere = append(goldWhere, condition)
freightWhere = append(freightWhere, condition)
}
walletDatabase := strings.TrimSpace(s.legacyWalletDatabase)
if walletDatabase == "" {
walletDatabase = "atyou_wallet"
}
unionSQL := `
SELECT CONCAT('aslan-gold-coin-', ugcr.id) AS transaction_id,
'gold_coin_recharge' AS source,
ugcr.user_id,
UPPER(ubi.country_code) AS country_code,
CAST(ugcr.recharge_amount AS CHAR) AS amount,
ugcr.create_time AS created_at
FROM user_gold_coin_recharge ugcr
INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE ` + strings.Join(goldWhere, " AND ") + `
UNION ALL
SELECT CONCAT('aslan-freight-purchase-', fbrw.id) AS transaction_id,
'freight_purchase' AS source,
fbrw.user_id,
UPPER(ubi.country_code) AS country_code,
CAST(CASE WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END AS CHAR) AS amount,
fbrw.create_time AS created_at
FROM ` + quoteMySQLIdentifier(walletDatabase) + `.user_freight_balance_running_water fbrw
INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id
WHERE ` + strings.Join(freightWhere, " AND ")
return unionSQL, append(goldArgs, freightArgs...)
}

View File

@ -63,6 +63,7 @@ type aslanDashboardMetric struct {
RechargeUSDMinor int64
ActiveUsers int64
NewUsers int64
PaidUsers int64
CoinTotal int64
SalaryUSDMinor int64
}
@ -407,6 +408,7 @@ func aslanCombinedMetric(flow aslanDashboardMetric, snapshot aslanDashboardMetri
metric := externalDashboardMetric{
NewUsers: flow.NewUsers,
ActiveUsers: flow.ActiveUsers,
PaidUsers: flow.PaidUsers,
UserRechargeUSDMinor: flow.RechargeUSDMinor,
RechargeUSDMinor: flow.RechargeUSDMinor,
CoinTotal: int64Ptr(snapshot.CoinTotal),
@ -426,6 +428,7 @@ func (m *aslanDashboardMetric) addFlow(item aslanDashboardMetric) {
m.RechargeUSDMinor += item.RechargeUSDMinor
m.ActiveUsers += item.ActiveUsers
m.NewUsers += item.NewUsers
m.PaidUsers += item.PaidUsers
}
func (m *aslanDashboardMetric) addSnapshot(item aslanDashboardMetric) {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,375 @@
package dashboard
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"go.mongodb.org/mongo-driver/v2/bson"
)
// 复合 $group 的 _id 子文档经 driver 解码进 bson.M 后,嵌套值默认是 bson.D 而不是 bson.M
// 这个测试锁死我们对两种形态的兼容,防止聚合行被静默丢弃(表现为"成功但全零")。
func TestAslanMongoDayCountryHandlesDecodedShapes(t *testing.T) {
raw, err := bson.Marshal(bson.M{"_id": bson.M{"day": "2026-07-02", "country": "sa"}, "amount": int64(5)})
if err != nil {
t.Fatalf("marshal fixture: %v", err)
}
row := bson.M{}
if err := bson.Unmarshal(raw, &row); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if _, isM := row["_id"].(bson.M); isM {
t.Log("driver decoded nested document as bson.M")
} else if _, isD := row["_id"].(bson.D); isD {
t.Log("driver decoded nested document as bson.D (must be supported)")
} else {
t.Fatalf("unexpected nested _id type %T", row["_id"])
}
day, country := aslanMongoDayCountry(row["_id"])
if day != "2026-07-02" || country != "SA" {
t.Fatalf("expected day/country from decoded _id, got %q/%q", day, country)
}
// 直接构造 bson.D 与 bson.M 两种形态都必须可读。
for _, value := range []any{
bson.D{{Key: "day", Value: "2026-07-01"}, {Key: "country", Value: "eg"}},
bson.M{"day": "2026-07-01", "country": "eg"},
} {
day, country := aslanMongoDayCountry(value)
if day != "2026-07-01" || country != "EG" {
t.Fatalf("expected 2026-07-01/EG for %T, got %q/%q", value, day, country)
}
}
}
func TestAslanMongoDocumentConversion(t *testing.T) {
if doc := aslanMongoDocument(bson.D{{Key: "day", Value: "2026-07-02"}, {Key: "type", Value: int32(1)}}); doc == nil || doc["day"] != "2026-07-02" {
t.Fatalf("bson.D conversion failed: %v", doc)
}
if doc := aslanMongoDocument(bson.M{"user": int64(7)}); doc == nil || doc["user"] != int64(7) {
t.Fatalf("bson.M passthrough failed: %v", doc)
}
if doc := aslanMongoDocument(nil); doc != nil {
t.Fatalf("nil should convert to nil, got %v", doc)
}
if doc := aslanMongoDocument("scalar"); doc != nil {
t.Fatalf("scalar should convert to nil, got %v", doc)
}
}
func TestAslanMongoLegacyCoinSellerRechargeFeedsRechargeChannel(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := &AslanMongoDashboardSource{
legacyDB: db,
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 0, 1)
grid := newLegacyMongoGrid(start, end)
cohorts := map[string]map[string][]int64{
"2026-07-05": {
"SA": {101},
},
}
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
perUser := map[string]map[int64]int64{}
mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2[\\s\\S]*ugcr\\.create_time >= \\?[\\s\\S]*ugcr\\.create_time < \\?").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
AddRow("2026-07-05", "sa", int64(101), "42.00").
AddRow("2026-07-05", "AE", int64(202), "10.25"))
if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err)
}
applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)
saMetric := grid.metrics["2026-07-05"]["SA"]
if saMetric == nil {
t.Fatalf("missing SA metric: %#v", grid.metrics)
}
saRow := saMetric.toExternal(nil).toMap()
assertInt64(t, saRow["coin_seller_recharge_usd_minor"], 4200)
assertInt64(t, saRow["recharge_usd_minor"], 4200)
assertInt64(t, saRow["total_recharge_usd_minor"], 4200)
assertInt64(t, saRow["user_recharge_usd_minor"], 0)
assertInt64(t, saRow["mifapay_recharge_usd_minor"], 0)
assertInt64(t, saRow["paid_users"], 1)
assertInt64(t, saRow["new_user_recharge_usd_minor"], 4200)
aeMetric := grid.metrics["2026-07-05"]["AE"]
if aeMetric == nil {
t.Fatalf("missing AE metric: %#v", grid.metrics)
}
aeRow := aeMetric.toExternal(nil).toMap()
assertInt64(t, aeRow["coin_seller_recharge_usd_minor"], 1025)
assertInt64(t, aeRow["recharge_usd_minor"], 1025)
assertInt64(t, aeRow["new_user_recharge_usd_minor"], 0)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoLegacyOtherRechargeFeedsThirdPartyChannel(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := &AslanMongoDashboardSource{
legacyDB: db,
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 0, 1)
grid := newLegacyMongoGrid(start, end)
cohorts := map[string]map[string][]int64{
"2026-07-05": {
"SA": {101, 202, 303},
},
}
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
perUser := map[string]map[int64]int64{}
mock.ExpectQuery("FROM order_other_recharge oor[\\s\\S]*INNER JOIN user_base_info ubi ON ubi\\.id = oor\\.user_id[\\s\\S]*oor\\.payment_date_number >= \\?[\\s\\S]*oor\\.payment_date_number < \\?").
WithArgs("ATYOU", "20260705", "20260706").
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
AddRow("2026-07-05", "SA", int64(101), "12.34").
AddRow("2026-07-05", "SA", int64(202), "0.00").
AddRow("2026-07-05", "SA", int64(303), "-3.00"))
if err := source.loadLegacyOtherRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
t.Fatalf("loadLegacyOtherRechargeStats: %v", err)
}
applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)
row := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap()
assertInt64(t, row["recharge_usd_minor"], 1234)
assertInt64(t, row["mifapay_recharge_usd_minor"], 1234)
assertInt64(t, row["coin_seller_recharge_usd_minor"], 0)
assertInt64(t, row["user_recharge_usd_minor"], 1234)
assertInt64(t, row["paid_users"], 1)
assertInt64(t, row["new_user_recharge_usd_minor"], 1234)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := &AslanMongoDashboardSource{
legacyDB: db,
legacyWalletDatabase: "atyou_wallet",
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 0, 1)
grid := newLegacyMongoGrid(start, end)
cohorts := map[string]map[string][]int64{
"2026-07-05": {
"PH": {2069828597070528514},
},
}
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
perUser := map[string]map[int64]int64{}
mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}))
mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.type = 0").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
AddRow("2026-07-05", "PH", int64(2069828597070528514), "2000.00").
AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47"))
if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err)
}
applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)
phRow := grid.metrics["2026-07-05"]["PH"].toExternal(nil).toMap()
assertInt64(t, phRow["coin_seller_recharge_usd_minor"], 200000)
assertInt64(t, phRow["recharge_usd_minor"], 200000)
assertInt64(t, phRow["user_recharge_usd_minor"], 0)
assertInt64(t, phRow["paid_users"], 1)
assertInt64(t, phRow["new_user_recharge_usd_minor"], 200000)
pkRow := grid.metrics["2026-07-05"]["PK"].toExternal(nil).toMap()
assertInt64(t, pkRow["coin_seller_recharge_usd_minor"], 2547)
assertInt64(t, pkRow["recharge_usd_minor"], 2547)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := &AslanMongoDashboardSource{
legacyDB: db,
legacyWalletDatabase: "atyou_wallet",
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
start := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC)
startArg := time.UnixMilli(start.UnixMilli())
endArg := time.UnixMilli(end.UnixMilli())
createdAt := time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC)
queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw"
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2)))
mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}).
AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt).
AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour)))
items, total, err := source.ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{
StartMS: start.UnixMilli(),
EndMS: end.UnixMilli(),
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListCoinSellerRechargeBills: %v", err)
}
if total != 2 || len(items) != 2 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" {
t.Fatalf("unexpected first item identity: %+v", items[0])
}
if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() {
t.Fatalf("unexpected first item amount/time: %+v", items[0])
}
if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 {
t.Fatalf("unexpected second item: %+v", items[1])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) {
match := bson.M{}
aslanMongoExcludeFreightGoldRecharge(match)
for _, field := range []string{"trackCommodityType", "products.code", "products.name", "products.content"} {
condition, ok := match[field].(bson.M)
if !ok {
t.Fatalf("%s condition = %#v", field, match[field])
}
values, ok := condition["$nin"].(bson.A)
if !ok {
t.Fatalf("%s $nin = %#v", field, condition["$nin"])
}
got := map[any]bool{}
for _, value := range values {
got[value] = true
}
if !got["FREIGHT_GOLD"] || !got["FREIGHT_GOLD_SUPER"] {
t.Fatalf("%s should exclude freight commodity types, got %#v", field, values)
}
}
}
func TestAslanMongoLegacyRechargeSkipsNonPositiveAmount(t *testing.T) {
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1))
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
perUser := map[string]map[int64]int64{}
addLegacyStandardRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 101, 0)
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100)
row := grid.metrics["2026-07-05"]["SA"]
if row != nil {
t.Fatalf("non-positive legacy recharge should not create metric row: %#v", row)
}
if len(distinctUsers) != 0 {
t.Fatalf("non-positive legacy recharge should not create paid users: %#v", distinctUsers)
}
if len(perUser) != 0 {
t.Fatalf("non-positive legacy recharge should not create per-user recharge: %#v", perUser)
}
}
func TestAslanMongoCoinSellerRechargeSourceAvailability(t *testing.T) {
available := aslanMongoDashboardMetricSources(false, true)
assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor")
unavailable := aslanMongoDashboardMetricSources(false, false)
assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor")
}
func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) {
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1))
grid.at("2026-07-05", "SA").RechargeUSDMinor = 4200
grid.at("2026-07-05", "AE").RechargeUSDMinor = 1000
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{
{day: "2026-07-05", country: "SA"}: {"101": {}},
{day: "2026-07-05", country: "AE"}: {"101": {}},
}
applyAslanRechargeUserMetrics(nil, grid, distinctUsers, nil)
overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true)
if overview.Total.PaidUsers != 1 {
t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers)
}
if len(overview.DailySeries) != 1 {
t.Fatalf("daily series mismatch: %#v", overview.DailySeries)
}
assertInt64(t, overview.DailySeries[0]["paid_users"], 1)
countries := map[string]int64{}
for _, row := range overview.CountryBreakdown {
country, _ := row["country_code"].(string)
paid, _ := row["paid_users"].(int64)
countries[country] = paid
}
if countries["SA"] != 1 || countries["AE"] != 1 {
t.Fatalf("country paid users should preserve country buckets, got %#v", countries)
}
}
func assertMetricSourceAvailable(t *testing.T, sources []map[string]any, field string) {
t.Helper()
for _, source := range sources {
if source["field"] != field {
continue
}
if source["available"] != true {
t.Fatalf("metric source %s should be available, got %#v", field, source)
}
return
}
t.Fatalf("metric source %s not found in %#v", field, sources)
}

View File

@ -0,0 +1,123 @@
package dashboard
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// ListCoinSellerRechargeBills 把 dashboard-cdc-worker 的 dealer_recharge 聚合事实暴露给 finance 充值流水。
// Yumi 当前只有按天/国家沉淀的币商充值金额,没有单笔订单号和币商用户 ID这里生成稳定 synthetic id
// 让列表、导出和区域筛选至少与总览/Databi 的金额口径完全一致。
func (s *MySQLExternalDashboardSource) ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error) {
if s == nil || s.db == nil {
return nil, 0, fmt.Errorf("external dashboard source is not configured")
}
countryFilter, err := s.countryFilter(ctx, StatisticsQuery{RegionID: query.RegionID})
if err != nil {
return nil, 0, err
}
if countryFilter.Applied && len(countryFilter.Codes) == 0 {
return []CoinSellerRechargeBill{}, 0, nil
}
requestTimezone := strings.TrimSpace(query.StatTZ)
if requestTimezone == "" {
requestTimezone = s.statTimezone
}
requestLocation, err := time.LoadLocation(requestTimezone)
if err != nil {
return nil, 0, fmt.Errorf("load external dashboard bill request timezone %s: %w", requestTimezone, err)
}
storageTimezone := s.statTimezone
if _, err := time.LoadLocation(storageTimezone); err != nil {
return nil, 0, fmt.Errorf("load external dashboard bill storage timezone %s: %w", storageTimezone, err)
}
startDate, endDate, _, _ := externalDashboardDateRange(StatisticsQuery{StartMS: query.StartMS, EndMS: query.EndMS}, requestLocation)
whereSQL, args := s.periodMetricWhere(storageTimezone, startDate, endDate, countryFilter)
whereSQL, args = s.appendCoinSellerBillKeyword(whereSQL, args, query.Keyword)
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize < 1 {
pageSize = 10
}
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
var total int64
if err := s.db.QueryRowContext(queryCtx, `
SELECT COUNT(*) FROM (
SELECT t.period_key, t.country_code
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.period_key, t.country_code
HAVING COALESCE(SUM(t.dealer_recharge), 0) <> 0
) bills`, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count external coin seller recharge bills for %s: %w", s.appCode, err)
}
if total == 0 {
return []CoinSellerRechargeBill{}, 0, nil
}
rows, err := s.db.QueryContext(queryCtx, `
SELECT t.period_key, UPPER(t.country_code), MAX(t.country_name), CAST(COALESCE(SUM(t.dealer_recharge), 0) AS CHAR)
FROM country_dashboard_period_metric t
WHERE `+whereSQL+`
GROUP BY t.period_key, t.country_code
HAVING COALESCE(SUM(t.dealer_recharge), 0) <> 0
ORDER BY t.period_key DESC, t.country_code
LIMIT ? OFFSET ?`, append(args, pageSize, (page-1)*pageSize)...)
if err != nil {
return nil, 0, fmt.Errorf("query external coin seller recharge bills for %s: %w", s.appCode, err)
}
defer rows.Close()
items := make([]CoinSellerRechargeBill, 0, pageSize)
for rows.Next() {
var periodKey string
var countryCode string
var countryName sql.NullString
var amountText string
if err := rows.Scan(&periodKey, &countryCode, &countryName, &amountText); err != nil {
return nil, 0, fmt.Errorf("scan external coin seller recharge bill for %s: %w", s.appCode, err)
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return nil, 0, fmt.Errorf("parse external coin seller recharge amount for %s/%s/%s: %w", s.appCode, periodKey, countryCode, err)
}
createdAt, err := time.ParseInLocation("2006-01-02", periodKey, requestLocation)
if err != nil {
return nil, 0, fmt.Errorf("parse external coin seller recharge day for %s/%s/%s: %w", s.appCode, periodKey, countryCode, err)
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
items = append(items, CoinSellerRechargeBill{
TransactionID: fmt.Sprintf("%s-dealer-recharge-%s-%s", s.appCode, periodKey, countryCode),
Source: "dealer_recharge",
CountryCode: countryCode,
USDMinorAmount: amount,
CreatedAtMS: createdAt.UnixMilli(),
})
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate external coin seller recharge bills for %s: %w", s.appCode, err)
}
return items, total, nil
}
func (s *MySQLExternalDashboardSource) appendCoinSellerBillKeyword(whereSQL string, args []any, rawKeyword string) (string, []any) {
keyword := strings.TrimSpace(rawKeyword)
if keyword == "" {
return whereSQL, args
}
upperKeyword := strings.ToUpper(keyword)
return whereSQL + `
AND (CONCAT(?, '-dealer-recharge-', t.period_key, '-', UPPER(t.country_code)) = ?
OR UPPER(t.country_code) = ?
OR t.country_name LIKE ?)`, append(args, s.appCode, keyword, upperKeyword, "%"+keyword+"%")
}

View File

@ -96,7 +96,11 @@ type externalDashboardMetric struct {
D7RetentionRate float64
D30RetentionRate float64
CoinTotal *int64
RefreshedAt sql.NullTime
// 以下指针字段只有 likei Mongo 源能给出事实cdc MySQL 源保持 nil前端展示 "--"。
CoinSellerTransferCoin *int64
ConsumedCoin *int64
OutputCoin *int64
RefreshedAt sql.NullTime
}
type externalMetricScanner interface {
@ -480,26 +484,27 @@ func (m externalDashboardMetric) toMap() map[string]any {
"arppu_usd_minor": m.ARPPUUSDMinor,
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
"coin_seller_stock_coin": nil,
"coin_seller_transfer_coin": nil,
"coin_seller_transfer_coin": nullableInt64(m.CoinSellerTransferCoin),
"coin_total": nullableInt64(m.CoinTotal),
"consumed_coin": nil,
"consume_output_ratio": nil,
"consumed_coin": nullableInt64(m.ConsumedCoin),
"consume_output_ratio": consumeOutputRatioValue(m.ConsumedCoin, m.OutputCoin),
"daily_active_user": m.ActiveUsers,
"d1_retention_base_users": m.D1RetentionBaseUsers,
"d1_retention_rate": m.D1RetentionRate,
"d1_retention_users": m.D1RetentionUsers,
"d7_retention_base_users": m.D7RetentionBaseUsers,
"d7_retention_rate": m.D7RetentionRate,
"d7_retention_users": m.D7RetentionUsers,
"d30_retention_base_users": m.D30RetentionBaseUsers,
"d30_retention_rate": m.D30RetentionRate,
"d30_retention_users": m.D30RetentionUsers,
"d1_retention_base_users": retentionMetricValue(m.D1RetentionBaseUsers, m.D1RetentionBaseUsers),
"d1_retention_rate": retentionRateValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"d1_retention_users": retentionMetricValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"d7_retention_base_users": retentionMetricValue(m.D7RetentionBaseUsers, m.D7RetentionBaseUsers),
"d7_retention_rate": retentionRateValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"d7_retention_users": retentionMetricValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"d30_retention_base_users": retentionMetricValue(m.D30RetentionBaseUsers, m.D30RetentionBaseUsers),
"d30_retention_rate": retentionRateValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"d30_retention_users": retentionMetricValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"game_payout": m.GamePayout,
"game_players": m.GamePlayers,
"game_profit": m.GameProfit,
"game_profit_rate": m.GameProfitRate,
"game_turnover": m.GameTurnover,
"gift_coin_spent": m.GiftCoinSpent,
"consume_output_delta": consumeOutputDeltaValue(m.ConsumedCoin, m.OutputCoin),
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
"lucky_gift_payers": m.LuckyGiftPayers,
@ -515,7 +520,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
"new_users": m.NewUsers,
"output_coin": nil,
"output_coin": nullableInt64(m.OutputCoin),
"paid_users": m.PaidUsers,
"paid_conversion_rate": m.PaidConversionRate,
"payer_rate": m.PayerRate,
@ -541,15 +546,15 @@ func (m externalDashboardMetric) toMap() map[string]any {
func (m externalDashboardMetric) retentionMap() map[string]any {
return map[string]any{
"day1_base_users": m.D1RetentionBaseUsers,
"day1_rate": m.D1RetentionRate,
"day1_users": m.D1RetentionUsers,
"day7_base_users": m.D7RetentionBaseUsers,
"day7_rate": m.D7RetentionRate,
"day7_users": m.D7RetentionUsers,
"day30_base_users": m.D30RetentionBaseUsers,
"day30_rate": m.D30RetentionRate,
"day30_users": m.D30RetentionUsers,
"day1_base_users": retentionMetricValue(m.D1RetentionBaseUsers, m.D1RetentionBaseUsers),
"day1_rate": retentionRateValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"day1_users": retentionMetricValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"day7_base_users": retentionMetricValue(m.D7RetentionBaseUsers, m.D7RetentionBaseUsers),
"day7_rate": retentionRateValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"day7_users": retentionMetricValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"day30_base_users": retentionMetricValue(m.D30RetentionBaseUsers, m.D30RetentionBaseUsers),
"day30_rate": retentionRateValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"day30_users": retentionMetricValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"registered_users": m.NewUsers,
}
}
@ -752,6 +757,20 @@ func ratioFloat64(numerator int64, denominator int64) float64 {
return float64(numerator) / float64(denominator)
}
func retentionMetricValue(value int64, base int64) any {
if base <= 0 {
return nil
}
return value
}
func retentionRateValue(users int64, base int64) any {
if base <= 0 {
return nil
}
return ratioFloat64(users, base)
}
func deltaRate(current int64, previous int64) float64 {
if previous == 0 {
if current == 0 {
@ -785,6 +804,20 @@ func nullableInt64(value *int64) any {
return *value
}
func consumeOutputRatioValue(consumed *int64, output *int64) any {
if consumed == nil || output == nil {
return nil
}
return ratioFloat64(*consumed, *output)
}
func consumeOutputDeltaValue(consumed *int64, output *int64) any {
if consumed == nil || output == nil {
return nil
}
return *consumed - *output
}
func firstExternalDashboardValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {

View File

@ -120,6 +120,87 @@ func TestMySQLExternalDashboardSourceStatisticsOverview(t *testing.T) {
}
}
func TestMySQLExternalDashboardSourceListCoinSellerRechargeBills(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := NewMySQLExternalDashboardSource(db, config.DashboardExternalSourceConfig{
Enabled: true,
AppCode: "Yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
StatTimezone: "Asia/Riyadh",
RequestTimeout: time.Second,
})
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 6, 7, 0, 0, 0, 0, shanghai)
end := time.Date(2026, 7, 7, 0, 0, 0, 0, shanghai)
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*FROM country_dashboard_period_metric t[\\s\\S]*HAVING COALESCE\\(SUM\\(t\\.dealer_recharge\\), 0\\) <> 0").
WithArgs("Asia/Riyadh", "LIKEI", "2026-06-07", "2026-07-07").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2)))
mock.ExpectQuery("SELECT t\\.period_key, UPPER\\(t\\.country_code\\), MAX\\(t\\.country_name\\), CAST\\(COALESCE\\(SUM\\(t\\.dealer_recharge\\), 0\\) AS CHAR\\)[\\s\\S]*FROM country_dashboard_period_metric t").
WithArgs("Asia/Riyadh", "LIKEI", "2026-06-07", "2026-07-07", 20, 0).
WillReturnRows(sqlmock.NewRows([]string{"period_key", "country_code", "country_name", "dealer_recharge"}).
AddRow("2026-07-05", "SA", "Saudi Arabia", "13686.92").
AddRow("2026-07-04", "EG", "Egypt", "10.00"))
items, total, err := source.(CoinSellerRechargeBillSource).ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{
StatTZ: "Asia/Shanghai",
StartMS: start.UnixMilli(),
EndMS: end.UnixMilli(),
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListCoinSellerRechargeBills: %v", err)
}
if total != 2 || len(items) != 2 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
if items[0].TransactionID != "yumi-dealer-recharge-2026-07-05-SA" || items[0].Source != "dealer_recharge" || items[0].CountryCode != "SA" {
t.Fatalf("unexpected first item identity: %+v", items[0])
}
if items[0].USDMinorAmount != 1_368_692 {
t.Fatalf("unexpected first item amount: %+v", items[0])
}
expectedCreatedAt := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai).UnixMilli()
if items[0].CreatedAtMS != expectedCreatedAt {
t.Fatalf("unexpected first item created_at: got %d want %d", items[0].CreatedAtMS, expectedCreatedAt)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestExternalDashboardRetentionUsesNilWhenBaseMissing(t *testing.T) {
metric := externalDashboardMetric{
D1RetentionUsers: 0,
D1RetentionBaseUsers: 0,
D7RetentionUsers: 0,
D7RetentionBaseUsers: 5,
D30RetentionUsers: 0,
D30RetentionBaseUsers: 0,
}
row := metric.toMap()
if row["d1_retention_rate"] != nil || row["d1_retention_users"] != nil || row["d1_retention_base_users"] != nil {
t.Fatalf("d1 retention without base should be nil, got %#v", row)
}
if row["d7_retention_rate"] != float64(0) || row["d7_retention_users"] != int64(0) || row["d7_retention_base_users"] != int64(5) {
t.Fatalf("d7 observed zero retention should stay visible, got %#v", row)
}
retention := metric.retentionMap()
if retention["day30_rate"] != nil || retention["day30_users"] != nil || retention["day30_base_users"] != nil {
t.Fatalf("d30 retention without base should be nil, got %#v", retention)
}
}
func TestMySQLExternalDashboardSourceFiltersLegacyRegionCountries(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {

View File

@ -19,6 +19,11 @@ func New(store *repository.Store, cfg config.Config, user userclient.Client, opt
return &Handler{service: NewService(store, cfg, user, options...)}
}
// NewWithService 复用已构建的 DashboardService社交 BI 模块与大屏共享同一份外部源路由。
func NewWithService(service *DashboardService) *Handler {
return &Handler{service: service}
}
func (h *Handler) DashboardOverview(c *gin.Context) {
overview, err := h.service.Overview()
if err != nil {

View File

@ -44,6 +44,16 @@ type SelfGameStatisticsQuery struct {
GameID string
}
type AppTrackingFunnelQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
RegionID int64
RegionIDs []int64
CountryID int64
}
type PlatformGrantStatisticsQuery struct {
AppCode string
StatTZ string
@ -167,6 +177,37 @@ func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query
return overview, nil
}
func (s *DashboardService) AppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (map[string]any, error) {
values := url.Values{}
values.Set("app_code", query.AppCode)
if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" {
values.Set("stat_tz", statTZ)
}
if query.StartMS > 0 {
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
}
if query.EndMS > 0 {
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
}
if query.RegionID > 0 {
values.Set("region_id", strconv.FormatInt(query.RegionID, 10))
} else if len(query.RegionIDs) > 0 {
parts := make([]string, 0, len(query.RegionIDs))
for _, regionID := range query.RegionIDs {
if regionID > 0 {
parts = append(parts, strconv.FormatInt(regionID, 10))
}
}
if len(parts) > 0 {
values.Set("region_ids", strings.Join(parts, ","))
}
}
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
return s.statisticsGET(ctx, "/internal/v1/statistics/app/funnel", values)
}
func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
if err != nil {

View File

@ -0,0 +1,133 @@
package databi
import (
"strconv"
"strings"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
store *repository.Store
audit shared.OperationLogger
}
func New(service *Service, store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{service: service, store: store, audit: audit}
}
func (h *Handler) Master(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, access.All)
if err != nil {
response.ServerError(c, "获取 BI 主数据失败")
return
}
response.OK(c, master)
}
func (h *Handler) Overview(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Overview(c.Request.Context(), access, OverviewQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
})
if err != nil {
response.ServerError(c, "获取 BI 统计数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) Funnel(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Funnel(c.Request.Context(), access, FunnelQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
})
if err != nil {
response.ServerError(c, "获取埋点漏斗数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) Kpi(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Kpi(c.Request.Context(), access, KpiQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
PeriodMonth: c.Query("period_month"),
AppCodes: splitCSV(c.Query("app_codes")),
OperatorUserID: queryUint(c, "operator_user_id"),
ActorUserID: shared.ActorFromContext(c).UserID,
ViewAll: access.All,
})
if err != nil {
if strings.Contains(err.Error(), "period_month") {
response.BadRequest(c, "运营充值月份格式不正确")
return
}
response.ServerError(c, "获取运营充值数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) moneyAccess(c *gin.Context) (repository.MoneyAccess, bool) {
if h.store == nil {
return repository.MoneyAccess{All: true}, true
}
access, err := h.store.MoneyAccessForUser(shared.ActorFromContext(c).UserID)
if err != nil {
response.ServerError(c, "获取数据范围失败")
return repository.MoneyAccess{}, false
}
return access, true
}
func queryInt64(c *gin.Context, key string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(c.Query(key)), 10, 64)
return parsed
}
func queryUint(c *gin.Context, key string) uint {
parsed, _ := strconv.ParseUint(strings.TrimSpace(c.Query(key)), 10, 64)
return uint(parsed)
}
func splitCSV(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}

View File

@ -0,0 +1,270 @@
package databi
import (
"encoding/json"
"math"
"strconv"
"strings"
)
// additiveMetricKeys 是各统计源statistics-service / dashboard 外接源)国家行里可以线性相加的指标;
// 国家按用户归属互斥,所以计数与金额按国家求和即为区域/整体口径,比例类字段一律丢弃后重算。
var additiveMetricKeys = []string{
"new_users",
"registered_users",
"registrations",
"active_users",
"paid_users",
"new_paid_users",
"recharge_users",
"recharge_usd_minor",
"total_recharge_usd_minor",
"user_recharge_usd_minor",
"new_user_recharge_usd_minor",
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"coin_seller_transfer_coin",
"new_dealer_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"google_recharge_usd_minor",
"coin_total",
"consumed_coin",
"output_coin",
"platform_grant_coin",
"manual_grant_coin",
"salary_usd_minor",
"salary_exchange_usd_minor",
"salary_transfer_usd_minor",
"salary_transfer_coin",
"mic_online_ms",
"mic_online_users",
"gift_coin_spent",
"lucky_gift_turnover",
"lucky_gift_payout",
"lucky_gift_payers",
"lucky_gift_anchor_share",
"room_lucky_gift_turnover",
"game_turnover",
"game_payout",
"game_refund",
"game_players",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"d1_retention_users",
"d1_retention_base_users",
"d7_retention_users",
"d7_retention_base_users",
"d30_retention_users",
"d30_retention_base_users",
}
type metricAccumulator struct {
values map[string]int64
present map[string]bool
}
func newMetricAccumulator() *metricAccumulator {
return &metricAccumulator{values: map[string]int64{}, present: map[string]bool{}}
}
func (acc *metricAccumulator) addRow(row map[string]any) {
if row == nil {
return
}
for _, key := range additiveMetricKeys {
raw, ok := row[key]
if !ok || raw == nil {
continue
}
value, ok := metricInt64(raw)
if !ok {
continue
}
acc.values[key] += value
acc.present[key] = true
}
}
// toMap 输出与统计源同名的指标字段:可加字段直接给累加值,派生字段(比例/均值/利润)统一重算;
// 某个字段在所有来源行里都缺失legacy 口径未接入)时输出 nil前端照旧显示 "--"。
func (acc *metricAccumulator) toMap() map[string]any {
out := map[string]any{}
for _, key := range additiveMetricKeys {
if acc.present[key] {
out[key] = acc.values[key]
} else {
out[key] = nil
}
}
recharge, hasRecharge := acc.value("recharge_usd_minor")
active, hasActive := acc.value("active_users")
paid, hasPaid := acc.value("paid_users")
rechargeUsers, hasRechargeUsers := acc.value("recharge_users")
out["arpu_usd_minor"] = nilUnless(hasRecharge && hasActive, divideRound(recharge, active))
out["arppu_usd_minor"] = nilUnless(hasRecharge && hasPaid, divideRound(recharge, paid))
out["payer_rate"] = nilUnless(hasPaid && hasActive, safeRatio(paid, active))
out["paid_conversion_rate"] = nilUnless(hasPaid && hasActive, safeRatio(paid, active))
out["recharge_conversion_rate"] = nilUnless(hasRechargeUsers && hasActive, safeRatio(rechargeUsers, active))
gameTurnover, hasGameTurnover := acc.value("game_turnover")
gamePayout, hasGamePayout := acc.value("game_payout")
if hasGameTurnover && hasGamePayout {
profit := gameTurnover - gamePayout
out["game_profit"] = profit
out["game_profit_rate"] = safeRatio(profit, gameTurnover)
} else {
out["game_profit"] = nil
out["game_profit_rate"] = nil
}
luckyTurnover, hasLuckyTurnover := acc.value("lucky_gift_turnover")
luckyPayout, hasLuckyPayout := acc.value("lucky_gift_payout")
if hasLuckyTurnover && hasLuckyPayout {
profit := luckyTurnover - luckyPayout
out["lucky_gift_profit"] = profit
out["lucky_gift_payout_rate"] = safeRatio(luckyPayout, luckyTurnover)
out["lucky_gift_profit_rate"] = safeRatio(profit, luckyTurnover)
} else {
out["lucky_gift_profit"] = nil
out["lucky_gift_payout_rate"] = nil
out["lucky_gift_profit_rate"] = nil
}
superTurnover, hasSuperTurnover := acc.value("super_lucky_gift_turnover")
superPayout, hasSuperPayout := acc.value("super_lucky_gift_payout")
if hasSuperTurnover && hasSuperPayout {
profit := superTurnover - superPayout
out["super_lucky_gift_profit"] = profit
out["super_lucky_gift_profit_rate"] = safeRatio(profit, superTurnover)
} else {
out["super_lucky_gift_profit"] = nil
out["super_lucky_gift_profit_rate"] = nil
}
consumed, hasConsumed := acc.value("consumed_coin")
output, hasOutput := acc.value("output_coin")
out["consume_output_ratio"] = nilUnless(hasConsumed && hasOutput, safeRatio(consumed, output))
out["consume_output_delta"] = nilUnless(hasConsumed && hasOutput, consumed-output)
micMS, hasMicMS := acc.value("mic_online_ms")
micUsers, hasMicUsers := acc.value("mic_online_users")
out["avg_mic_online_ms"] = nilUnless(hasMicMS && hasMicUsers, divideRound(micMS, micUsers))
for _, day := range []string{"d1", "d7", "d30"} {
users, hasUsers := acc.value(day + "_retention_users")
base, hasBase := acc.value(day + "_retention_base_users")
out[day+"_retention_rate"] = nilUnless(hasUsers && hasBase, safeRatio(users, base))
}
return out
}
func (acc *metricAccumulator) value(key string) (int64, bool) {
return acc.values[key], acc.present[key]
}
func nilUnless(condition bool, value any) any {
if !condition {
return nil
}
return value
}
func divideRound(numerator int64, denominator int64) int64 {
if denominator == 0 {
return 0
}
return int64(math.Round(float64(numerator) / float64(denominator)))
}
func safeRatio(numerator int64, denominator int64) float64 {
if denominator == 0 {
return 0
}
return float64(numerator) / float64(denominator)
}
func metricInt64(value any) (int64, bool) {
switch typed := value.(type) {
case nil:
return 0, false
case int64:
return typed, true
case int32:
return int64(typed), true
case int:
return int64(typed), true
case uint:
return int64(typed), true
case uint64:
return int64(typed), true
case float64:
return int64(math.Round(typed)), true
case float32:
return int64(math.Round(float64(typed))), true
case json.Number:
if parsed, err := typed.Int64(); err == nil {
return parsed, true
}
if parsed, err := typed.Float64(); err == nil {
return int64(math.Round(parsed)), true
}
return 0, false
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return 0, false
}
if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
return parsed, true
}
if parsed, err := strconv.ParseFloat(trimmed, 64); err == nil {
return int64(math.Round(parsed)), true
}
return 0, false
default:
return 0, false
}
}
func anyRowSlice(value any) []map[string]any {
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if row, ok := item.(map[string]any); ok {
out = append(out, row)
}
}
return out
default:
return nil
}
}
func rowString(row map[string]any, keys ...string) string {
for _, key := range keys {
if raw, ok := row[key]; ok && raw != nil {
if text, ok := raw.(string); ok {
if trimmed := strings.TrimSpace(text); trimmed != "" {
return trimmed
}
}
}
}
return ""
}
func rowInt64(row map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
if raw, ok := row[key]; ok && raw != nil {
if value, ok := metricInt64(raw); ok {
return value, true
}
}
}
return 0, false
}

View File

@ -0,0 +1,112 @@
package databi
import (
"encoding/json"
"testing"
)
func TestRollupByRegionGroupsCountriesAndRecomputesDerived(t *testing.T) {
regions := []RegionInfo{
{AppCode: "aslan", RegionID: 11, RegionCode: "MENA", RegionName: "中东", Countries: []string{"SA", "AE"}},
{AppCode: "aslan", RegionID: 12, RegionCode: "SEA", RegionName: "东南亚", Countries: []string{"ID"}},
}
rows := []map[string]any{
{"country_code": "SA", "recharge_usd_minor": int64(10000), "active_users": int64(100), "paid_users": int64(10), "recharge_users": int64(10), "gift_coin_spent": nil},
{"country_code": "AE", "recharge_usd_minor": json.Number("5000"), "active_users": json.Number("50"), "paid_users": json.Number("5"), "recharge_users": json.Number("5"), "gift_coin_spent": nil},
{"country_code": "ID", "recharge_usd_minor": int64(2000), "active_users": int64(40), "paid_users": int64(2), "recharge_users": int64(2), "gift_coin_spent": int64(700)},
{"country_code": "XX", "recharge_usd_minor": int64(1), "active_users": int64(1), "paid_users": int64(0), "recharge_users": int64(0)},
}
out := rollupByRegion(rows, regionResolver(regions))
if len(out) != 3 {
t.Fatalf("expected 3 region rows (MENA/SEA/未划分), got %d", len(out))
}
mena := out[0]
if got, _ := rowInt64(mena, "region_id"); got != 11 {
t.Fatalf("expected first row to be region 11 by recharge desc, got %d", got)
}
if got, _ := rowInt64(mena, "recharge_usd_minor"); got != 15000 {
t.Fatalf("expected MENA recharge 15000, got %d", got)
}
if got, _ := rowInt64(mena, "active_users"); got != 150 {
t.Fatalf("expected MENA active users 150, got %d", got)
}
if got, ok := mena["arppu_usd_minor"].(int64); !ok || got != 1000 {
t.Fatalf("expected MENA arppu 1000, got %v", mena["arppu_usd_minor"])
}
if mena["gift_coin_spent"] != nil {
t.Fatalf("expected MENA gift_coin_spent nil when all rows nil, got %v", mena["gift_coin_spent"])
}
sea := out[1]
if got, _ := rowInt64(sea, "gift_coin_spent"); got != 700 {
t.Fatalf("expected SEA gift_coin_spent 700, got %d", got)
}
unassigned := out[2]
if got, _ := rowInt64(unassigned, "region_id"); got != 0 {
t.Fatalf("expected unassigned region id 0, got %d", got)
}
}
func TestRollupByRegionPrefersRowRegionID(t *testing.T) {
regions := []RegionInfo{{AppCode: "lalu", RegionID: 3, RegionCode: "LATAM", RegionName: "拉美", Countries: []string{"BR"}}}
rows := []map[string]any{
{"region_id": json.Number("3"), "country_id": int64(55), "recharge_usd_minor": int64(100)},
{"region_id": int64(3), "country_id": int64(56), "recharge_usd_minor": int64(200)},
}
out := rollupByRegion(rows, regionResolver(regions))
if len(out) != 1 {
t.Fatalf("expected 1 region row, got %d", len(out))
}
if got := out[0]["region_name"]; got != "拉美" {
t.Fatalf("expected region name 拉美, got %v", got)
}
if got, _ := rowInt64(out[0], "recharge_usd_minor"); got != 300 {
t.Fatalf("expected recharge 300, got %d", got)
}
}
func TestFilterRowsByRegion(t *testing.T) {
regions := []RegionInfo{
{AppCode: "yumi", RegionID: 21, Countries: []string{"SA"}},
{AppCode: "yumi", RegionID: 22, Countries: []string{"EG"}},
}
rows := []map[string]any{
{"country_code": "SA"},
{"country_code": "EG"},
{"country_code": "??"},
}
out := filterRowsByRegion(rows, regionResolver(regions), int64Set([]int64{21}))
if len(out) != 1 || out[0]["country_code"] != "SA" {
t.Fatalf("expected only SA row, got %v", out)
}
}
func TestAllowedRegions(t *testing.T) {
access := testAccess()
if all, _ := allowedRegions(access, "lalu"); !all {
t.Fatalf("expected whole-app access for lalu (region 0 scope)")
}
all, ids := allowedRegions(access, "aslan")
if all || len(ids) != 2 {
t.Fatalf("expected two region scopes for aslan, got all=%v ids=%v", all, ids)
}
if all, ids := allowedRegions(access, "yumi"); all || len(ids) != 0 {
t.Fatalf("expected no access for yumi, got all=%v ids=%v", all, ids)
}
}
func TestResolveKpiMonthClampsFuture(t *testing.T) {
location := mustLocation(t, "Asia/Shanghai")
month, start, end, err := resolveKpiMonth("2020-02", 0, location)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if month != "2020-02" {
t.Fatalf("expected month 2020-02, got %s", month)
}
if start.Format("2006-01-02") != "2020-02-01" || end.Format("2006-01-02") != "2020-03-01" {
t.Fatalf("unexpected month window %s ~ %s", start, end)
}
if _, _, _, err := resolveKpiMonth("2020/02", 0, location); err == nil {
t.Fatalf("expected format error for 2020/02")
}
}

View File

@ -0,0 +1,14 @@
package databi
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/databi/social/master", middleware.RequirePermission("overview:view"), h.Master)
protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview)
protected.GET("/admin/databi/social/funnel", middleware.RequirePermission("overview:view"), h.Funnel)
protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
package databi
import (
"testing"
"time"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
func testAccess() repository.MoneyAccess {
return repository.MoneyAccess{
UserID: 7,
Scopes: []model.UserMoneyScope{
{UserID: 7, AppCode: "lalu", RegionID: 0},
{UserID: 7, AppCode: "aslan", RegionID: 11},
{UserID: 7, AppCode: "aslan", RegionID: 12},
},
}
}
func mustLocation(t *testing.T, name string) *time.Location {
t.Helper()
location, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("load location %s: %v", name, err)
}
return location
}
func TestRegionDisplay(t *testing.T) {
regions := []RegionInfo{{AppCode: "aslan", RegionID: 11, RegionCode: "MENA", RegionName: "中东"}}
if _, name := regionDisplay(regions, 0); name != "全部区域" {
t.Fatalf("expected 全部区域 for region 0, got %s", name)
}
if code, name := regionDisplay(regions, 11); code != "MENA" || name != "中东" {
t.Fatalf("unexpected display for region 11: %s %s", code, name)
}
if _, name := regionDisplay(regions, 99); name != "区域 99" {
t.Fatalf("expected fallback name for unknown region, got %s", name)
}
}
func TestTopLevelMetricsStripsStructuralKeys(t *testing.T) {
day1Rate := 0.5
out := topLevelMetrics(map[string]any{
"recharge_usd_minor": int64(5),
"daily_series": []any{"x"},
"country_breakdown": []any{"y"},
"daily_country_breakdown": []any{"z"},
"report_metric_sources": []any{},
"retention": map[string]any{
"day1_base_users": int64(6),
"day1_rate": day1Rate,
"day1_users": int64(3),
},
})
if _, ok := out["daily_series"]; ok {
t.Fatalf("expected daily_series stripped")
}
if out["recharge_usd_minor"] != int64(5) {
t.Fatalf("expected metric kept, got %v", out["recharge_usd_minor"])
}
if out["d1_retention_rate"] != day1Rate || out["d1_retention_users"] != int64(3) || out["d1_retention_base_users"] != int64(6) {
t.Fatalf("expected nested retention flattened, got %#v", out)
}
}
// legacy 雪花区域 ID 超出 JS 安全整数,历史 scope 里存的是 float64 圆整值;
// 归一与展示必须能容差还原(线上「区域 20453...600」事故的回归测试)。
func TestRegionDisplayToleratesRoundedIDs(t *testing.T) {
exact := int64(2049084769873498113)
rounded := int64(float64(exact))
if exact == rounded {
t.Fatalf("fixture should lose precision")
}
regions := []RegionInfo{{AppCode: "aslan", RegionID: exact, RegionCode: "AR", RegionName: "阿语区"}}
if _, name := regionDisplay(regions, rounded); name != "阿语区" {
t.Fatalf("expected rounded id to resolve region name, got %s", name)
}
if got := normalizeRegionID(regions, rounded); got != exact {
t.Fatalf("expected normalize to exact id, got %d", got)
}
if !regionInCatalog(regions, rounded) {
t.Fatalf("expected rounded id to match catalog")
}
}

View File

@ -27,6 +27,7 @@ const (
targetScopeRegion = "region"
targetScopeCountry = "country"
targetScopeAllActiveUsers = "all_active_users"
targetScopeAllRegistered = "all_registered_users"
defaultProducerEventType = "admin_full_server_notice"
defaultBatchSize = 500
@ -43,30 +44,30 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type fanoutRequest struct {
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs []int64 `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON string `json:"metadata_json"`
BatchSize int32 `json:"batch_size"`
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs int64List `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON string `json:"metadata_json"`
BatchSize int32 `json:"batch_size"`
}
type fanoutDTO struct {
@ -201,7 +202,7 @@ func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, er
func buildTargetFilterJSON(req fanoutRequest) (string, error) {
var payload map[string]any
switch req.TargetScope {
case targetScopeAllActiveUsers:
case targetScopeAllActiveUsers, targetScopeAllRegistered:
payload = map[string]any{}
case targetScopeSingleUser:
if req.TargetUserID <= 0 {
@ -209,7 +210,7 @@ func buildTargetFilterJSON(req fanoutRequest) (string, error) {
}
payload = map[string]any{"target_user_id": req.TargetUserID}
case targetScopeUserIDs:
userIDs := positiveUniqueInt64s(req.UserIDs)
userIDs := positiveUniqueInt64s([]int64(req.UserIDs))
if len(userIDs) == 0 {
return "", fmt.Errorf("user_ids 不能为空")
}
@ -268,13 +269,48 @@ func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) {
func allowedTargetScope(scope string) bool {
switch scope {
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers:
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers, targetScopeAllRegistered:
return true
default:
return false
}
}
type int64List []int64
func (values *int64List) UnmarshalJSON(data []byte) error {
var rawItems []json.RawMessage
if err := json.Unmarshal(data, &rawItems); err != nil {
return err
}
parsed := make([]int64, 0, len(rawItems))
for _, rawItem := range rawItems {
value, err := parseJSONInt64(rawItem)
if err != nil {
return err
}
parsed = append(parsed, value)
}
*values = parsed
return nil
}
func parseJSONInt64(raw json.RawMessage) (int64, error) {
text := strings.TrimSpace(string(raw))
if strings.HasPrefix(text, `"`) {
unquoted, err := strconv.Unquote(text)
if err != nil {
return 0, err
}
text = strings.TrimSpace(unquoted)
}
value, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return 0, err
}
return value, nil
}
func positiveUniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))

View File

@ -9,7 +9,7 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "activity",
TargetScope: "user_ids",
UserIDs: []int64{42, 43, 42, 0},
UserIDs: int64List{42, 43, 42, 0},
Title: "活动上线",
Summary: "新活动已上线。",
ActionType: "activity_detail",
@ -38,6 +38,16 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
}
}
func TestFanoutUserIDsAcceptNumericStrings(t *testing.T) {
var userIDs int64List
if err := userIDs.UnmarshalJSON([]byte(`["325379237278126080","42"]`)); err != nil {
t.Fatalf("UnmarshalJSON failed: %v", err)
}
if len(userIDs) != 2 || userIDs[0] != 325379237278126080 || userIDs[1] != 42 {
t.Fatalf("user_ids mismatch: %+v", userIDs)
}
}
func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "system",
@ -53,3 +63,23 @@ func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
t.Fatalf("country target without country should fail")
}
}
func TestBuildFanoutJSONForAllRegisteredUsersScope(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "system",
TargetScope: "all_registered_users",
Title: "系统通知",
Summary: "所有注册用户可见。",
BatchSize: 500,
}, 1)
if err != nil {
t.Fatalf("normalizeFanoutRequest failed: %v", err)
}
targetFilterJSON, err := buildTargetFilterJSON(req)
if err != nil {
t.Fatalf("buildTargetFilterJSON failed: %v", err)
}
if targetFilterJSON != `{}` {
t.Fatalf("target filter mismatch: %s", targetFilterJSON)
}
}

View File

@ -54,6 +54,7 @@ type ManagerListItem struct {
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanGrantBadge bool `json:"canGrantBadge"`
CanGrantVIP bool `json:"canGrantVip"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddAdmin bool `json:"canAddAdmin"`
@ -619,15 +620,16 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = 'active',
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_grant_badge = VALUES(can_grant_badge),
can_grant_vip = VALUES(can_grant_vip),
can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_admin = VALUES(can_add_admin),
@ -635,8 +637,8 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanGrantBadge, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
return nil, err
}
@ -740,14 +742,14 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
mp.status, COALESCE(mp.contact_info, ''),
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_update_user_level,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.created_at_ms, mp.updated_at_ms
%s
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_update_user_level,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
mp.created_at_ms, mp.updated_at_ms
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
@ -773,6 +775,7 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.CanGrantAvatarFrame,
&item.CanGrantVehicle,
&item.CanGrantBadge,
&item.CanGrantVIP,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddAdmin,

View File

@ -72,6 +72,7 @@ type createManagerRequest struct {
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
@ -85,6 +86,7 @@ type updateManagerRequest struct {
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
@ -97,6 +99,7 @@ type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
@ -110,6 +113,7 @@ func (r createManagerRequest) permissionsWithDefault() managerPermissions {
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
@ -123,6 +127,7 @@ func (r updateManagerRequest) appendPermissionAssignments(assignments *[]string,
appendBoolAssignment(assignments, args, "can_grant_avatar_frame", r.CanGrantAvatarFrame)
appendBoolAssignment(assignments, args, "can_grant_vehicle", r.CanGrantVehicle)
appendBoolAssignment(assignments, args, "can_grant_badge", r.CanGrantBadge)
appendBoolAssignment(assignments, args, "can_grant_vip", r.CanGrantVIP)
appendBoolAssignment(assignments, args, "can_update_user_level", r.CanUpdateUserLevel)
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)

View File

@ -100,6 +100,7 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
Status: "active",
Contact: "+63",
CanGrantVehicle: true,
CanGrantVIP: true,
CanBlockUser: false,
BDLeaderCount: 3,
LastInvitedAtMs: 1720000000000,
@ -114,6 +115,7 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
`"displayUserId":"165549"`,
`"contact":"+63"`,
`"canGrantVehicle":true`,
`"canGrantVip":true`,
`"canBlockUser":false`,
`"bdLeaderCount":3`,
`"lastInvitedAtMs":1720000000000`,

View File

@ -28,6 +28,13 @@ type rechargeBillDTO struct {
ExchangeUSDMinorAmount int64 `json:"exchangeUsdMinorAmount"`
ProviderAmountMinor int64 `json:"providerAmountMinor,omitempty"`
CreatedAtMS int64 `json:"createdAtMs"`
ProviderCode string `json:"providerCode"`
UserPaidCurrencyCode string `json:"userPaidCurrencyCode"`
UserPaidAmountMicro int64 `json:"userPaidAmountMicro"`
ProviderFeeMicro int64 `json:"providerFeeMicro"`
ProviderTaxMicro int64 `json:"providerTaxMicro"`
ProviderNetMicro int64 `json:"providerNetMicro"`
PaidSyncedAtMS int64 `json:"paidSyncedAtMs"`
User rechargeBillUserDTO `json:"user"`
Seller rechargeBillUserDTO `json:"seller"`
}
@ -168,6 +175,13 @@ func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
ExchangeUSDMinorAmount: item.GetExchangeUsdMinorAmount(),
ProviderAmountMinor: item.GetProviderAmountMinor(),
CreatedAtMS: item.GetCreatedAtMs(),
ProviderCode: item.GetProviderCode(),
UserPaidCurrencyCode: item.GetUserPaidCurrencyCode(),
UserPaidAmountMicro: item.GetUserPaidAmountMicro(),
ProviderFeeMicro: item.GetProviderFeeMicro(),
ProviderTaxMicro: item.GetProviderTaxMicro(),
ProviderNetMicro: item.GetProviderNetMicro(),
PaidSyncedAtMS: item.GetPaidSyncedAtMs(),
}
}

View File

@ -32,6 +32,7 @@ type Handler struct {
audit shared.OperationLogger
exchangeRates exchangeRateClient
moneyRegionSources []MoneyRegionSource
billSources map[string]RechargeBillSource
}
type HandlerOption func(*Handler)
@ -46,6 +47,21 @@ func WithMoneyRegionSources(sources ...MoneyRegionSource) HandlerOption {
}
}
// WithRechargeBillSources 注册 legacy AppYumi 等)的外部账单源;命中 app_code 的充值明细和汇总直接读外部源。
func WithRechargeBillSources(sources ...RechargeBillSource) HandlerOption {
return func(h *Handler) {
for _, source := range sources {
if source == nil {
continue
}
if h.billSources == nil {
h.billSources = map[string]RechargeBillSource{}
}
h.billSources[appctx.Normalize(source.AppCode())] = source
}
}
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger, options ...HandlerOption) *Handler {
handler := &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
@ -61,6 +77,10 @@ func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *re
func (h *Handler) ListRechargeBills(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
h.listLegacyRechargeBills(c, source)
return
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
@ -82,6 +102,7 @@ func (h *Handler) ListRechargeBills(c *gin.Context) {
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
@ -688,8 +709,13 @@ func applyThirdPartyPaymentRateMarkup(rateText string, markupPercent float64) (s
if err != nil || rawRate <= 0 || !validThirdPartyPaymentRateMarkup(markupPercent) {
return "", false
}
// 汇率源返回的是实时 USD->本币原值;写库前统一叠加运营设置的上浮比例,再按 1 位小数向上取,保证 H5 下单看到的支付汇率不低于实时汇率。
// 汇率源返回的是实时 USD->本币原值;写库前先叠加运营上浮比例,再按金额量级向上取整,保证 H5 下单汇率不低于实时汇率。
markedRate := rawRate * (1 + markupPercent/100)
if markedRate > 50 {
// 高汇率币种(例如 IDR展示和支付金额都不应出现小数减去极小量只抵消 float 乘法毛刺,真实 18510.1 仍会进到 18511。
return strconv.FormatFloat(math.Ceil(markedRate-1e-9), 'f', 0, 64), true
}
// 低汇率币种仍保留原有“最多 1 位小数且向上取”的策略,避免 SAR/BHD 这类币种因强行整数化放大支付金额。
roundedRate := math.Ceil(markedRate*10-1e-9) / 10
return trimOneDecimalRate(strconv.FormatFloat(roundedRate, 'f', 1, 64)), true
}

View File

@ -482,7 +482,7 @@ func TestSyncThirdPartyPaymentMethodsDefaultsToV5Pay(t *testing.T) {
}
}
func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.T) {
func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilRules(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: "lalu",
@ -500,12 +500,13 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
Status: "active",
Methods: []*walletv1.ThirdPartyPaymentMethod{
{MethodId: 2310, CurrencyCode: "SAR", Status: "active"},
{MethodId: 2311, CurrencyCode: "IDR", Status: "active"},
},
}},
}}
handler := New(wallet, nil, nil, nil, nil)
handler.exchangeRates = fakeExchangeRateClient{result: exchangeRateResult{
Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000"},
Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000", "IDR": "18510.10000000"},
SourceNames: []string{"open.er-api.com"},
}}
router := newPaymentHandlerTestRouter(handler)
@ -521,7 +522,7 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("sync must read active and disabled methods: %+v", wallet.lastThirdPartyChannels)
}
if len(wallet.updateRateRequests) != 3 {
if len(wallet.updateRateRequests) != 4 {
t.Fatalf("sync should update every method with a fetched currency rate: %+v", wallet.updateRateRequests)
}
got := map[int64]string{}
@ -531,18 +532,26 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
}
got[req.GetMethodId()] = req.GetUsdToCurrencyRate()
}
if got[810] != "3.9" || got[811] != "0.4" || got[2310] != "3.9" {
if got[810] != "3.9" || got[811] != "0.4" || got[2310] != "3.9" || got[2311] != "19066" {
t.Fatalf("synced rates mismatch: %+v", got)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != 0 || response.Data["updatedCount"].(float64) != 3 {
if response.Code != 0 || response.Data["updatedCount"].(float64) != 4 {
t.Fatalf("sync response mismatch: %+v", response)
}
}
func TestApplyThirdPartyPaymentRateMarkupCeilsHighRateToInteger(t *testing.T) {
rate, ok := applyThirdPartyPaymentRateMarkup("18510.10000000", 0)
if !ok || rate != "18511" {
t.Fatalf("IDR high rate must ceil to integer: rate=%q ok=%v", rate, ok)
}
}
func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,470 @@
package payment
import (
"context"
"testing"
"time"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/modules/dashboard"
"go.mongodb.org/mongo-driver/v2/bson"
)
func yumiBillSourceConfig() config.FinanceBillSourceConfig {
return config.FinanceBillSourceConfig{
Enabled: true,
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
MongoDatabase: "test",
MongoCollection: "in_app_purchase_details",
RequestTimeout: time.Second,
}
}
func aslanBillSourceConfig() config.FinanceBillSourceConfig {
cfg := yumiBillSourceConfig()
cfg.AppCode = "aslan"
cfg.AppName = "Aslan"
cfg.SysOrigin = "ATYOU"
cfg.MongoDatabase = "atyou"
return cfg
}
func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) {
amount, err := bson.ParseDecimal128("164.99")
if err != nil {
t.Fatalf("parse amount: %v", err)
}
amountUSD, err := bson.ParseDecimal128("2.99")
if err != nil {
t.Fatalf("parse amount usd: %v", err)
}
createTime := time.UnixMilli(1_751_000_000_000).UTC()
dto := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{
ID: "175100000000012345",
OrderID: "GPA.1234-5678-9012-34567",
AcceptUserID: 42,
CountryCode: "TR",
Currency: "try",
Amount: amount,
AmountUSD: amountUSD,
Status: "SUCCESS",
Factory: legacyPurchaseFactory{Platform: "Android", FactoryCode: "GOOGLE"},
Products: []legacyPurchaseProduct{
{Name: "GOLD", Content: "560000"},
{Name: "PROPS", Content: "AVATAR_FRAME"},
},
CreateTime: createTime,
})
if dto.AppCode != "yumi" || dto.TransactionID != "175100000000012345" {
t.Fatalf("unexpected identity: %+v", dto)
}
if dto.RechargeType != "google_play_recharge" || dto.ProviderCode != "google" {
t.Fatalf("unexpected channel mapping: %+v", dto)
}
if dto.Status != "succeeded" || dto.ExternalRef != "GPA.1234-5678-9012-34567" {
t.Fatalf("unexpected status/external ref: %+v", dto)
}
if dto.CoinAmount != 560000 {
t.Fatalf("coin amount = %d, want 560000", dto.CoinAmount)
}
if dto.USDMinorAmount != 299 {
t.Fatalf("usd minor = %d, want 299", dto.USDMinorAmount)
}
if dto.UserPaidCurrencyCode != "TRY" || dto.UserPaidAmountMicro != 164_990_000 {
t.Fatalf("unexpected user paid: %+v", dto)
}
// 谷歌账单初始必须是未同步0等 Orders API 实付缓存回填后才算同步完成。
if dto.CreatedAtMS != createTime.UnixMilli() || dto.PaidSyncedAtMS != 0 {
t.Fatalf("unexpected timestamps: %+v", dto)
}
mifapayDTO := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{
ID: "175100000000054321",
Factory: legacyPurchaseFactory{Platform: "H5", FactoryCode: "MIFA_PAY"},
CreateTime: createTime,
})
if mifapayDTO.RechargeType != "mifapay" || mifapayDTO.PaidSyncedAtMS != mifapayDTO.CreatedAtMS {
t.Fatalf("unexpected mifapay mapping: %+v", mifapayDTO)
}
}
func TestLegacyRechargeTypeMapping(t *testing.T) {
cases := map[string]string{
"GOOGLE": "google_play_recharge",
"APPLE": "apple_recharge",
"HUAWEI": "huawei_recharge",
"TELEGRAM": "telegram_recharge",
"PAYER_MAX": "web_recharge",
"": "web_recharge",
}
for factoryCode, want := range cases {
if got := legacyRechargeType(factoryCode); got != want {
t.Fatalf("legacyRechargeType(%q) = %q, want %q", factoryCode, got, want)
}
}
}
func TestLegacyDecimalScaledHandlesTypes(t *testing.T) {
decimal, err := bson.ParseDecimal128("6.99")
if err != nil {
t.Fatalf("parse decimal: %v", err)
}
if got := legacyDecimalToMinor(decimal); got != 699 {
t.Fatalf("decimal minor = %d, want 699", got)
}
if got := legacyDecimalToMicro("54.99"); got != 54_990_000 {
t.Fatalf("string micro = %d, want 54990000", got)
}
if got := legacyDecimalToMinor(int64(3)); got != 300 {
t.Fatalf("int64 minor = %d, want 300", got)
}
if got := legacyDecimalToMinor(nil); got != 0 {
t.Fatalf("nil minor = %d, want 0", got)
}
if got := legacyDecimalToMinor("not-a-number"); got != 0 {
t.Fatalf("invalid minor = %d, want 0", got)
}
}
type staticLegacyRegionResolver struct {
codes []string
handled bool
}
func (r staticLegacyRegionResolver) CountryCodesForRegion(context.Context, string, int64) ([]string, bool, error) {
return r.codes, r.handled, nil
}
func TestLegacyBillFilterBuildsQuery(t *testing.T) {
source := &MongoRechargeBillSource{
config: yumiBillSourceConfig(),
regionResolvers: []legacyRegionCountryResolver{staticLegacyRegionResolver{codes: []string{"tr", "SA"}, handled: true}},
}
filter, regionCodes, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{
Keyword: "GPA.1",
RegionID: 9_000_000_000_000_000_001,
StartAtMS: 1_000,
EndAtMS: 2_000,
})
if err != nil || !matchable {
t.Fatalf("billFilter err=%v matchable=%v", err, matchable)
}
if filter["sysOrigin"] != "LIKEI" || filter["status"] != "SUCCESS" {
t.Fatalf("unexpected base filter: %v", filter)
}
timeRange, ok := filter["createTime"].(bson.M)
if !ok || !timeRange["$gte"].(time.Time).Equal(time.UnixMilli(1_000)) || !timeRange["$lt"].(time.Time).Equal(time.UnixMilli(2_000)) {
t.Fatalf("unexpected time range: %v", filter["createTime"])
}
// likei 账单不带国家码;区域条件必须以国家码清单返回,由聚合阶段按付款用户资料匹配。
if len(regionCodes) != 2 || regionCodes[0] != "TR" || regionCodes[1] != "SA" {
t.Fatalf("unexpected region codes: %v", regionCodes)
}
if _, ok := filter["countryCode"]; ok {
t.Fatalf("bill countryCode filter should not be used: %v", filter)
}
if _, ok := filter["$or"]; !ok {
t.Fatalf("keyword filter missing: %v", filter)
}
}
func TestLegacyBillFilterExcludesFreightGoldRecharge(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
filter, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RechargeType: "third_party"})
if err != nil || !matchable {
t.Fatalf("billFilter err=%v matchable=%v", err, matchable)
}
nor, ok := filter["$nor"].(bson.A)
if !ok || len(nor) != len(legacyFreightCommodityFields) {
t.Fatalf("freight exclusion missing: %v", filter)
}
for _, condition := range nor {
item, ok := condition.(bson.M)
if !ok {
t.Fatalf("unexpected freight condition: %#v", condition)
}
for _, value := range item {
typed, ok := value.(bson.M)
if !ok || typed["$in"] == nil {
t.Fatalf("unexpected freight condition value: %#v", value)
}
}
}
}
func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
_, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RegionID: 123})
if err != nil {
t.Fatalf("billFilter err=%v", err)
}
if matchable {
t.Fatalf("unresolved region should not be matchable")
}
}
type fakeDashboardRechargeSource struct {
appCode string
overview map[string]any
query dashboard.StatisticsQuery
calls int
bills []dashboard.CoinSellerRechargeBill
billTotal int64
billQuery dashboard.CoinSellerRechargeBillQuery
billCalls int
}
func (s *fakeDashboardRechargeSource) AppCode() string {
return s.appCode
}
func (s *fakeDashboardRechargeSource) StatisticsOverview(_ context.Context, query dashboard.StatisticsQuery) (map[string]any, error) {
s.query = query
s.calls++
return s.overview, nil
}
func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Context, query dashboard.CoinSellerRechargeBillQuery) ([]dashboard.CoinSellerRechargeBill, int64, error) {
s.billQuery = query
s.billCalls++
return s.bills, s.billTotal, nil
}
func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "aslan",
billTotal: 1,
bills: []dashboard.CoinSellerRechargeBill{{
TransactionID: "aslan-freight-purchase-99",
Source: "freight_purchase",
UserID: 2069828597070528514,
CountryCode: "PH",
USDMinorAmount: 132790,
CreatedAtMS: 1_783_530_000_000,
}},
}
source := &MongoRechargeBillSource{
config: aslanBillSourceConfig(),
regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{
AppCode: "aslan",
RegionID: 301,
Name: "Philippines",
Countries: []string{"PH"},
}}}},
}
source.SetDashboardCoinSellerSource(dashboardSource)
items, total, err := source.ListRechargeBills(context.Background(), legacyRechargeBillQuery{
RechargeType: "coin_seller",
StartAtMS: 1_783_468_800_000,
EndAtMS: 1_786_060_800_000,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListRechargeBills: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
item := items[0]
if item.RechargeType != "coin_seller_stock_purchase" || item.ProviderCode != "coin_seller" {
t.Fatalf("unexpected coin seller mapping: %+v", item)
}
if item.TransactionID != "aslan-freight-purchase-99" || item.SellerUserID != 2069828597070528514 || item.USDMinorAmount != 132790 {
t.Fatalf("unexpected bill fields: %+v", item)
}
if item.SellerRegionID != 301 || item.TargetRegionID != 301 {
t.Fatalf("region mapping failed: %+v", item)
}
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
t.Fatalf("paid facts mismatch: %+v", item)
}
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" {
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
}
}
func TestLegacyCoinSellerDashboardStatsMergeSummary(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "Yumi",
overview: map[string]any{
"coin_seller_recharge_usd_minor": int64(350_010),
},
}
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
summary := rechargeBillSummaryDTO{
GooglePlay: rechargeBillSummaryBucketDTO{BillCount: 2, USDMinorAmount: 10_000},
ThirdParty: rechargeBillSummaryBucketDTO{BillCount: 3, USDMinorAmount: 20_000},
}
if err := source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.AddDate(0, 0, 1).UnixMilli(),
}, 480, &summary); err != nil {
t.Fatalf("mergeCoinSellerSummary: %v", err)
}
legacyRecomputeSummaryTotal(&summary)
if summary.CoinSeller.USDMinorAmount != 350_010 {
t.Fatalf("coin seller usd = %d, want 350010", summary.CoinSeller.USDMinorAmount)
}
if summary.Total.USDMinorAmount != 380_010 || summary.Total.BillCount != 5 {
t.Fatalf("unexpected total after coin seller merge: %+v", summary.Total)
}
if dashboardSource.calls != 1 || dashboardSource.query.StatTZ != "Asia/Shanghai" {
t.Fatalf("dashboard query mismatch: calls=%d query=%+v", dashboardSource.calls, dashboardSource.query)
}
}
func TestLegacyCoinSellerDashboardStatsRejectsPartialDayRange(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "yumi",
overview: map[string]any{"coin_seller_recharge_usd_minor": int64(100)},
}
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 10, 0, 0, 0, shanghai)
summary := rechargeBillSummaryDTO{}
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.Add(2 * time.Hour).UnixMilli(),
}, 480, &summary)
if err == nil {
t.Fatalf("partial day range should fail")
}
if dashboardSource.calls != 0 {
t.Fatalf("dashboard source should not be called for partial day range")
}
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{}, 480, &summary)
if err == nil {
t.Fatalf("missing day range should fail")
}
}
func TestLegacyCoinSellerDashboardStatsRequiresMetricField(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "yumi",
overview: map[string]any{"coin_seller_recharge_usd_minor": nil},
}
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
summary := rechargeBillSummaryDTO{}
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.AddDate(0, 0, 1).UnixMilli(),
}, 480, &summary)
if err == nil {
t.Fatalf("missing dashboard coin seller field should fail")
}
if dashboardSource.calls != 1 {
t.Fatalf("dashboard source calls = %d, want 1", dashboardSource.calls)
}
}
func TestLegacyCoinSellerDashboardStatsMergeOverview(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "yumi",
overview: map[string]any{
"coin_seller_recharge_usd_minor": int64(5_000),
"daily_series": []map[string]any{
{"stat_day": "2026-07-05", "coin_seller_recharge_usd_minor": int64(4_200)},
{"stat_day": "2026-07-06", "coin_seller_recharge_usd_minor": int64(800)},
},
"country_breakdown": []map[string]any{
{"country_code": "SA", "coin_seller_recharge_usd_minor": int64(4_200)},
{"country_code": "AE", "coin_seller_recharge_usd_minor": int64(800)},
},
},
}
source := &MongoRechargeBillSource{
config: yumiBillSourceConfig(),
regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{
AppCode: "yumi",
RegionID: 101,
Name: "Gulf",
Countries: []string{"SA", "AE"},
}}}},
}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
overview := rechargeBillOverviewDTO{
Daily: []rechargeBillDailyBucketDTO{{Date: "2026-07-05", ThirdPartyUsdMinor: 2_000}},
Regions: []rechargeBillRegionBucketDTO{{
RegionID: 101,
Name: "Gulf",
UsdMinorAmount: 2_000,
}},
}
if err := source.mergeCoinSellerOverview(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.AddDate(0, 0, 2).UnixMilli(),
}, 480, &overview); err != nil {
t.Fatalf("mergeCoinSellerOverview: %v", err)
}
if len(overview.Daily) != 2 {
t.Fatalf("daily buckets = %d, want 2: %+v", len(overview.Daily), overview.Daily)
}
if overview.Daily[0].Date != "2026-07-05" || overview.Daily[0].CoinSellerUsdMinor != 4_200 {
t.Fatalf("existing day not merged: %+v", overview.Daily)
}
if overview.Daily[1].Date != "2026-07-06" || overview.Daily[1].CoinSellerUsdMinor != 800 {
t.Fatalf("new day not appended: %+v", overview.Daily)
}
if len(overview.Regions) != 1 || overview.Regions[0].UsdMinorAmount != 7_000 {
t.Fatalf("region not merged: %+v", overview.Regions)
}
}
func TestLegacyRegionLookupStages(t *testing.T) {
if stages := legacyRegionLookupStages(nil); len(stages) != 0 {
t.Fatalf("empty codes should not add stages: %v", stages)
}
stages := legacyRegionLookupStages([]string{"TR", "SA"})
if len(stages) != 3 {
t.Fatalf("expected lookup/addFields/match stages, got %d", len(stages))
}
lookup, ok := stages[0][0].Value.(bson.M)
if !ok || lookup["from"] != legacyUserProfileCollection || lookup["localField"] != "acceptUserId" || lookup["foreignField"] != "_id" {
t.Fatalf("unexpected lookup stage: %v", stages[0])
}
match, ok := stages[2][0].Value.(bson.M)
if !ok {
t.Fatalf("unexpected match stage: %v", stages[2])
}
condition, ok := match["legacyBillCountry"].(bson.M)
if !ok {
t.Fatalf("match stage missing country condition: %v", match)
}
codes, ok := condition["$in"].([]string)
if !ok || len(codes) != 2 || codes[0] != "TR" || codes[1] != "SA" {
t.Fatalf("unexpected region codes in match: %v", condition["$in"])
}
}

View File

@ -110,7 +110,7 @@ func (s *MongoMoneyRegionSource) CountryBelongsToRegion(ctx context.Context, app
if err != nil {
return false, false, err
}
if documentRegionID != regionID {
if !repository.RegionIDLooseEqual(documentRegionID, regionID) {
continue
}
for _, item := range splitLegacyCountryCodes(document.CountryCodes) {
@ -148,7 +148,7 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
if err != nil {
return nil, false, err
}
if documentRegionID != regionID {
if !repository.RegionIDLooseEqual(documentRegionID, regionID) {
continue
}
// 大屏外接源只认 legacy country_code这里集中复用财务范围的 Mongo 区域口径,避免每个外接 App 自己维护一套区域映射。
@ -160,6 +160,40 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
return nil, false, nil
}
// RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态;
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
type RegionCatalogEntry struct {
AppCode string
AppName string
RegionID int64
RegionCode string
RegionName string
Countries []string
}
func (s *MongoMoneyRegionSource) RegionCatalog(ctx context.Context) ([]RegionCatalogEntry, error) {
if s == nil || s.collection == nil {
return nil, nil
}
_, regions, _, err := s.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return nil, err
}
appName := strings.TrimSpace(s.config.AppName)
entries := make([]RegionCatalogEntry, 0, len(regions))
for _, region := range regions {
entries = append(entries, RegionCatalogEntry{
AppCode: region.AppCode,
AppName: appName,
RegionID: region.RegionID,
RegionCode: region.RegionCode,
RegionName: region.Name,
Countries: region.Countries,
})
}
return entries, nil
}
func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfig, documents []legacyRegionDocument, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
appCode := appctx.Normalize(sourceConfig.AppCode)
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {

View File

@ -0,0 +1,275 @@
package payment
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"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"
)
const (
rechargeBillExportPageSize = 500
// rechargeBillExportMaxRows 限制单次导出行数,避免财务误选超大时间范围拖垮 admin 与 wallet-service。
rechargeBillExportMaxRows = 50_000
)
// rechargeBillExportTimezone 与页面展示一致,导出统一使用中国时区时间。
var rechargeBillExportTimezone = time.FixedZone("GMT+8", 8*3600)
// ExportRechargeBills 按当前筛选口径导出充值明细 CSVhyapp 与 legacy 账单源共用同一套列。
func (h *Handler) ExportRechargeBills(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
bills, ok := h.collectRechargeBillsForExport(c, appCode)
if !ok {
return
}
truncated := len(bills) >= rechargeBillExportMaxRows
regionNames := map[int64]string{}
if regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode); err == nil {
for _, region := range regions {
name := region.Name
if name == "" {
name = region.RegionCode
}
regionNames[region.RegionID] = name
}
}
buffer := &bytes.Buffer{}
// UTF-8 BOM 让 Excel 直接双击打开时正确识别中文。
buffer.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(buffer)
_ = writer.Write([]string{
"充值时间", "APP", "充值来源", "交易号", "业务单号", "外部订单号",
"金币数量", "美金金额(USD)", "账单币种", "账单金额",
"实付币种", "实付金额", "手续费", "税费", "净收入", "扣款占比",
"充值用户ID", "充值用户昵称", "币商用户ID", "区域", "状态",
})
for _, item := range bills {
_ = writer.Write(rechargeBillExportRow(item, regionNames))
}
if truncated {
_ = writer.Write([]string{fmt.Sprintf("-- 导出达到上限 %d 行,请缩小时间范围后重新导出 --", rechargeBillExportMaxRows)})
}
writer.Flush()
if err := writer.Error(); err != nil {
response.ServerError(c, "生成导出文件失败")
return
}
fileName := fmt.Sprintf("recharge-bills-%s-%s.csv", appCode, time.Now().In(rechargeBillExportTimezone).Format("20060102-150405"))
shared.OperationLog(c, h.audit, "export-recharge-bills", "wallet_recharge_records", "success", fmt.Sprintf("%s:%d rows", appCode, len(bills)))
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Writer.WriteHeader(200)
_, _ = c.Writer.Write(buffer.Bytes())
}
// collectRechargeBillsForExport 复用列表筛选逐页拉取账单;返回 ok=false 表示已写出错误响应。
func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) ([]rechargeBillDTO, bool) {
options := shared.ListOptions(c)
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// legacy 币商充值导出必须和页面列表使用同一个自然日解释方式。
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
if source, ok := h.billSources[appCode]; ok {
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
Page: page,
PageSize: rechargeBillExportPageSize,
})
if err != nil {
response.ServerError(c, "导出账单失败")
return nil, false
}
bills = append(bills, items...)
if len(items) < rechargeBillExportPageSize || int64(len(bills)) >= total {
break
}
}
return capRechargeBillExportRows(bills), true
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return nil, false
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return nil, false
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
return []rechargeBillDTO{}, true
}
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
Page: int32(page),
PageSize: rechargeBillExportPageSize,
})
if err != nil {
writeWalletError(c, err, "导出账单失败")
return nil, false
}
items := make([]rechargeBillDTO, 0, len(resp.GetBills()))
for _, item := range resp.GetBills() {
items = append(items, rechargeBillFromProto(item))
}
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "导出账单用户资料失败")
return nil, false
}
bills = append(bills, items...)
if len(items) < rechargeBillExportPageSize || int64(len(bills)) >= resp.GetTotal() {
break
}
}
return capRechargeBillExportRows(bills), true
}
func capRechargeBillExportRows(bills []rechargeBillDTO) []rechargeBillDTO {
if len(bills) > rechargeBillExportMaxRows {
return bills[:rechargeBillExportMaxRows]
}
return bills
}
func rechargeBillExportRow(item rechargeBillDTO, regionNames map[int64]string) []string {
deductionMicro := item.ProviderFeeMicro + item.ProviderTaxMicro
deductionRatio := ""
if deductionMicro > 0 && item.UserPaidAmountMicro > 0 {
deductionRatio = fmt.Sprintf("%.2f%%", float64(deductionMicro)/float64(item.UserPaidAmountMicro)*100)
}
billAmountMinor := item.USDMinorAmount
if item.ProviderAmountMinor > 0 {
billAmountMinor = item.ProviderAmountMinor
}
chargeUserID := ""
if item.User.UserID != "" {
chargeUserID = item.User.UserID
} else if item.UserID > 0 {
chargeUserID = strconv.FormatInt(item.UserID, 10)
}
sellerUserID := ""
if item.SellerUserID > 0 {
sellerUserID = strconv.FormatInt(item.SellerUserID, 10)
}
regionID := item.TargetRegionID
if regionID == 0 {
regionID = item.SellerRegionID
}
regionText := ""
if regionID > 0 {
if name, ok := regionNames[regionID]; ok && name != "" {
regionText = name
} else {
regionText = strconv.FormatInt(regionID, 10)
}
}
return []string{
time.UnixMilli(item.CreatedAtMS).In(rechargeBillExportTimezone).Format("2006-01-02 15:04:05"),
item.AppCode,
rechargeSourceLabel(item.RechargeType),
item.TransactionID,
item.CommandID,
item.ExternalRef,
strconv.FormatInt(item.CoinAmount, 10),
csvMinorAmount(item.USDMinorAmount),
item.CurrencyCode,
csvMinorAmount(billAmountMinor),
item.UserPaidCurrencyCode,
csvMicroAmount(item.UserPaidAmountMicro),
csvMicroAmount(item.ProviderFeeMicro),
csvMicroAmount(item.ProviderTaxMicro),
csvMicroAmount(item.ProviderNetMicro),
deductionRatio,
chargeUserID,
item.User.Username,
sellerUserID,
regionText,
item.Status,
}
}
// rechargeSourceLabel 与前端 rechargeTypeLabel 保持同一套中文口径,导出文件可直接给财务阅读。
func rechargeSourceLabel(rechargeType string) string {
switch rechargeType {
case "google_play_recharge":
return "谷歌充值"
case "coin_seller_stock_purchase":
return "币商进货"
case "coin_seller_stock_deduction":
return "币商冲回"
case "coin_seller_transfer":
return "币商充值"
case "mifapay":
return "三方充值-MiFaPay"
case "v5pay":
return "三方充值-V5Pay"
case "usdt_trc20":
return "三方充值-USDT"
case "apple_recharge":
return "苹果充值"
case "huawei_recharge":
return "华为充值"
case "telegram_recharge":
return "Telegram充值"
case "web_recharge":
return "三方充值-Web"
default:
return rechargeType
}
}
func csvMinorAmount(minor int64) string {
return strconv.FormatFloat(float64(minor)/100, 'f', 2, 64)
}
func csvMicroAmount(micro int64) string {
if micro == 0 {
return "0"
}
negative := micro < 0
if negative {
micro = -micro
}
text := formatUSDTMicro(micro)
if negative {
return "-" + text
}
return text
}

View File

@ -0,0 +1,179 @@
package payment
import (
"strings"
"hyapp-admin-server/internal/appctx"
"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 rechargeBillDailyBucketDTO struct {
Date string `json:"date"`
GoogleUsdMinor int64 `json:"googleUsdMinor"`
ThirdPartyUsdMinor int64 `json:"thirdPartyUsdMinor"`
CoinSellerUsdMinor int64 `json:"coinSellerUsdMinor"`
GoogleCoinAmount int64 `json:"googleCoinAmount"`
ThirdPartyCoinAmount int64 `json:"thirdPartyCoinAmount"`
CoinSellerCoinAmount int64 `json:"coinSellerCoinAmount"`
}
type rechargeBillRegionBucketDTO struct {
RegionID int64 `json:"regionId"`
Name string `json:"name"`
BillCount int64 `json:"billCount"`
UsdMinorAmount int64 `json:"usdMinorAmount"`
}
type rechargeBillGooglePaidStatsDTO struct {
GoogleBillCount int64 `json:"googleBillCount"`
SyncedCount int64 `json:"syncedCount"`
UnsyncedCount int64 `json:"unsyncedCount"`
CoveredUsdMinor int64 `json:"coveredUsdMinor"`
EstFeeUsdMinor int64 `json:"estFeeUsdMinor"`
EstTaxUsdMinor int64 `json:"estTaxUsdMinor"`
EstNetUsdMinor int64 `json:"estNetUsdMinor"`
}
// rechargeBillWithdrawalStatsDTO 是“用户提现”合并口径:工资转币商(钱包账本)+ 审核通过的提现申请admin 库)。
type rechargeBillWithdrawalStatsDTO struct {
TransferCount int64 `json:"transferCount"`
TransferUsdMinor int64 `json:"transferUsdMinor"`
ApprovedCount int64 `json:"approvedCount"`
ApprovedUsdMinor int64 `json:"approvedUsdMinor"`
TotalUsdMinor int64 `json:"totalUsdMinor"`
}
type rechargeBillOverviewDTO struct {
Daily []rechargeBillDailyBucketDTO `json:"daily"`
Regions []rechargeBillRegionBucketDTO `json:"regions"`
GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"`
Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"`
}
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// 财务系统统一按中国时区切日边界。
tzOffsetMinutes = 480
}
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
if source, ok := h.billSources[appCode]; ok {
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
return
}
overview, err := source.Overview(c.Request.Context(), query, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
return
}
resp, err := h.wallet.GetRechargeBillOverview(c.Request.Context(), &walletv1.GetRechargeBillOverviewRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
StartAtMs: startAtMS,
EndAtMs: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
})
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
}
overview := rechargeBillOverviewDTO{
Daily: make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily())),
Regions: make([]rechargeBillRegionBucketDTO, 0, len(resp.GetRegions())),
}
for _, bucket := range resp.GetDaily() {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
Date: bucket.GetDate(),
GoogleUsdMinor: bucket.GetGoogleUsdMinor(),
ThirdPartyUsdMinor: bucket.GetThirdPartyUsdMinor(),
CoinSellerUsdMinor: bucket.GetCoinSellerUsdMinor(),
GoogleCoinAmount: bucket.GetGoogleCoinAmount(),
ThirdPartyCoinAmount: bucket.GetThirdPartyCoinAmount(),
CoinSellerCoinAmount: bucket.GetCoinSellerCoinAmount(),
})
}
// 区域名从后台区域目录补齐;目录缺失时回退区域 ID 文本,避免概览页出现空白区块。
regionNames := map[int64]string{}
if regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode); err == nil {
for _, region := range regions {
name := region.Name
if name == "" {
name = region.RegionCode
}
regionNames[region.RegionID] = name
}
}
for _, bucket := range resp.GetRegions() {
overview.Regions = append(overview.Regions, rechargeBillRegionBucketDTO{
RegionID: bucket.GetRegionId(),
Name: regionNames[bucket.GetRegionId()],
BillCount: bucket.GetBillCount(),
UsdMinorAmount: bucket.GetUsdMinorAmount(),
})
}
if stats := resp.GetGooglePaid(); stats != nil {
overview.GooglePaid = rechargeBillGooglePaidStatsDTO{
GoogleBillCount: stats.GetGoogleBillCount(),
SyncedCount: stats.GetSyncedCount(),
UnsyncedCount: stats.GetUnsyncedCount(),
CoveredUsdMinor: stats.GetCoveredUsdMinor(),
EstFeeUsdMinor: stats.GetEstFeeUsdMinor(),
EstTaxUsdMinor: stats.GetEstTaxUsdMinor(),
EstNetUsdMinor: stats.GetEstNetUsdMinor(),
}
}
if transfer := resp.GetSalaryTransfer(); transfer != nil {
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
}
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
return true
}
if _, _, _, err := legacyDashboardDayRange(query, tzOffsetMinutes); err != nil {
response.BadRequest(c, "币商统计仅支持明确整日时间范围")
return false
}
return true
}
// fillApprovedWithdrawalStats 把 admin 库的“审核通过提现申请”并入用户提现口径;查询失败只降级该卡片,不阻断概览。
func (h *Handler) fillApprovedWithdrawalStats(overview *rechargeBillOverviewDTO, appCode string, startAtMS int64, endAtMS int64) {
if h.store != nil {
if stats, err := h.store.ApprovedWithdrawalStats(appCode, startAtMS, endAtMS); err == nil {
overview.Withdrawal.ApprovedCount = stats.ApprovedCount
overview.Withdrawal.ApprovedUsdMinor = stats.ApprovedUSDMinor
}
}
overview.Withdrawal.TotalUsdMinor = overview.Withdrawal.TransferUsdMinor + overview.Withdrawal.ApprovedUsdMinor
}

View File

@ -0,0 +1,313 @@
package payment
import (
"context"
"sort"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type rechargeBillSummaryBucketDTO struct {
BillCount int64 `json:"billCount"`
CoinAmount int64 `json:"coinAmount"`
USDMinorAmount int64 `json:"usdMinorAmount"`
}
type rechargeBillSummaryDTO struct {
Total rechargeBillSummaryBucketDTO `json:"total"`
GooglePlay rechargeBillSummaryBucketDTO `json:"googlePlay"`
ThirdParty rechargeBillSummaryBucketDTO `json:"thirdParty"`
CoinSeller rechargeBillSummaryBucketDTO `json:"coinSeller"`
}
type rechargeBillRegionDTO struct {
RegionID int64 `json:"regionId"`
RegionCode string `json:"regionCode"`
Name string `json:"name"`
}
type googleRechargePaidRefreshRequest struct {
TransactionIDs []string `json:"transactionIds"`
}
type googleRechargePaidDTO struct {
TransactionID string `json:"transactionId"`
CurrencyCode string `json:"currencyCode"`
PaidAmountMicro int64 `json:"paidAmountMicro"`
TaxMicro int64 `json:"taxMicro"`
NetMicro int64 `json:"netMicro"`
SyncedAtMS int64 `json:"syncedAtMs"`
Error string `json:"error,omitempty"`
}
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
options := shared.ListOptions(c)
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// legacy 币商充值列表可能来自 dashboard 自然日聚合,默认沿用财务页中国时区。
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
Page: options.Page,
PageSize: options.PageSize,
})
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
// GetRechargeBillSummary 返回与账单列表同一筛选口径的充值总和、Google 充值与三方充值聚合。
func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// legacy finance 的币商补充来自 dashboard 自然日聚合,默认沿用财务页中国时区。
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
if source, ok := h.billSources[appCode]; ok {
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
}
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
return
}
summary, err := source.SummarizeRechargeBills(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取充值汇总失败")
return
}
response.OK(c, summary)
return
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
response.OK(c, rechargeBillSummaryDTO{})
return
}
resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
SellerUserId: 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"),
})
if err != nil {
writeWalletError(c, err, "获取充值汇总失败")
return
}
response.OK(c, rechargeBillSummaryDTO{
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()),
})
}
func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBucket) rechargeBillSummaryBucketDTO {
if bucket == nil {
return rechargeBillSummaryBucketDTO{}
}
return rechargeBillSummaryBucketDTO{
BillCount: bucket.GetBillCount(),
CoinAmount: bucket.GetCoinAmount(),
USDMinorAmount: bucket.GetUsdMinorAmount(),
}
}
// RefreshGoogleRechargePaidDetails 对指定账单触发 Google Orders API 实付明细同步,供财务补齐历史订单实付。
func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
var request googleRechargePaidRefreshRequest
if err := c.ShouldBindJSON(&request); err != nil || len(request.TransactionIDs) == 0 {
response.BadRequest(c, "交易号参数不正确")
return
}
if len(request.TransactionIDs) > 100 {
response.BadRequest(c, "单次最多刷新 100 笔账单")
return
}
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
if !source.SupportsGooglePaidSync() {
response.BadRequest(c, "该 App 未配置 Google Play 包名和服务账号,无法同步谷歌实付")
return
}
results, err := source.RefreshGooglePaidDetails(c.Request.Context(), request.TransactionIDs)
if err != nil {
response.ServerError(c, "查询谷歌实付明细失败")
return
}
shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "admin_legacy_google_paid_details", "success", appCode)
response.OK(c, gin.H{"items": results})
return
}
resp, err := h.wallet.RefreshGooglePaymentPrices(c.Request.Context(), &walletv1.RefreshGooglePaymentPricesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
TransactionIds: request.TransactionIDs,
})
if err != nil {
writeWalletError(c, err, "查询谷歌实付明细失败")
return
}
items := make([]googleRechargePaidDTO, 0, len(resp.GetPrices()))
for _, price := range resp.GetPrices() {
items = append(items, googleRechargePaidDTO{
TransactionID: price.GetTransactionId(),
CurrencyCode: price.GetCurrencyCode(),
PaidAmountMicro: price.GetPaidAmountMicro(),
TaxMicro: price.GetTaxMicro(),
NetMicro: price.GetNetMicro(),
SyncedAtMS: price.GetSyncedAtMs(),
Error: price.GetError(),
})
}
shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "payment_orders", "success", appCode)
response.OK(c, gin.H{"items": items})
}
type rechargeBillAppDTO struct {
AppCode string `json:"appCode"`
AppName string `json:"appName"`
}
// ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。
func (h *Handler) ListRechargeBillApps(c *gin.Context) {
items := []rechargeBillAppDTO{}
seen := map[string]struct{}{}
if h.userDB != nil {
rows, err := h.userDB.QueryContext(c.Request.Context(), `
SELECT app_code, app_name
FROM apps
WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC`)
if err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
defer rows.Close()
for rows.Next() {
var item rechargeBillAppDTO
if err := rows.Scan(&item.AppCode, &item.AppName); err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
item.AppCode = appctx.Normalize(item.AppCode)
seen[item.AppCode] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
}
legacyApps := make([]rechargeBillAppDTO, 0, len(h.billSources))
for _, source := range h.billSources {
appCode := appctx.Normalize(source.AppCode())
if _, ok := seen[appCode]; ok {
continue
}
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName()})
}
sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName })
items = append(items, legacyApps...)
response.OK(c, gin.H{"items": items, "total": len(items)})
}
// ListRechargeBillRegions 返回当前 App 的区域目录,用于充值详情按区域筛选;不受财务范围授权限制。
func (h *Handler) ListRechargeBillRegions(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode)
if err != nil {
response.ServerError(c, "获取区域列表失败")
return
}
response.OK(c, gin.H{"items": regions, "total": len(regions)})
}
func (h *Handler) listRechargeBillRegions(ctx context.Context, appCode string) ([]rechargeBillRegionDTO, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
items := []rechargeBillRegionDTO{}
seen := map[int64]struct{}{}
if h.userDB != nil {
rows, err := h.userDB.QueryContext(ctx, `
SELECT region_id, region_code, name
FROM regions
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, name ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var item rechargeBillRegionDTO
if err := rows.Scan(&item.RegionID, &item.RegionCode, &item.Name); err != nil {
return nil, err
}
seen[item.RegionID] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
// Yumi/Aslan 这类 legacy App 的区域目录在线上 likei Mongo这里只取目录展示账单筛选仍以 wallet 的 target_region_id 为准。
for _, source := range h.moneyRegionSources {
_, sourceRegions, _, err := source.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return nil, err
}
for _, region := range sourceRegions {
if appctx.Normalize(region.AppCode) != appCode {
continue
}
if _, ok := seen[region.RegionID]; ok {
continue
}
seen[region.RegionID] = struct{}{}
items = append(items, rechargeBillRegionDTO{RegionID: region.RegionID, RegionCode: region.RegionCode, Name: region.Name})
}
}
return items, nil
}

View File

@ -12,6 +12,12 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
protected.GET("/admin/payment/recharge-bills/overview", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillOverview)
protected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:view"), h.ExportRechargeBills)
protected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:view"), h.RefreshGoogleRechargePaidDetails)
protected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
protected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)

View File

@ -231,6 +231,53 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.OK(c, resource)
}
func (h *Handler) DeleteResource(c *gin.Context) {
resourceID, ok := parseID(c, "resource_id")
if !ok {
return
}
resp, err := h.wallet.DeleteResource(c.Request.Context(), &walletv1.DeleteResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
resource := resourceFromProto(resp.GetResource())
h.auditLog(c, "delete-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode)
response.OK(c, resource)
}
func (h *Handler) BatchDeleteResources(c *gin.Context) {
var req resourceDeleteBatchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.ResourceIDs) == 0 {
response.BadRequest(c, "资源参数不正确")
return
}
resp, err := h.wallet.BatchDeleteResources(c.Request.Context(), &walletv1.BatchDeleteResourcesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceIds: req.ResourceIDs,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]resourceDTO, 0, len(resp.GetResources()))
deletedIDs := make([]string, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
resource := resourceFromProto(item)
items = append(items, resource)
deletedIDs = append(deletedIDs, fmt.Sprintf("%d", resource.ResourceID))
}
h.auditLog(c, "delete-resources", "resources", "batch", "success", strings.Join(deletedIDs, ","))
response.OK(c, items)
}
func (h *Handler) UpdateMP4ResourceLayouts(c *gin.Context) {
var req resourceMP4LayoutBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -500,6 +547,37 @@ func (h *Handler) CreateGift(c *gin.Context) {
response.Created(c, gift)
}
func (h *Handler) BatchCreateGifts(c *gin.Context) {
var req giftBatchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.Items) == 0 {
response.BadRequest(c, "礼物参数不正确")
return
}
items := make([]*walletv1.CreateGiftConfigRequest, 0, len(req.Items))
for _, item := range req.Items {
items = append(items, item.createProto(c))
}
resp, err := h.wallet.BatchCreateGiftConfigs(c.Request.Context(), &walletv1.BatchCreateGiftConfigsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Items: items,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
gifts := make([]giftDTO, 0, len(resp.GetGifts()))
giftIDs := make([]string, 0, len(resp.GetGifts()))
for _, item := range resp.GetGifts() {
gift := giftFromProto(item)
gifts = append(gifts, gift)
giftIDs = append(giftIDs, gift.GiftID)
}
h.auditLog(c, "create-gifts", "gift_configs", "batch", "success", strings.Join(giftIDs, ","))
response.Created(c, gifts)
}
func (h *Handler) UpdateGift(c *gin.Context) {
giftID := strings.TrimSpace(c.Param("gift_id"))
if giftID == "" {

View File

@ -136,6 +136,74 @@ func TestDeleteGiftOnlyCallsGiftConfigDelete(t *testing.T) {
}
}
func TestDeleteResourceCallsWalletDeleteResource(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
11: {AppCode: "lalu", ResourceId: 11, ResourceCode: "rose_resource", ResourceType: "gift", Name: "Rose"},
}}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/admin/resources/11", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("delete resource status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.deletedResources) != 1 {
t.Fatalf("expected one resource delete request, got %d", len(wallet.deletedResources))
}
deleteReq := wallet.deletedResources[0]
if deleteReq.GetAppCode() != "lalu" || deleteReq.GetResourceId() != 11 || deleteReq.GetOperatorUserId() != 7 {
t.Fatalf("delete resource request mismatch: %+v", deleteReq)
}
}
func TestBatchDeleteResourcesCallsWalletBatchDelete(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
11: {AppCode: "lalu", ResourceId: 11, ResourceCode: "rose_resource", ResourceType: "gift", Name: "Rose"},
12: {AppCode: "lalu", ResourceId: 12, ResourceCode: "ring_resource", ResourceType: "gift", Name: "Ring"},
}}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/resources/batch-delete", strings.NewReader(`{"resourceIds":[11,12]}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("batch delete resources status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.batchDeletedResources) != 1 {
t.Fatalf("expected one batch resource delete request, got %d", len(wallet.batchDeletedResources))
}
deleteReq := wallet.batchDeletedResources[0]
if deleteReq.GetAppCode() != "lalu" || fmt.Sprint(deleteReq.GetResourceIds()) != "[11 12]" || deleteReq.GetOperatorUserId() != 7 {
t.Fatalf("batch delete resource request mismatch: %+v", deleteReq)
}
}
func TestBatchCreateGiftsCallsWalletBatchCreate(t *testing.T) {
wallet := &mockResourceWallet{}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
body := `{"items":[{"giftId":"rose","resourceId":11,"status":"active","name":"Rose","priceVersion":"default","giftTypeCode":"normal","chargeAssetType":"COIN","coinPrice":10,"regionIds":[0]},{"giftId":"ring","resourceId":12,"status":"active","name":"Ring","priceVersion":"default","giftTypeCode":"normal","chargeAssetType":"COIN","coinPrice":20,"regionIds":[0]}]}`
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/gifts/batch", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("batch create gifts status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.batchCreatedGifts) != 1 {
t.Fatalf("expected one batch gift create request, got %d", len(wallet.batchCreatedGifts))
}
createReq := wallet.batchCreatedGifts[0]
if createReq.GetAppCode() != "lalu" || len(createReq.GetItems()) != 2 || createReq.GetItems()[0].GetGiftId() != "rose" || createReq.GetItems()[1].GetGiftId() != "ring" {
t.Fatalf("batch create gift request mismatch: %+v", createReq)
}
}
func TestIdentityAutoGrantConfigRouteDoesNotHitResourceGroupID(t *testing.T) {
router := newResourceHandlerTestRouter(New(&mockResourceWallet{}, nil, nil, time.Second, nil))
@ -215,6 +283,7 @@ func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
"gift:create",
"gift:status",
"resource:create",
"resource:delete",
"resource-grant:create",
"resource-grant:revoke",
"resource-grant:view",
@ -234,10 +303,13 @@ func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
type mockResourceWallet struct {
walletclient.Client
resources map[int64]*walletv1.Resource
updates []*walletv1.UpdateResourceRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest
listGrantRequests []*walletv1.ListResourceGrantsRequest
resources map[int64]*walletv1.Resource
updates []*walletv1.UpdateResourceRequest
deletedResources []*walletv1.DeleteResourceRequest
batchDeletedResources []*walletv1.BatchDeleteResourcesRequest
batchCreatedGifts []*walletv1.BatchCreateGiftConfigsRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest
listGrantRequests []*walletv1.ListResourceGrantsRequest
}
func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
@ -273,6 +345,43 @@ func (m *mockResourceWallet) UpdateResource(ctx context.Context, req *walletv1.U
}}, nil
}
func (m *mockResourceWallet) DeleteResource(ctx context.Context, req *walletv1.DeleteResourceRequest) (*walletv1.ResourceResponse, error) {
m.deletedResources = append(m.deletedResources, req)
resource := m.resources[req.GetResourceId()]
if resource == nil {
return nil, fmt.Errorf("resource not found")
}
return &walletv1.ResourceResponse{Resource: resource}, nil
}
func (m *mockResourceWallet) BatchDeleteResources(ctx context.Context, req *walletv1.BatchDeleteResourcesRequest) (*walletv1.BatchDeleteResourcesResponse, error) {
m.batchDeletedResources = append(m.batchDeletedResources, req)
resp := &walletv1.BatchDeleteResourcesResponse{Resources: make([]*walletv1.Resource, 0, len(req.GetResourceIds()))}
for _, resourceID := range req.GetResourceIds() {
resource := m.resources[resourceID]
if resource == nil {
return nil, fmt.Errorf("resource not found")
}
resp.Resources = append(resp.Resources, resource)
}
return resp, nil
}
func (m *mockResourceWallet) BatchCreateGiftConfigs(ctx context.Context, req *walletv1.BatchCreateGiftConfigsRequest) (*walletv1.BatchCreateGiftConfigsResponse, error) {
m.batchCreatedGifts = append(m.batchCreatedGifts, req)
resp := &walletv1.BatchCreateGiftConfigsResponse{Gifts: make([]*walletv1.GiftConfig, 0, len(req.GetItems()))}
for _, item := range req.GetItems() {
resp.Gifts = append(resp.Gifts, &walletv1.GiftConfig{
AppCode: item.GetAppCode(),
GiftId: item.GetGiftId(),
ResourceId: item.GetResourceId(),
Status: item.GetStatus(),
Name: item.GetName(),
})
}
return resp, nil
}
func (m *mockResourceWallet) DeleteGiftConfig(ctx context.Context, req *walletv1.DeleteGiftConfigRequest) (*walletv1.GiftConfigResponse, error) {
m.deletedGifts = append(m.deletedGifts, req)
return &walletv1.GiftConfigResponse{Gift: &walletv1.GiftConfig{

View File

@ -89,6 +89,10 @@ type resourceMP4LayoutBatchRequest struct {
Items []resourceMP4LayoutUpdateRequest `json:"items"`
}
type resourceDeleteBatchRequest struct {
ResourceIDs []int64 `json:"resourceIds"`
}
type resourceMP4LayoutUpdateRequest struct {
ResourceID int64 `json:"resourceId"`
MetadataJSON string `json:"metadataJson"`
@ -191,6 +195,10 @@ type giftRequest struct {
RegionIDs []int64 `json:"regionIds"`
}
type giftBatchRequest struct {
Items []giftRequest `json:"items"`
}
type giftTypeRequest struct {
DisplayName string `json:"displayName"`
TabName string `json:"tabName"`

View File

@ -14,8 +14,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/resources", middleware.RequirePermission("resource:view"), h.ListResources)
protected.POST("/admin/resources", middleware.RequirePermission("resource:create"), h.CreateResource)
protected.PUT("/admin/resources/mp4-layouts/batch", middleware.RequirePermission("resource:update"), h.UpdateMP4ResourceLayouts)
protected.POST("/admin/resources/batch-delete", middleware.RequirePermission("resource:delete"), h.BatchDeleteResources)
protected.GET("/admin/resources/:resource_id", middleware.RequirePermission("resource:view"), h.GetResource)
protected.PUT("/admin/resources/:resource_id", middleware.RequirePermission("resource:update"), h.UpdateResource)
protected.DELETE("/admin/resources/:resource_id", middleware.RequirePermission("resource:delete"), h.DeleteResource)
protected.POST("/admin/resources/:resource_id/enable", middleware.RequirePermission("resource:update"), h.EnableResource)
protected.POST("/admin/resources/:resource_id/disable", middleware.RequirePermission("resource:update"), h.DisableResource)
@ -38,6 +40,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PUT("/admin/gift-types", middleware.RequirePermission("gift:update"), h.UpdateGiftTypes)
protected.PUT("/admin/gift-types/:type_code", middleware.RequirePermission("gift:update"), h.UpdateGiftType)
protected.POST("/admin/gifts", middleware.RequirePermission("gift:create"), h.CreateGift)
protected.POST("/admin/gifts/batch", middleware.RequirePermission("gift:create"), h.BatchCreateGifts)
protected.PUT("/admin/gifts/:gift_id", middleware.RequirePermission("gift:update"), h.UpdateGift)
protected.DELETE("/admin/gifts/:gift_id", middleware.RequirePermission("gift:delete"), h.DeleteGift)
protected.POST("/admin/gifts/:gift_id/enable", middleware.RequirePermission("gift:status"), h.EnableGift)

View File

@ -139,13 +139,14 @@ func (h *Handler) UpdateLevels(c *gin.Context) {
input := make([]*walletv1.AdminVipLevelInput, 0, len(req.Levels))
for _, level := range req.Levels {
input = append(input, &walletv1.AdminVipLevelInput{
Level: level.Level,
Name: strings.TrimSpace(level.Name),
Status: strings.TrimSpace(level.Status),
PriceCoin: level.PriceCoin,
DurationMs: level.DurationMS,
RewardResourceGroupId: level.RewardResourceGroupID,
RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount,
Level: level.Level,
Name: strings.TrimSpace(level.Name),
Status: strings.TrimSpace(level.Status),
PriceCoin: level.PriceCoin,
DurationMs: level.DurationMS,
RewardResourceGroupId: level.RewardResourceGroupID,
// VIP 累充门槛已下线;后台旧请求即使携带旧字段,也统一写 0避免旧页面或缓存把门槛重新保存回来。
RequiredRechargeCoinAmount: 0,
SortOrder: level.SortOrder,
})
}
@ -170,16 +171,17 @@ func vipLevelsFromProto(items []*walletv1.VipLevel) []vipLevelDTO {
continue
}
levels = append(levels, vipLevelDTO{
Level: item.GetLevel(),
Name: item.GetName(),
Status: item.GetStatus(),
PriceCoin: item.GetPriceCoin(),
DurationMS: item.GetDurationMs(),
RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(),
RechargeGateRequired: item.GetRechargeGateRequired(),
RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(),
UserRechargeCoinAmount: item.GetUserRechargeCoinAmount(),
Level: item.GetLevel(),
Name: item.GetName(),
Status: item.GetStatus(),
PriceCoin: item.GetPriceCoin(),
DurationMS: item.GetDurationMs(),
RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(),
// 响应仍保留兼容字段,但后台配置页不再展示或依赖累充资格,统一返回无门槛。
RechargeGateRequired: false,
RequiredRechargeCoinAmount: 0,
UserRechargeCoinAmount: 0,
PurchaseLockedReason: item.GetPurchaseLockedReason(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),

View File

@ -0,0 +1,30 @@
package repository
import (
"hyapp-admin-server/internal/model"
)
// ListMoneyScopeOperators 返回所有配置过财务/数据范围的后台用户及其范围;
// 社交 BI 的"运营人员"就是这批人,不再依赖固定 team_id 约定。
func (s *Store) ListMoneyScopeOperators() ([]model.User, map[uint][]model.UserMoneyScope, error) {
var scopes []model.UserMoneyScope
if err := s.db.Order("user_id ASC, app_code ASC, region_id ASC").Find(&scopes).Error; err != nil {
return nil, nil, err
}
scopesByUser := map[uint][]model.UserMoneyScope{}
userIDs := make([]uint, 0, len(scopes))
for _, scope := range scopes {
if _, ok := scopesByUser[scope.UserID]; !ok {
userIDs = append(userIDs, scope.UserID)
}
scopesByUser[scope.UserID] = append(scopesByUser[scope.UserID], scope)
}
if len(userIDs) == 0 {
return []model.User{}, scopesByUser, nil
}
var users []model.User
if err := s.db.Where("id IN ?", userIDs).Order("id ASC").Find(&users).Error; err != nil {
return nil, nil, err
}
return users, scopesByUser, nil
}

View File

@ -0,0 +1,92 @@
package repository
import (
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm/clause"
)
// UpsertLegacyGooglePaidDetail 写入或覆盖 legacy 谷歌账单实付明细;同一账单重复同步以最新结果为准。
func (s *Store) UpsertLegacyGooglePaidDetail(detail model.LegacyGooglePaidDetail) error {
detail.AppCode = strings.ToLower(strings.TrimSpace(detail.AppCode))
detail.TransactionID = strings.TrimSpace(detail.TransactionID)
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_code"}, {Name: "transaction_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"provider_order_id", "paid_currency_code", "paid_amount_micro",
"paid_tax_micro", "paid_net_micro", "bill_usd_minor", "bill_created_at_ms",
"synced_at_ms", "updated_at_ms",
}),
}).Create(&detail).Error
}
// LegacyGooglePaidStats 是 legacy 谷歌实付缓存在指定账单时间段内的聚合口径。
type LegacyGooglePaidStats struct {
SyncedCount int64
CoveredUSDMinor int64
EstFeeUSDMinor int64
EstTaxUSDMinor int64
EstNetUSDMinor int64
}
// LegacyGooglePaidStatsByBillTime 按账单创建时间段聚合已同步实付:税/费/净收按实付占比折算到账单美金口径。
func (s *Store) LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64, endAtMS int64) (LegacyGooglePaidStats, error) {
where := "app_code = ? AND paid_amount_micro > 0 AND paid_net_micro > 0"
args := []any{strings.ToLower(strings.TrimSpace(appCode))}
if startAtMS > 0 {
where += " AND bill_created_at_ms >= ?"
args = append(args, startAtMS)
}
if endAtMS > 0 {
where += " AND bill_created_at_ms < ?"
args = append(args, endAtMS)
}
var stats LegacyGooglePaidStats
row := s.db.Raw(`
SELECT COUNT(*),
COALESCE(SUM(bill_usd_minor), 0),
COALESCE(SUM(ROUND(bill_usd_minor * GREATEST(paid_amount_micro - paid_net_micro - paid_tax_micro, 0) / paid_amount_micro)), 0),
COALESCE(SUM(ROUND(bill_usd_minor * paid_tax_micro / paid_amount_micro)), 0),
COALESCE(SUM(ROUND(bill_usd_minor * paid_net_micro / paid_amount_micro)), 0)
FROM admin_legacy_google_paid_details
WHERE `+where, args...).Row()
if err := row.Scan(&stats.SyncedCount, &stats.CoveredUSDMinor, &stats.EstFeeUSDMinor, &stats.EstTaxUSDMinor, &stats.EstNetUSDMinor); err != nil {
return LegacyGooglePaidStats{}, err
}
return stats, nil
}
// LegacyGooglePaidTransactionIDs 返回该 App 已同步实付的账单交易号集合,供“未同步”视图在 Mongo 侧做排除。
func (s *Store) LegacyGooglePaidTransactionIDs(appCode string) ([]string, error) {
ids := []string{}
err := s.db.Model(&model.LegacyGooglePaidDetail{}).
Where("app_code = ?", strings.ToLower(strings.TrimSpace(appCode))).
Pluck("transaction_id", &ids).Error
return ids, err
}
// LegacyGooglePaidDetails 按交易号批量读取实付明细,供财务明细列表按页回填。
func (s *Store) LegacyGooglePaidDetails(appCode string, transactionIDs []string) (map[string]model.LegacyGooglePaidDetail, error) {
details := map[string]model.LegacyGooglePaidDetail{}
cleaned := make([]string, 0, len(transactionIDs))
for _, transactionID := range transactionIDs {
if transactionID = strings.TrimSpace(transactionID); transactionID != "" {
cleaned = append(cleaned, transactionID)
}
}
if len(cleaned) == 0 {
return details, nil
}
rows := []model.LegacyGooglePaidDetail{}
if err := s.db.
Where("app_code = ? AND transaction_id IN ?", strings.ToLower(strings.TrimSpace(appCode)), cleaned).
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
details[row.TransactionID] = row
}
return details, nil
}

View File

@ -28,13 +28,27 @@ func (access MoneyAccess) Allows(appCode string, regionID int64) bool {
if appctx.Normalize(scope.AppCode) != appCode {
continue
}
if scope.RegionID == 0 || scope.RegionID == regionID {
if scope.RegionID == 0 || RegionIDLooseEqual(scope.RegionID, regionID) {
return true
}
}
return false
}
// RegionIDLooseEqual 对齐 legacy 大区 ID 的浮点圆整误差:雪花/合成 ID 超出 JS 安全整数,
// 浏览器写回的值是 JS Number 的"最短可往返十进制"(如 …690882 → …691000
// int64(float64(x)) 的回转值…690880都不相等唯一可靠的等价层是 float64 本身:
// 只要两个十进制落在同一个 float64 桶里就视为同一区域(相邻 ID 间距远大于桶宽,不会误碰)。
func RegionIDLooseEqual(left int64, right int64) bool {
if left == right {
return true
}
if left == 0 || right == 0 {
return false
}
return float64(left) == float64(right)
}
func (access MoneyAccess) AppCodes() []string {
if access.All {
return nil

View File

@ -75,3 +75,41 @@ func newRepositorySQLMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) {
_ = sqlDB.Close()
}
}
func TestRegionIDLooseEqual(t *testing.T) {
exact := int64(2049039732077690882)
rounded := int64(float64(exact))
if !RegionIDLooseEqual(exact, rounded) || !RegionIDLooseEqual(rounded, exact) {
t.Fatalf("expected rounded/exact pair to match")
}
// 浏览器实际写回的是 JS Number 的最短十进制表示…691000与 float64 回转值…690880不同
// 必须在 float64 层等价(线上「区域 2049039732077691000」事故的回归测试
jsShortest := int64(2049039732077691000)
if jsShortest == rounded {
t.Fatalf("fixture should differ from cast value")
}
if !RegionIDLooseEqual(exact, jsShortest) || !RegionIDLooseEqual(jsShortest, exact) {
t.Fatalf("expected js shortest-repr id to match exact id")
}
if !RegionIDLooseEqual(7, 7) {
t.Fatalf("expected exact small ids to match")
}
if RegionIDLooseEqual(7, 8) {
t.Fatalf("small ids must not loosely match")
}
if RegionIDLooseEqual(0, rounded) || RegionIDLooseEqual(exact, 0) {
t.Fatalf("zero must never loosely match")
}
}
func TestMoneyAccessAllowsRoundedRegion(t *testing.T) {
exact := int64(2049039732077690882)
rounded := int64(float64(exact))
access := MoneyAccess{UserID: 1, Scopes: []model.UserMoneyScope{{UserID: 1, AppCode: "aslan", RegionID: rounded}}}
if !access.Allows("aslan", exact) {
t.Fatalf("rounded scope should allow exact region id")
}
if access.Allows("aslan", exact+999) {
t.Fatalf("unrelated region must stay denied")
}
}

View File

@ -81,6 +81,7 @@ func (s *Store) AutoMigrate() error {
&model.DataScope{},
&model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},
&model.FinanceApplication{},
&model.UserWithdrawalApplication{},
&model.AdminJob{},

View File

@ -55,6 +55,7 @@ var defaultPermissions = []model.Permission{
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
{Name: "资源创建", Code: "resource:create", Kind: "button"},
{Name: "资源更新", Code: "resource:update", Kind: "button"},
{Name: "资源删除", Code: "resource:delete", Kind: "button"},
{Name: "道具商店查看", Code: "resource-shop:view", Kind: "menu"},
{Name: "道具商店更新", Code: "resource-shop:update", Kind: "button"},
{Name: "资源组查看", Code: "resource-group:view", Kind: "menu"},
@ -110,8 +111,8 @@ var defaultPermissions = []model.Permission{
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
{Name: "全服通知查看", Code: "full-server-notice:view", Kind: "menu"},
{Name: "全服通知发送", Code: "full-server-notice:send", Kind: "button"},
{Name: "系统消息推送查看", Code: "full-server-notice:view", Kind: "menu"},
{Name: "系统消息推送发送", Code: "full-server-notice:send", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
@ -300,6 +301,7 @@ func (s *Store) seedMenus() error {
{ParentID: &appConfigID, Title: "弹窗配置", Code: "app-config-popups", Path: "/app-config/popups", Icon: "image", PermissionCode: "app-config:view", Sort: 69, Visible: true},
{ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 70, Visible: true},
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 72, Visible: true},
{ParentID: &appConfigID, Title: "系统消息推送", Code: "operation-full-server-notice", Path: "/app-config/system-message-push", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 73, Visible: true},
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
{ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true},
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 69, Visible: true},
@ -312,7 +314,6 @@ func (s *Store) seedMenus() error {
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 71, Visible: true},
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 73, Visible: true},
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 74, Visible: true},
{ParentID: &operationsID, Title: "全服通知", Code: "operation-full-server-notice", Path: "/operations/full-server-notices", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 75, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
@ -632,7 +633,7 @@ func defaultRolePermissionCodes(code string) []string {
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-whitelist:view", "room-whitelist:update", "room-robot:view", "room-robot:create", "room-robot:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
"resource:view", "resource:create", "resource:update", "resource:delete",
"resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create", "resource-grant:revoke",
@ -742,7 +743,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"risk-config:view", "risk-config:update",
"region-block:view", "region-block:update",
"resource:view", "resource:create", "resource:update",
"resource:view", "resource:create", "resource:update", "resource:delete",
"resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create", "resource-grant:revoke",

View File

@ -92,6 +92,32 @@ func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationA
return s.GetWithdrawalApplication(id)
}
// ApprovedWithdrawalStats 按审批通过时间聚合用户提现申请的 USDT 金额withdraw_amount_minor美分口径
type ApprovedWithdrawalStats struct {
ApprovedCount int64
ApprovedUSDMinor int64
}
func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS int64) (ApprovedWithdrawalStats, error) {
query := s.db.Model(&model.UserWithdrawalApplication{}).
Where("status = ?", model.WithdrawalApplicationStatusApproved)
if appCode = strings.TrimSpace(appCode); appCode != "" {
query = query.Where("app_code = ?", appCode)
}
if startAtMS > 0 {
query = query.Where("approved_at_ms >= ?", startAtMS)
}
if endAtMS > 0 {
query = query.Where("approved_at_ms < ?", endAtMS)
}
var stats ApprovedWithdrawalStats
row := query.Select("COUNT(*), COALESCE(SUM(withdraw_amount_minor), 0)").Row()
if err := row.Scan(&stats.ApprovedCount, &stats.ApprovedUSDMinor); err != nil {
return ApprovedWithdrawalStats{}, err
}
return stats, nil
}
func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB {
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
query = query.Where("app_code = ?", appCode)

View File

@ -18,6 +18,7 @@ import (
"hyapp-admin-server/internal/modules/cumulativerechargereward"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/databi"
"hyapp-admin-server/internal/modules/financeapplication"
"hyapp-admin-server/internal/modules/financewithdrawal"
"hyapp-admin-server/internal/modules/firstrechargereward"
@ -78,6 +79,7 @@ type Handlers struct {
CumulativeRecharge *cumulativerechargereward.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
Databi *databi.Handler
FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceWithdrawal *financewithdrawal.Handler
@ -160,6 +162,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
roomrocket.RegisterRoutes(protected, h.RoomRocket)
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
dashboard.RegisterRoutes(protected, h.Dashboard)
databi.RegisterRoutes(protected, h.Databi)
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg)

View File

@ -0,0 +1,20 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- legacy AppYumi/Aslan的谷歌账单实付明细缓存likei 平台 Mongo 对 admin 只读,
-- Play Orders API 同步到的实付币种/总额/税/净收入落在 admin 库,按账单交易号回填财务充值明细。
CREATE TABLE IF NOT EXISTS admin_legacy_google_paid_details (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码yumi/aslan',
transaction_id VARCHAR(96) NOT NULL COMMENT 'legacy 内购明细内部订单 IDin_app_purchase_details._id',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Google 订单号GPA.xxx',
paid_currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '用户实付币种,来自 Google Orders API',
paid_amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '用户实付总额微单位(实付币种)',
paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)',
paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)',
bill_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '账单美金流水快照(美分),同步时从 legacy 账单带入',
bill_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '账单创建时间快照UTC epoch ms',
synced_at_ms BIGINT NOT NULL COMMENT '实付明细同步时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, transaction_id),
KEY idx_legacy_google_paid_bill_time (app_code, bill_created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='legacy App 谷歌账单实付明细缓存';

View File

@ -0,0 +1,34 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_databi_kpi_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID运营人员',
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID0 表示整个 app',
period_month CHAR(7) NOT NULL COMMENT '目标月份,格式 YYYY-MM',
target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当月充值目标USD 分',
daily_target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当日充值目标USD 分0 表示按月目标/自然天数折算',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_databi_kpi_target (user_id, app_code, region_id, period_month),
KEY idx_admin_databi_kpi_targets_period (period_month, app_code, region_id),
CONSTRAINT fk_admin_databi_kpi_targets_user FOREIGN KEY (user_id) REFERENCES admin_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='社交 BI 运营人员充值 KPI 月度目标';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('BI KPI 目标配置', 'databi-kpi:manage', 'button', '允许配置社交 BI 运营人员的充值 KPI 目标', @now_ms, @now_ms),
('BI 全员绩效查看', 'databi-kpi:view-all', 'button', '允许查看所有运营人员的 KPI 绩效;无此权限时只能查看自己', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('databi-kpi:manage', 'databi-kpi:view-all');

View File

@ -0,0 +1,13 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- App 级充值 KPI 月度目标:不分运营的全员共同目标;人级目标仍在 admin_databi_kpi_targetsregion_id=0 表示该运营的整 App 目标)。
CREATE TABLE IF NOT EXISTS admin_databi_app_kpi_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
period_month CHAR(7) NOT NULL COMMENT '目标月份,格式 YYYY-MM',
target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当月充值目标USD 分',
daily_target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当日充值目标USD 分',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_databi_app_kpi_target (app_code, period_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='社交 BI App 级充值 KPI 月度目标(不分运营)';

View File

@ -0,0 +1,18 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('资源删除', 'resource:delete', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code = 'resource:delete';

View File

@ -0,0 +1,12 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
DELETE admin_role_permissions
FROM admin_role_permissions
JOIN admin_permissions ON admin_permissions.id = admin_role_permissions.permission_id
WHERE admin_permissions.code IN ('databi-kpi:manage', 'databi-kpi:view-all');
DELETE FROM admin_permissions
WHERE code IN ('databi-kpi:manage', 'databi-kpi:view-all');
DROP TABLE IF EXISTS admin_databi_app_kpi_targets;
DROP TABLE IF EXISTS admin_databi_kpi_targets;

View File

@ -0,0 +1,27 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 将既有全服通知入口迁移到 APP 配置下,菜单 code 和权限 code 保持不变以兼容历史角色授权。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('系统消息推送查看', 'full-server-notice:view', 'menu', '', @now_ms, @now_ms),
('系统消息推送发送', 'full-server-notice:send', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '系统消息推送', 'operation-full-server-notice', '/app-config/system-message-push', 'campaign', 'full-server-notice:view', 73, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'app-config'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;

View File

@ -66,6 +66,7 @@ broadcast:
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
ensure_groups_interval: "30m"
rocketmq:
# Docker 测试环境使用 compose RocketMQ 消费 wallet_outbox 红包事实。
enabled: true

View File

@ -66,6 +66,7 @@ broadcast:
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
ensure_groups_interval: "30m"
rocketmq:
enabled: true
name_servers:

View File

@ -66,6 +66,7 @@ broadcast:
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
ensure_groups_interval: "30m"
rocketmq:
# 本地默认走 RocketMQ fanout和 Docker/testbox/线上保持同一条 outbox 链路。
enabled: true

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