Compare commits
34 Commits
b12c774bb3
...
7515233227
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7515233227 | ||
|
|
5a03a3d49a | ||
|
|
ccebdcb28a | ||
|
|
ff9321a1e2 | ||
|
|
f839e44375 | ||
|
|
15810e891d | ||
|
|
6c3841248e | ||
|
|
ff5df240dd | ||
|
|
4bd22c61d6 | ||
|
|
dc44b0bf08 | ||
|
|
b666a5ad99 | ||
|
|
e5cd2a5ce7 | ||
|
|
7a2d292c36 | ||
|
|
0a072894ec | ||
|
|
ec35fcad5e | ||
|
|
41a65c244f | ||
|
|
b83c3f38d4 | ||
|
|
026a354b04 | ||
|
|
e8d2fd37a2 | ||
|
|
ea347d2921 | ||
|
|
d7894c5825 | ||
|
|
a2252a7115 | ||
|
|
c53b710539 | ||
|
|
786b36afb2 | ||
|
|
27773290b5 | ||
|
|
3d3ebb19a0 | ||
|
|
87081a6324 | ||
|
|
d8c07b07ac | ||
|
|
0cd30e222d | ||
|
|
0480304483 | ||
|
|
a1e7514af3 | ||
|
|
9e46ce0b02 | ||
|
|
c6ec9fe6aa | ||
|
|
7f2387ba95 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,3 +31,4 @@ redis-data/
|
||||
|
||||
.tmp/
|
||||
/tmp/
|
||||
.claude/settings.local.json
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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 配置、线上配置示例和端到端冒烟脚本。
|
||||
|
||||
@ -272,7 +272,7 @@ sequenceDiagram
|
||||
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
|
||||
| 无 | 无 | 解除关系成功不发 IM,App 根据解除接口返回刷新 |
|
||||
|
||||
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. 错误码
|
||||
|
||||
|
||||
@ -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 ID;v1 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
|
||||
}
|
||||
|
||||
108
docs/flutter对接/App埋点Flutter对接.md
Normal file
108
docs/flutter对接/App埋点Flutter对接.md
Normal 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
|
||||
|
||||
无。
|
||||
@ -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,
|
||||
|
||||
277
docs/flutter对接/房内贡献榜Flutter对接.md
Normal file
277
docs/flutter对接/房内贡献榜Flutter对接.md
Normal 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 | 上榜用户 ID,HTTP 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 切换或送礼后重拉榜单快照。
|
||||
@ -550,6 +550,7 @@ definitions:
|
||||
- region
|
||||
- country
|
||||
- all_active_users
|
||||
- all_registered_users
|
||||
target_filter_json:
|
||||
type: string
|
||||
template_snapshot_json:
|
||||
|
||||
@ -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 作为 viewer,room-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"
|
||||
|
||||
@ -1039,6 +1039,7 @@ definitions:
|
||||
type: string
|
||||
enum:
|
||||
- all_active_users
|
||||
- all_registered_users
|
||||
- region
|
||||
- country
|
||||
cursor_user_id:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
@ -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
1
go.mod
@ -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
3
go.sum
@ -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=
|
||||
|
||||
@ -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 全局播报群 ID(v2,AVChatRoom)。
|
||||
// app_code 先 Normalize 再进入 GroupID,确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
|
||||
func GlobalBroadcastGroupID(value string) (string, error) {
|
||||
return GlobalBroadcastGroupIDWithPrefix("", value)
|
||||
}
|
||||
|
||||
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 ID。
|
||||
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 ID(v2,AVChatRoom)。
|
||||
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 生成区域播报群 ID(v2,AVChatRoom)。
|
||||
// region_id <= 0 被拒绝,GLOBAL/未知区域不能变成一个可 join 的区域群。
|
||||
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
||||
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
|
||||
}
|
||||
|
||||
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID。
|
||||
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID(v2,AVChatRoom)。
|
||||
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
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
@ -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"}
|
||||
@ -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)
|
||||
|
||||
12
scripts/mysql/055_google_payment_paid_details.sql
Normal file
12
scripts/mysql/055_google_payment_paid_details.sql
Normal 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 ms;0 表示未同步' AFTER paid_net_micro;
|
||||
|
||||
ALTER TABLE payment_orders
|
||||
ADD INDEX idx_payment_orders_wallet_tx (app_code, wallet_transaction_id);
|
||||
6
scripts/mysql/056_reset_vip_recharge_thresholds.sql
Normal file
6
scripts/mysql/056_reset_vip_recharge_thresholds.sql
Normal file
@ -0,0 +1,6 @@
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- VIP 购买已改为所有等级直接按金币购买;保留字段只为兼容旧协议,线上已有累充门槛统一清零。
|
||||
UPDATE vip_levels
|
||||
SET required_recharge_coin_amount = 0
|
||||
WHERE required_recharge_coin_amount <> 0;
|
||||
92
scripts/mysql/057_add_yumi_app.sql
Normal file
92
scripts/mysql/057_add_yumi_app.sql
Normal 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);
|
||||
12
scripts/mysql/058_manager_grant_vip_permission.sql
Normal file
12
scripts/mysql/058_manager_grant_vip_permission.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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 分批完成。
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 源按整段区间做聚合(注册/日活/充值/礼物 + 留存 cohort),30 天区间比逐日查询重,
|
||||
# 5s 会在长区间超时;线上建议 30s 起。
|
||||
request_timeout: "30s"
|
||||
finance_notifications:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 App(Yumi/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",
|
||||
// Aslan(likei-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 Mongo(10.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":
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
258
server/admin/internal/integration/googleorders/client.go
Normal file
258
server/admin/internal/integration/googleorders/client.go
Normal file
@ -0,0 +1,258 @@
|
||||
// Package googleorders 提供 Play Developer API Orders 资源的最小只读客户端。
|
||||
// hyapp 自有 App(Lalu 等)的实付同步走 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -417,6 +417,28 @@ func (TemporaryPaymentLinkOwner) TableName() string {
|
||||
return "admin_temporary_payment_link_owners"
|
||||
}
|
||||
|
||||
// LegacyGooglePaidDetail 缓存 legacy App(Yumi/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"`
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -28,4 +28,5 @@ type coinAdjustmentRequest struct {
|
||||
TargetUserID any `json:"targetUserId"`
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 "})
|
||||
|
||||
@ -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...)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
1796
server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go
Normal file
1796
server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
@ -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+"%")
|
||||
}
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
133
server/admin/internal/modules/databi/handler.go
Normal file
133
server/admin/internal/modules/databi/handler.go
Normal 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
|
||||
}
|
||||
270
server/admin/internal/modules/databi/metrics.go
Normal file
270
server/admin/internal/modules/databi/metrics.go
Normal 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
|
||||
}
|
||||
112
server/admin/internal/modules/databi/metrics_test.go
Normal file
112
server/admin/internal/modules/databi/metrics_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
14
server/admin/internal/modules/databi/routes.go
Normal file
14
server/admin/internal/modules/databi/routes.go
Normal 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)
|
||||
}
|
||||
1379
server/admin/internal/modules/databi/service.go
Normal file
1379
server/admin/internal/modules/databi/service.go
Normal file
File diff suppressed because it is too large
Load Diff
87
server/admin/internal/modules/databi/service_test.go
Normal file
87
server/admin/internal/modules/databi/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 App(Yumi 等)的外部账单源;命中 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 组装支付后台 handler;wallet 负责账单和商品事实,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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
1402
server/admin/internal/modules/payment/legacy_bill_source.go
Normal file
1402
server/admin/internal/modules/payment/legacy_bill_source.go
Normal file
File diff suppressed because it is too large
Load Diff
470
server/admin/internal/modules/payment/legacy_bill_source_test.go
Normal file
470
server/admin/internal/modules/payment/legacy_bill_source_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
275
server/admin/internal/modules/payment/recharge_bill_export.go
Normal file
275
server/admin/internal/modules/payment/recharge_bill_export.go
Normal 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 按当前筛选口径导出充值明细 CSV;hyapp 与 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
|
||||
}
|
||||
179
server/admin/internal/modules/payment/recharge_bill_overview.go
Normal file
179
server/admin/internal/modules/payment/recharge_bill_overview.go
Normal 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
|
||||
}
|
||||
313
server/admin/internal/modules/payment/recharge_bill_stats.go
Normal file
313
server/admin/internal/modules/payment/recharge_bill_stats.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
30
server/admin/internal/repository/databi_repository.go
Normal file
30
server/admin/internal/repository/databi_repository.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.DataScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
&model.FinanceApplication{},
|
||||
&model.UserWithdrawalApplication{},
|
||||
&model.AdminJob{},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
20
server/admin/migrations/074_legacy_google_paid_details.sql
Normal file
20
server/admin/migrations/074_legacy_google_paid_details.sql
Normal file
@ -0,0 +1,20 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- legacy App(Yumi/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 内购明细内部订单 ID(in_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 谷歌账单实付明细缓存';
|
||||
34
server/admin/migrations/075_databi_kpi_targets.sql
Normal file
34
server/admin/migrations/075_databi_kpi_targets.sql
Normal 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 '区域 ID,0 表示整个 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');
|
||||
13
server/admin/migrations/076_databi_app_kpi_targets.sql
Normal file
13
server/admin/migrations/076_databi_app_kpi_targets.sql
Normal file
@ -0,0 +1,13 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- App 级充值 KPI 月度目标:不分运营的全员共同目标;人级目标仍在 admin_databi_kpi_targets(region_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 月度目标(不分运营)';
|
||||
18
server/admin/migrations/077_resource_delete_permission.sql
Normal file
18
server/admin/migrations/077_resource_delete_permission.sql
Normal 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';
|
||||
12
server/admin/migrations/078_drop_databi_kpi_targets.sql
Normal file
12
server/admin/migrations/078_drop_databi_kpi_targets.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user