增加后台数据方案

This commit is contained in:
zhx 2026-07-14 17:12:39 +08:00
parent 8a665c11f4
commit 1fbbe2758f
53 changed files with 5785 additions and 3348 deletions

View File

@ -7,6 +7,13 @@
"runtimeArgs": ["--dir", "/Users/hy/Documents/hy/hyapp-admin-platform", "dev", "--port", "7002"],
"port": 7002,
"autoPort": false
},
{
"name": "admin-server",
"runtimeExecutable": "go",
"runtimeArgs": ["run", "-C", "server/admin", "./cmd/server"],
"port": 13100,
"autoPort": false
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -1637,6 +1637,21 @@ message UpsertLevelTierResponse {
bool created = 2;
}
// BatchUpsertLevelConfigRequest
// rules/tiers DTO使
message BatchUpsertLevelConfigRequest {
RequestMeta meta = 1;
repeated LevelRule rules = 2;
repeated LevelTier tiers = 3;
int64 operator_admin_id = 4;
}
message BatchUpsertLevelConfigResponse {
repeated LevelRule rules = 1;
repeated LevelTier tiers = 2;
int64 server_time_ms = 3;
}
// ListLevelConfigRequest
message ListLevelConfigRequest {
RequestMeta meta = 1;
@ -2586,6 +2601,7 @@ service AdminGrowthLevelService {
rpc UpsertLevelTrack(UpsertLevelTrackRequest) returns (UpsertLevelTrackResponse);
rpc UpsertLevelRule(UpsertLevelRuleRequest) returns (UpsertLevelRuleResponse);
rpc UpsertLevelTier(UpsertLevelTierRequest) returns (UpsertLevelTierResponse);
rpc BatchUpsertLevelConfig(BatchUpsertLevelConfigRequest) returns (BatchUpsertLevelConfigResponse);
rpc BatchGetUserLevelAdminProfiles(BatchGetUserLevelAdminProfilesRequest) returns (BatchGetUserLevelAdminProfilesResponse);
rpc AdjustTemporaryUserLevels(AdjustTemporaryUserLevelsRequest) returns (AdjustTemporaryUserLevelsResponse);
}

View File

@ -5888,6 +5888,7 @@ const (
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier"
AdminGrowthLevelService_BatchUpsertLevelConfig_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchUpsertLevelConfig"
AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchGetUserLevelAdminProfiles"
AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/AdjustTemporaryUserLevels"
)
@ -5902,6 +5903,7 @@ type AdminGrowthLevelServiceClient interface {
UpsertLevelTrack(ctx context.Context, in *UpsertLevelTrackRequest, opts ...grpc.CallOption) (*UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, in *UpsertLevelRuleRequest, opts ...grpc.CallOption) (*UpsertLevelRuleResponse, error)
UpsertLevelTier(ctx context.Context, in *UpsertLevelTierRequest, opts ...grpc.CallOption) (*UpsertLevelTierResponse, error)
BatchUpsertLevelConfig(ctx context.Context, in *BatchUpsertLevelConfigRequest, opts ...grpc.CallOption) (*BatchUpsertLevelConfigResponse, error)
BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error)
}
@ -5954,6 +5956,16 @@ func (c *adminGrowthLevelServiceClient) UpsertLevelTier(ctx context.Context, in
return out, nil
}
func (c *adminGrowthLevelServiceClient) BatchUpsertLevelConfig(ctx context.Context, in *BatchUpsertLevelConfigRequest, opts ...grpc.CallOption) (*BatchUpsertLevelConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchUpsertLevelConfigResponse)
err := c.cc.Invoke(ctx, AdminGrowthLevelService_BatchUpsertLevelConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminGrowthLevelServiceClient) BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserLevelAdminProfilesResponse)
@ -5984,6 +5996,7 @@ type AdminGrowthLevelServiceServer interface {
UpsertLevelTrack(context.Context, *UpsertLevelTrackRequest) (*UpsertLevelTrackResponse, error)
UpsertLevelRule(context.Context, *UpsertLevelRuleRequest) (*UpsertLevelRuleResponse, error)
UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error)
BatchUpsertLevelConfig(context.Context, *BatchUpsertLevelConfigRequest) (*BatchUpsertLevelConfigResponse, error)
BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error)
mustEmbedUnimplementedAdminGrowthLevelServiceServer()
@ -6008,6 +6021,9 @@ func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelRule(context.Contex
func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertLevelTier not implemented")
}
func (UnimplementedAdminGrowthLevelServiceServer) BatchUpsertLevelConfig(context.Context, *BatchUpsertLevelConfigRequest) (*BatchUpsertLevelConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchUpsertLevelConfig not implemented")
}
func (UnimplementedAdminGrowthLevelServiceServer) BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserLevelAdminProfiles not implemented")
}
@ -6108,6 +6124,24 @@ func _AdminGrowthLevelService_UpsertLevelTier_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _AdminGrowthLevelService_BatchUpsertLevelConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchUpsertLevelConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminGrowthLevelServiceServer).BatchUpsertLevelConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminGrowthLevelService_BatchUpsertLevelConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminGrowthLevelServiceServer).BatchUpsertLevelConfig(ctx, req.(*BatchUpsertLevelConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserLevelAdminProfilesRequest)
if err := dec(in); err != nil {
@ -6167,6 +6201,10 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertLevelTier",
Handler: _AdminGrowthLevelService_UpsertLevelTier_Handler,
},
{
MethodName: "BatchUpsertLevelConfig",
Handler: _AdminGrowthLevelService_BatchUpsertLevelConfig_Handler,
},
{
MethodName: "BatchGetUserLevelAdminProfiles",
Handler: _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler,

View File

@ -1,6 +1,6 @@
# Fami VIP Flutter 对接方案
# 可配置 VIP 权限体系 Flutter 对接方案Fami / Lalu
本文只定义 Flutter 与已落地后端的对接契约,不要在客户端重复实现 VIP 状态机。界面和文案统一使用“VIP”不再使用“SVIP”。
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”不再使用“SVIP”。
## 1. 通用约定
@ -21,7 +21,178 @@
- 所有 `*_at_ms` / `duration_ms` 都是 Unix epoch milliseconds。业务切日使用 UTCFlutter 只在展示层转本地时间。
- 客户端展示当前身份只读 `state.effective_vip`,权益资格只读 `state.effective_benefits`。不要根据等级、`unlock_level` 或 App 名自行继承权益;进房/上线通知还要结合 `state.user_settings`
## 2. VIP 套餐
## 2. 客户端权限控制方案
### 2.1 结论
客户端只把“VIP 等级”用于套餐名称、价格、有效期和等级对比展示;所有功能权限一律使用稳定的 `benefit_code` 判断:
```text
后台等级配置 -> wallet 计算当前 effective_benefits -> Flutter 权益快照 -> 页面预判
-> owner service 最终鉴权
```
权限分三层,三层职责不能混用:
| 层级 | 负责内容 | 是否是最终权限依据 |
|---|---|---|
| VIP 配置与 wallet 快照 | 按当前 App、付费 VIP/体验卡、开关和配置版本计算 `effective_benefits` | 是,提供资格事实 |
| Flutter 权益中心 | 控制入口显隐、锁态、升级引导、装扮渲染,减少无效请求 | 否,只是体验层预判 |
| room/user/wallet/notice 等 owner service | 在写操作发生时重新校验权益、业务状态和平台权限 | 是,接口结果永远优先 |
禁止以下实现:
- `vipLevel >= 8` 就允许进房通知,或 `vipLevel >= 9` 就认为不可踢。
- 根据 `unlock_level` 在客户端累计低等级权益。
- `if (appCode == 'fami')` 写一套权限分支、`lalu` 再写另一套。
- 用本地缓存阻止房主发起踢人/禁言,或用缓存绕过服务端拒绝。
- 收到 VIP IM 后直接拼装权限,不再查询 `/vip/me`
成熟会员/订阅产品通常采用“服务端维护 entitlement当前资格客户端缓存当前资格并做 UI 预判敏感能力由服务端再次鉴权”的模式。Google Play 官方建议在安全后端管理购买状态并在授予权益前验证购买Apple 的 StoreKit 也提供 current entitlements并给出服务端判定服务资格的模型。这里沿用同一边界但资格来源是本项目的 wallet VIP 状态,而不是让 Flutter 直接解释订单:
- [Google Play在安全后端管理购买生命周期与权益](https://developer.android.com/google/play/billing/backend?hl=en)
- [Google Play授予权益前验证购买](https://developer.android.com/google/play/billing/security?hl=en)
- [Apple StoreKitcurrentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements)
- [Apple StoreKit服务端判定 entitlement](https://developer.apple.com/documentation/StoreKit/determining-service-entitlement-on-the-server)
### 2.2 Flutter 统一权益中心
建议建立一个按登录 App 隔离的 `VipEntitlementStore`。下面是数据结构示意,不要求照搬类名:
```dart
class VipEntitlementSnapshot {
final VipProgramConfig program;
final VipIdentity effectiveVip;
final String effectiveSource; // paid / trial / none
final Map<String, VipBenefit> benefitsByCode;
final VipUserSettings settings;
final int evaluatedAtMs;
bool has(String code) {
return program.status == 'active' &&
effectiveVip.active &&
benefitsByCode[code]?.status == 'active';
}
bool allowsRoomEntryNotice() {
return has('room_entry_notice') && settings.roomEntryNoticeEnabled;
}
bool allowsOnlineGlobalNotice() {
return has('online_global_notice') &&
settings.onlineGlobalNoticeEnabled;
}
}
```
`benefitsByCode` 只能由 `/vip/me.data.state.effective_benefits` 建立。客户端的已知权益注册表只描述“这个 code 由哪个模块渲染/调用”,不能配置“最低 VIP 等级”:
```dart
final benefitHandlers = <String, VipBenefitHandler>{
'custom_room_background': roomBackgroundHandler,
'daily_coin_rebate': coinRebateHandler,
'room_entry_notice': roomEntryNoticeHandler,
'online_global_notice': onlineNoticeHandler,
};
```
遇到服务端新下发、旧客户端不认识的 `benefit_code` 时:保留在快照中、上报一次兼容性埋点,但不自动开放未知功能、不崩溃。这样后台可先配置新权益,旧版本客户端会安全忽略。
### 2.3 页面与写操作的统一决策
| 场景 | Flutter 预处理 | 最终处理 |
|---|---|---|
| VIP 中心等级对比 | 使用 `packages[].benefits` 展示每个套餐当前完整权益 | 仅展示,不计算用户权限 |
| 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` |
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
对于锁定入口,“升级到哪一级”也不能使用本地最低等级表。应按 `sort_order` 遍历当前 `/vip/packages`,找到第一个 `status=active``can_purchase=true``benefits` 包含目标 code 的套餐;找不到时只显示“暂未开放”,不要给出错误等级承诺。
## 3. 每一级 VIP 如何展示和控制
### 3.1 Fami P1 初始权益矩阵
下表是初始化配置中的“本级新增权益”,用于产品和客户端理解页面分组,不是客户端授权表。后台保存的是每一级完整权益集合,运营调整后必须以 `packages[level].benefits` 为准。
| 等级 | 初始化新增权益 |
|---|---|
| VIP1 | `vip_badge_identity` VIP标识、`vip_medal` VIP勋章、`avatar_frame` VIP头像框、`vip_title` VIP称号 |
| VIP2 | `chat_bubble` VIP聊天气泡、`vip_gift` VIP礼物、`entry_effect` VIP进场通知、`visitor_history` 查看访客 |
| VIP3 | `voice_wave` VIP声波纹、`profile_card` VIP资料卡皮肤、`custom_room_background` 自定义房间背景、`room_image_message` 房间发图 |
| VIP4 | `animated_avatar` 动态头像、`animated_room_cover` 动态房间封面、`mic_skin` 麦克风皮肤、`room_border` 房间边框、`vehicle` VIP座驾 |
| VIP5 | `colored_room_name` VIP彩色房间名称、`colored_nickname` VIP彩色昵称、`colored_id` VIP彩色ID、`gift_tray_skin` 送礼托盘皮肤 |
| VIP6 | `hide_profile_data` 隐藏个人数据、`custom_avatar_frame` 定制头框、`leaderboard_invisible` 榜单隐身、`daily_coin_rebate` 金币返现 |
| VIP7 | `custom_profile_card` 定制资料卡、`anonymous_profile_visit` 匿名访问主页、`custom_gift` 定制礼物 |
| VIP8 | `room_entry_notice` 进房高亮通知、`custom_pretty_id` 定制靓号、`custom_vehicle` 定制座驾 |
| VIP9 | `anti_kick` 防踢、`anti_mute` 防禁言、`online_global_notice` 上线全服通知 |
展示等级详情时直接循环该套餐的 `benefits`,可按 `benefit_type``execution_scope` 分组;不要把上表写进 Flutter。高等级是否包含低等级权益由后台下发的完整集合决定。
### 3.2 Lalu 与其他 App
- `/vip/packages``/vip/me` 返回当前登录态对应 App 的配置Flutter 不传 `app_code`
- Lalu 当前是 `legacy_timed`Fami 当前是 `tiered_privilege_v1`;客户端只展示 `program_config`,不按该值重写购买状态机。
- 等级数量使用 `program_config.level_count`,可购买等级使用 `packages` 实际数组。不能假设所有 App 都有 VIP1VIP9。
- 套餐是否可买使用 `can_purchase`;购买后的等级、期限、权益和当前展示身份使用购买响应中的 `state`
## 4. 权益快照生命周期与缓存
### 4.1 刷新时机
| 事件 | 客户端动作 |
|---|---|
| 登录成功、切换账号或切换 App | 清空旧 App store调用 `/vip/me` |
| App 冷启动、回前台 | 调 `/vip/me` 校准过期、后台停用和体验卡回落 |
| 进入 VIP 中心 | 并发调用 `/vip/packages``/vip/me` |
| 购买、佩戴体验卡、卸下体验卡成功 | 立即用响应 `state` 原子替换 store |
| 收到 `vip_notice` | 用 `event_id` 去重,然后调用 `/vip/me`;若版本变更再刷新 packages |
| `state.program_config.config_version` 与套餐版本不一致 | 用户权限先以 state 为准,重新请求 packages不能跨版本合并权益数组 |
| 进入房间 | 本人首屏读 Join 响应;其他人的进房效果读当前房间 IM不查询对方 `/vip/me` |
内存快照是当前会话的主缓存。磁盘缓存只可用于会员徽章等首帧占位,必须立刻后台校准;防踢、防禁言、返现、发送图片等敏感功能不能在离线状态仅凭磁盘快照授权。
客户端不要用手机定时器直接删除权益。可以用 `expires_at_ms` 做倒计时和提前显示过期态,但恢复联网或执行写操作时,最终结果仍以服务端为准。
### 4.2 `config_version` 规则
- `/vip/me.state.program_config.config_version`:当前用户权益快照所依据的版本。
- `/vip/packages.program_config.config_version` 和各 package 的 `config_version`:套餐列表所依据的版本。
- 收到更高版本的购买响应或 `vip_notice.config_version` 时,刷新 `/vip/me``/vip/packages`
- 版本不一致时,绝不能把旧 packages 的权益补进新 state也不能把新 packages 当作用户已拥有权益。
## 5. 当前接口与消息总览
### 5.1 HTTP
| 目的 | 方法与地址 | 客户端状态影响 |
|---|---|---|
| 套餐与每级完整权益 | `GET /api/v1/vip/packages` | 更新套餐目录,不直接授权 |
| 当前 VIP 与生效权益 | `GET /api/v1/vip/me` | 原子替换 VIP store |
| 购买/续期/升级 | `POST /api/v1/vip/purchase` | 成功后使用响应 `state` |
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` |
| 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` |
| VIP 用户开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 |
| 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 |
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
### 5.2 腾讯云 IM 与 inbox
| 事件 | 通道与标识 | 客户端处理 |
|---|---|---|
| 购买/直接激活 VIP | C2C `TIMCustomElem``Ext=vip_notice``Desc=VipActivated` | `event_id` 去重,刷新 `/vip/me`,不能直接授权 |
| 后台发 Fami 体验卡 | 当前无专用 C2C | 进入背包时刷新资源列表,不假装自动佩戴 |
| 用户进入房间 | 当前房间群 `TIMCustomElem``Ext=room_system_message``Desc=room_user_joined` | 使用事件内 VIP 快照渲染本次进房效果 |
| VIP 上线全服通知 | 全局播报群 `TIMCustomElem``Ext=im_broadcast``Desc=vip_online_notice` | `event_id` 去重并渲染事件快照 |
| VIP 金币返现 | 后端 system inbox`action_type=vip_coin_rebate_claim` | 解析 `action_param` 后调用 statuses 恢复真实状态 |
当前没有“任意 VIP 权限变化”的通用 IM。客户端必须依靠上述刷新生命周期校准不能把 IM 是否到达当作权益是否生效的依据。
## 6. VIP 套餐
### 接口地址
@ -150,7 +321,7 @@
- `benefits` 已是该等级的完整集合。`unlock_level` 仅是配置来源信息,不参与 Flutter 权益计算。
- `current_vip` 是兼容字段,新代码优先使用 `state.effective_vip`
## 3. 当前 VIP 状态
## 7. 当前 VIP 状态
### 接口地址
@ -278,7 +449,7 @@
- 无 VIP 时,`effective_source="none"``effective_vip.active=false``effective_benefits=[]`
- `user_settings.updated_at_ms=0` 表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
## 4. 购买、续期和升级
## 8. 购买、续期和升级
### 接口地址
@ -381,7 +552,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED`
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
## 5. 体验卡背包列表
## 9. 体验卡背包列表
### 接口地址
@ -449,7 +620,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 列表仅返回当前有效的背包实例。卡过期后会自然从 active 列表消失。
- 预置卡素材初始为空Flutter 需提供无图兜底,不得拼接本地固定素材 URL。
## 6. 佩戴体验卡
## 10. 佩戴体验卡
### 接口地址
@ -518,7 +689,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 倒计时使用 `expires_at_ms - server_time_ms`,不要在每次切换时重置为 `duration_ms`
- Flutter 的 VIP 流程固定调用本接口,不调用通用 `/users/me/resources/{resource_id}/equip`。后端虽兼容通用背包装备并会内部转交同一 VIP 状态机,但通用响应没有完整 `state`;历史通用背包组件若触发成功,必须立即补拉 `GET /vip/me`
## 7. 卸下体验卡
## 11. 卸下体验卡
### 接口地址
@ -573,7 +744,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 卸下只删除佩戴关系,不删卡、不暂停卡、不修改卡的截止时间。
- 卸下后优先回落到有效 `paid_vip`;没有付费 VIP 则 `effective_source="none"`
## 8. 经理中心发放 VIP
## 12. 经理中心发放 VIP
经理中心页面如果复用 Flutter仍调用原地址不需要按 Fami/Lalu 写分支。gateway 会读取当前 App 的 `program_config.grant_mode`Lalu 调直接会员发放Fami 调体验卡专用发放。
@ -648,7 +819,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- `trial_card` 返回的 `active=false` 表示没有直接激活会员;卡片本身状态看 `card_status`
- 配置恰好在 gateway 查询后被后台切换时wallet 会拒绝旧模式写入Flutter 展示接口错误并允许使用同一 `command_id` 重试。
## 9. 进房通知 IM
## 13. 进房通知 IM
### 接口地址
@ -727,7 +898,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
## 10. VIP 通知开关
## 14. VIP 通知开关
### 接口地址
@ -809,7 +980,7 @@ PATCH
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 设置按 App 隔离Fami 的修改不影响 Lalu。
## 11. 每日 VIP 金币返现
## 15. 每日 VIP 金币返现
### 接口地址
@ -989,7 +1160,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 领取成功后用 `data.coin_balance` 替换 COIN 余额快照,不在 Flutter 本地做 `balance += coin_amount`
- 记录不存在或不属于当前用户时返回 `VIP_COIN_REBATE_NOT_FOUND`;复用一个 `command_id` 改投另一笔返现时返回 `IDEMPOTENCY_CONFLICT`
## 12. 上线全服通知
## 16. 上线全服通知
### 接口地址
@ -1078,7 +1249,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
## 13. 权益场景和完成边界
## 17. 权益场景和完成边界
| 场景 | Flutter 处理 | 当前后端状态 |
|---|---|---|
@ -1097,7 +1268,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM服务端不做日级去重 |
| 其他 VIP 装扮/功能 | 可根据 `effective_benefits` 展示锁定/解锁态;有素材时使用后端 URL | 权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 `resource_id=0`、素材为空,未绑定 owner/资源前不能宣称已实现 |
## 14. Flutter 建议状态流
## 18. Flutter 建议状态流
1. 进 VIP 页并发请求 `/vip/packages``/vip/me`,套餐用前者,用户生效状态用后者。
2. 购买成功、佩戴成功、卸下成功时,立即用响应中的 `state` 替换全局 VIP store。

View File

@ -356,7 +356,7 @@ func main() {
AgencyOpening: agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppRegistry: appregistrymodule.New(userDB, store),
AppUser: appUserHandler,
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryRegionHandler,
@ -367,9 +367,14 @@ func main() {
Dashboard: dashboardmodule.NewWithService(dashboardService),
Databi: databimodule.New(databiService, store, auditHandler),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler,
financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService),
financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...),
financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
})),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),
@ -413,7 +418,7 @@ func main() {
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
}
engine := router.New(cfg, auth, handlers)
engine := router.New(cfg, auth, store, handlers)
server := &http.Server{
Addr: cfg.HTTPAddr,

View File

@ -36,6 +36,16 @@
- `platform-admin` 当前会绑定全部权限;其他预置角色需要补默认权限绑定策略。
- `菜单权限` 页面当前只读,没有菜单、权限定义的增删改能力。
## 用户 App 可见范围
角色权限决定“能做什么”,用户 App 范围决定“能在哪个 App 中做”。两者必须同时满足:
- `admin_users.app_scope_mode=all`:主后台可以切换所有当前启用 App。
- `selected`:只允许切换 `admin_user_app_scopes` 中显式绑定的 App。
- `none`:主后台不发送 `X-App-Code`,只能进入通用工作台;财务工作台和运营工作台入口仍按 `finance:view``overview:view` 控制。
- 财务工作台的 App/区域数据范围继续由 `admin_user_money_scopes` 管理,不能与主后台 App 范围合并。
- App 级 API 除角色权限外还必须通过后端 App scope 中间件校验,不能只依赖前端隐藏下拉项。
## 权限类型
权限 `kind` 使用以下值:
@ -328,48 +338,54 @@
## 角色预设
### 平台管理员
### 超级管理员
`platform-admin`
- 拥有全部权限。
- 能维护用户、角色、权限、菜单和所有业务模块。
### 运维管理员
### 运营负责人
`ops-admin`
建议权限:
- 负责运营、团队、房间、资源、活动和游戏的日常管理。
- 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。
- `overview:view`
- `service:view`
- `service:create`
- `service:update`
- `service:operate`
- `user:view`
- `user:status`
- `log:view`
### 运营专员
### 审计员
`operations-specialist`
`auditor`
- 可执行用户、团队结构、房间、资源和常规运营动作。
- 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。
建议权限:
### 产品负责人
- `overview:view`
- `log:view`
- `user:view`
- `role:view`
- `permission:view`
`product-lead`
### 只读用户
- 可管理 APP 配置、版本、房间、资源、活动和游戏。
- 团队、支付、地区和 App 用户主资料按表格保持只读或限定动作。
`readonly`
### 产品专员
建议权限:
`product-specialist`
- 所有已开放模块的 `*:view`
- 不包含创建、更新、删除、导出、状态变更、重置密码、授权等按钮权限。
- 负责资源配置;房间仅可管理机器人配置。
- APP 配置、活动、游戏、支付和地区模块保持只读。
### 财务负责人
`finance-lead`
- 只进入财务看板、充值对账、提现审核和房内猜拳订单。
### 财务专员
`finance-specialist`
- 当前与财务负责人使用同一权限矩阵;保留独立角色用于人员分工和后续数据范围配置。
固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor``readonly` 和用户自建角色不会被删除或重置。
## 前端落地规则
@ -535,15 +551,13 @@
- 创建、更新、删除、授权、导出、批量和状态类操作都有操作日志。
- 全局搜索结果按当前用户权限过滤。
- `platform-admin` 拥有所有权限。
- `readonly` 只有查看权限,不能触发任何写操作。
- 六个岗位角色的权限与 `093_role_permission_matrix.sql` 及后端默认矩阵完全一致。
- 游戏列表、自研游戏、全站机器人、房内猜拳配置和房内猜拳订单互不借用查看或更新权限。
- 支付账单导出/刷新、支付方式更新/同步、汇率编辑/同步互不借用列表查看权限。
## 实施顺序
## 维护规则
1. 拆分并补齐权限种子:新增 `permission:*``menu:*``role:create/update/delete/permission``user:export``log:export`
2. 后端补权限定义和菜单管理接口。
3. 后端把现有接口从宽权限调整到按钮级权限。
4. 前端补角色授权抽屉和菜单权限页编辑能力。
5. 前端替换按钮权限码,导出、已读等动作不再复用查看权限。
6. 补默认角色权限绑定策略。
7. 补搜索结果权限过滤。
8. 补后端权限相关测试和前端构建验证。
1. 新增页面时先定义独立的 `*:view`,再按真实副作用拆按钮权限。
2. 不允许为了复用一个 Handler把相邻菜单重新绑定到同一个宽权限。
3. 固定岗位矩阵变更必须同时更新默认种子、版本化迁移和矩阵一致性测试。
4. 旧权限码只能作为自定义历史角色的兼容入口,不能重新分配给七类固定岗位。

View File

@ -63,6 +63,7 @@ type Client interface {
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, req *activityv1.UpsertLevelRuleRequest) (*activityv1.UpsertLevelRuleResponse, error)
UpsertLevelTier(ctx context.Context, req *activityv1.UpsertLevelTierRequest) (*activityv1.UpsertLevelTierResponse, error)
BatchUpsertLevelConfig(ctx context.Context, req *activityv1.BatchUpsertLevelConfigRequest) (*activityv1.BatchUpsertLevelConfigResponse, error)
SetUserLevel(ctx context.Context, req *activityv1.SetUserLevelRequest) (*activityv1.SetUserLevelResponse, error)
CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error)
CreateFanoutJob(ctx context.Context, req *activityv1.CreateFanoutJobRequest) (*activityv1.CreateFanoutJobResponse, error)
@ -320,6 +321,10 @@ func (c *GRPCClient) UpsertLevelTier(ctx context.Context, req *activityv1.Upsert
return c.growthClient.UpsertLevelTier(ctx, req)
}
func (c *GRPCClient) BatchUpsertLevelConfig(ctx context.Context, req *activityv1.BatchUpsertLevelConfigRequest) (*activityv1.BatchUpsertLevelConfigResponse, error) {
return c.growthClient.BatchUpsertLevelConfig(ctx, req)
}
func (c *GRPCClient) SetUserLevel(ctx context.Context, req *activityv1.SetUserLevelRequest) (*activityv1.SetUserLevelResponse, error) {
return c.levelClient.SetUserLevel(ctx, req)
}

View File

@ -0,0 +1,52 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"github.com/gin-gonic/gin"
)
type fakeAppAccessStore struct {
access repository.AppAccess
}
func (store fakeAppAccessStore) AppAccessForUser(uint) (repository.AppAccess, error) {
return store.access, nil
}
func TestRequireAppScopeAllowsOnlySelectedApp(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextUserID, uint(7))
})
router.GET("/app", RequireAppScope(fakeAppAccessStore{access: repository.AppAccess{
Mode: model.UserAppScopeModeSelected,
AppCodes: []string{"lalu"},
}}), func(c *gin.Context) { c.Status(http.StatusNoContent) })
allowed := httptest.NewRequest(http.MethodGet, "/app", nil)
allowed.Header.Set("X-App-Code", "LALU")
allowedResponse := httptest.NewRecorder()
router.ServeHTTP(allowedResponse, allowed)
if allowedResponse.Code != http.StatusNoContent {
t.Fatalf("allowed status = %d body=%s", allowedResponse.Code, allowedResponse.Body.String())
}
for _, header := range []string{"", "huwaa"} {
request := httptest.NewRequest(http.MethodGet, "/app", nil)
if header != "" {
request.Header.Set("X-App-Code", header)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("header %q status = %d body=%s", header, response.Code, response.Body.String())
}
}
}

View File

@ -1,18 +1,25 @@
package middleware
import (
"errors"
"net/http"
"slices"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AppAccessStore interface {
AppAccessForUser(userID uint) (repository.AppAccess, error)
}
const (
ContextUserID = "userID"
ContextUsername = "username"
@ -77,6 +84,39 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
}
}
func RequireAppScope(store AppAccessStore) gin.HandlerFunc {
return func(c *gin.Context) {
// App 级路由必须显式携带 header不能复用 appctx 的 lalu 默认值;否则无 App 用户省略 header 仍会落入 Lalu 数据域。
appCode := strings.ToLower(strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode)))
if appCode == "" {
response.Forbidden(c, "当前用户没有可访问的 App")
c.Abort()
return
}
if store == nil {
response.ServerError(c, "App 权限服务不可用")
c.Abort()
return
}
access, err := store.AppAccessForUser(CurrentUserID(c))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Unauthorized(c, "用户不存在")
} else {
response.ServerError(c, "校验 App 权限失败")
}
c.Abort()
return
}
if !access.Allows(appCode) {
response.Forbidden(c, "当前用户无权访问该 App")
c.Abort()
return
}
c.Next()
}
}
func RequirePermission(code string) gin.HandlerFunc {
return func(c *gin.Context) {
if HasPermission(c, code) {

View File

@ -5,6 +5,10 @@ const (
UserStatusLocked = "locked"
UserStatusDisabled = "disabled"
UserAppScopeModeAll = "all"
UserAppScopeModeSelected = "selected"
UserAppScopeModeNone = "none"
TeamProductResearchID uint = 1
TeamOperationsID uint = 2
@ -44,6 +48,7 @@ type User struct {
TeamID *uint `gorm:"column:team_id;index" json:"teamId"`
TeamRecord Team `gorm:"foreignKey:TeamID;references:ID" json:"-"`
Status string `gorm:"size:24;index;not null;default:active" json:"status"`
AppScopeMode string `gorm:"size:16;not null;default:none" json:"appScopeMode"`
MFAEnabled bool `gorm:"not null;default:false" json:"mfaEnabled"`
LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"`
Roles []Role `gorm:"many2many:admin_user_roles;" json:"roles"`
@ -512,6 +517,20 @@ func (UserMoneyScope) TableName() string {
return "admin_user_money_scopes"
}
// UserAppScope 是后台主站的 App 可见范围。它与财务区域范围彼此独立:前者决定主站可切换的 App
// 后者继续决定财务工作台可查看的 App/区域,不能复用同一张表,否则无 App 用户将无法进入通用财务工作台。
type UserAppScope struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_admin_user_app_scopes_user;index:uk_admin_user_app_scope,unique;not null" json:"userId"`
AppCode string `gorm:"size:32;index:uk_admin_user_app_scope,unique;index:idx_admin_user_app_scopes_app;not null" json:"appCode"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (UserAppScope) TableName() string {
return "admin_user_app_scopes"
}
type TemporaryPaymentLinkOwner struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`

View File

@ -86,6 +86,26 @@ func (h *Handler) ListUserMoneyScopes(c *gin.Context) {
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
}
func (h *Handler) ListMoneyScopeAssignments(c *gin.Context) {
assignments, err := h.service.ListMoneyScopeAssignments()
if err != nil {
response.ServerError(c, "获取财务范围分配失败")
return
}
items := make([]gin.H, 0, len(assignments))
for _, item := range assignments {
items = append(items, gin.H{
"appCode": item.AppCode,
"regionId": item.RegionID,
"userId": item.UserID,
"userName": item.UserName,
"userAccount": item.UserAccount,
"userStatus": item.UserStatus,
})
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
@ -109,6 +129,42 @@ func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
}
func (h *Handler) GetUserAppScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
access, err := h.service.GetUserAppScopes(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "用户不存在")
return
}
if err != nil {
response.ServerError(c, "获取 App 范围失败")
return
}
response.OK(c, appScopeDTO(access))
}
func (h *Handler) ReplaceUserAppScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
var req appScopeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "App 范围参数不正确")
return
}
access, err := h.service.ReplaceUserAppScopes(id, AppScopeInput{Mode: req.Mode, AppCodes: req.AppCodes})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "replace-user-app-scopes", "admin_user_app_scopes", "success", fmt.Sprintf("user_id=%d mode=%s apps=%d", id, access.Mode, len(access.AppCodes)))
response.OK(c, appScopeDTO(access))
}
func (h *Handler) UpdateUser(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
@ -210,3 +266,11 @@ func moneyScopeDTOs(scopes []model.UserMoneyScope) []gin.H {
}
return out
}
func appScopeDTO(access repository.AppAccess) gin.H {
appCodes := access.AppCodes
if appCodes == nil {
appCodes = []string{}
}
return gin.H{"mode": access.Mode, "appCodes": appCodes}
}

View File

@ -41,3 +41,8 @@ type moneyScopeItemRequest struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
}
type appScopeRequest struct {
Mode string `json:"mode" binding:"required"`
AppCodes []string `json:"appCodes"`
}

View File

@ -11,10 +11,13 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/users", middleware.RequirePermission("user:create"), h.CreateUser)
protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers)
protected.POST("/users/batch/status", middleware.RequirePermission("user:status"), h.BatchUpdateUserStatus)
protected.GET("/users/finance-scope-assignments", middleware.RequirePermission("user:view"), h.ListMoneyScopeAssignments)
protected.GET("/users/:id", middleware.RequirePermission("user:view"), h.GetUser)
protected.GET("/users/:id/app-scopes", middleware.RequirePermission("user:view"), h.GetUserAppScopes)
protected.GET("/users/:id/finance-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.GET("/users/:id/money-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.PATCH("/users/:id", middleware.RequirePermission("user:update"), h.UpdateUser)
protected.PUT("/users/:id/app-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserAppScopes)
protected.PUT("/users/:id/finance-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PUT("/users/:id/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus)

View File

@ -65,6 +65,11 @@ type MoneyScopeInput struct {
RegionID int64
}
type AppScopeInput struct {
Mode string
AppCodes []string
}
func NewService(store *repository.Store, cfg config.Config) *AdminUserService {
return &AdminUserService{store: store, cfg: cfg}
}
@ -98,6 +103,8 @@ func (s *AdminUserService) CreateUser(input CreateUserInput) (*model.User, Passw
Team: teamName,
TeamID: teamID,
Status: shared.DefaultString(input.Status, model.UserStatusActive),
// 新用户先以零 App 权限落库,后续独立 scope 请求即使失败也不会产生短暂的全 App 越权窗口。
AppScopeMode: model.UserAppScopeModeNone,
MFAEnabled: input.MFAEnabled,
}
if err := s.store.CreateUser(&user, input.RoleIDs); err != nil {
@ -118,6 +125,42 @@ func (s *AdminUserService) ListUserMoneyScopes(id uint) ([]model.UserMoneyScope,
return s.store.ListUserMoneyScopes(id)
}
// MoneyScopeAssignment 描述某个 (App, 区域) 当前被哪位后台用户持有,用于分配弹窗把他人已占的区域置灰。
type MoneyScopeAssignment struct {
UserID uint
UserName string
UserAccount string
UserStatus string
AppCode string
RegionID int64
}
func (s *AdminUserService) ListMoneyScopeAssignments() ([]MoneyScopeAssignment, error) {
scopes, err := s.store.ListAllUserMoneyScopes()
if err != nil {
return nil, err
}
userIDs := make([]uint, 0, len(scopes))
for _, scope := range scopes {
userIDs = append(userIDs, scope.UserID)
}
users, err := s.store.FindUsersByIDs(userIDs)
if err != nil {
return nil, err
}
out := make([]MoneyScopeAssignment, 0, len(scopes))
for _, scope := range scopes {
assignment := MoneyScopeAssignment{UserID: scope.UserID, AppCode: scope.AppCode, RegionID: scope.RegionID}
if user, ok := users[scope.UserID]; ok {
assignment.UserName = user.Name
assignment.UserAccount = user.Username
assignment.UserStatus = user.Status
}
out = append(out, assignment)
}
return out, nil
}
func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInput) ([]model.UserMoneyScope, error) {
scopes := make([]model.UserMoneyScope, 0, len(input))
for _, item := range input {
@ -132,6 +175,14 @@ func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInp
return s.store.ListUserMoneyScopes(id)
}
func (s *AdminUserService) GetUserAppScopes(id uint) (repository.AppAccess, error) {
return s.store.AppAccessForUser(id)
}
func (s *AdminUserService) ReplaceUserAppScopes(id uint, input AppScopeInput) (repository.AppAccess, error) {
return s.store.ReplaceUserAppScopes(id, input.Mode, input.AppCodes)
}
func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.User, error) {
updates := map[string]any{}
if input.Name != nil {

View File

@ -3,6 +3,8 @@ package appregistry
import (
"database/sql"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
@ -10,10 +12,11 @@ import (
type Handler struct {
service *Service
store *repository.Store
}
func New(userDB *sql.DB) *Handler {
return &Handler{service: NewService(userDB)}
func New(userDB *sql.DB, store *repository.Store) *Handler {
return &Handler{service: NewService(userDB), store: store}
}
func (h *Handler) ListApps(c *gin.Context) {
@ -24,3 +27,23 @@ func (h *Handler) ListApps(c *gin.Context) {
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) ListVisibleApps(c *gin.Context) {
access, err := h.store.AppAccessForUser(middleware.CurrentUserID(c))
if err != nil {
response.ServerError(c, "获取 App 权限失败")
return
}
items, err := h.service.ListApps(c.Request.Context())
if err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
visible := make([]App, 0, len(items))
for _, item := range items {
if access.Allows(item.AppCode) {
visible = append(visible, item)
}
}
response.OK(c, gin.H{"items": visible, "total": len(visible), "mode": access.Mode})
}

View File

@ -8,4 +8,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/apps", h.ListApps)
protected.GET("/admin/apps/visible", h.ListVisibleApps)
}

View File

@ -54,6 +54,25 @@ type AslanMongoDashboardSource struct {
goldWaterMu sync.Mutex
goldWaterChecked bool
goldWaterAvailable bool
// 留存链路的两个 Mongo 触点可注入,供单元测试用假数据驱动 applyRetention 的归属逻辑;
// 为 nil 时走真实实现。
retentionCohortsLoader func(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error)
activeUserSetLoader func(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error)
}
func (s *AslanMongoDashboardSource) retentionCohorts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
if s.retentionCohortsLoader != nil {
return s.retentionCohortsLoader(ctx, startDate, endDate, countryFilter)
}
return s.loadRegistrationCohorts(ctx, startDate, endDate, countryFilter)
}
func (s *AslanMongoDashboardSource) activeUserSet(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
if s.activeUserSetLoader != nil {
return s.activeUserSetLoader(ctx, activeDate, userIDs)
}
return s.loadActiveUserSet(ctx, activeDate, userIDs)
}
func (s *AslanMongoDashboardSource) hasGoldWater(ctx context.Context) bool {
@ -414,7 +433,7 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim
)
}
if includeRetention {
tasks = append(tasks, func() error { return s.applyRetention(ctx, cohorts, grid) })
tasks = append(tasks, func() error { return s.applyRetention(ctx, startDate, countryFilter, cohorts, grid) })
}
if err := runConcurrently(tasks); err != nil {
return aslanRangeOverview{}, err
@ -1285,13 +1304,41 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate
return nil
}
// applyRetention 用注册 cohort × user_daily_active_log 推 D1/D7/D30目标观察日未到时基数保持 0
// 上层会把 0 基数转成 nil避免把“还不能观察”展示成真实 0% 留存。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
// applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30
// 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D
//(与 Yumi 的 CDC 预聚合、statistics-service 口径一致。cohort 日无注册时基数保持 0
// 上层会把 0 基数转成 nil避免把“无 cohort”展示成真实 0% 留存。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, startDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
location, err := time.LoadLocation(s.statTimezone)
if err != nil {
return err
}
// 观察日 D 的 cohort 注册日最早是 D-30区间起点之前的注册日不在主 cohorts 里,单独补一段。
// 主 cohorts 覆盖 [start, end),这里只补 [start-30, start),两边合起来覆盖全部回看窗口。
earlier, err := s.retentionCohorts(ctx, startDate.AddDate(0, 0, -30), startDate, countryFilter)
if err != nil {
return fmt.Errorf("load retention cohorts: %w", err)
}
cohortFor := func(day string) map[string][]int64 {
main, hasMain := cohorts[day]
extra, hasExtra := earlier[day]
if !hasExtra {
return main
}
if !hasMain {
return extra
}
// 请求时区与源统计时区不一致时,同一 cohort 日可能被 createTime 边界切进两个 map
// 两边用户集互斥(加载区间不相交),合并后才是完整 cohort。
merged := make(map[string][]int64, len(main)+len(extra))
for countryCode, userIDs := range main {
merged[countryCode] = userIDs
}
for countryCode, userIDs := range extra {
merged[countryCode] = append(append([]int64{}, merged[countryCode]...), userIDs...)
}
return merged
}
today := dashboardSQLDate(time.Now().In(location))
offsets := []struct {
days int
@ -1301,31 +1348,42 @@ func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts
{7, func(metric *legacyMongoMetric, users, base int64) { metric.D7Users, metric.D7Base = users, base }},
{30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }},
}
for day, byCountry := range cohorts {
if !grid.hasDay(day) {
for _, day := range grid.days {
if day > today {
continue
}
dayTime, err := time.ParseInLocation("2006-01-02", day, location)
if err != nil {
continue
}
allIDs := []int64{}
for _, userIDs := range byCountry {
allIDs = append(allIDs, userIDs...)
// 三个 offset 的 cohort 用户并集只查一次活跃集合,把每天的 Mongo 点查从 3 次收敛到 1 次。
cohortsByOffset := make([]map[string][]int64, len(offsets))
unionIDs := []int64{}
seen := map[int64]struct{}{}
for index, offset := range offsets {
byCountry := cohortFor(dashboardSQLDate(dayTime.AddDate(0, 0, -offset.days)))
cohortsByOffset[index] = byCountry
for _, userIDs := range byCountry {
for _, userID := range userIDs {
if _, ok := seen[userID]; !ok {
seen[userID] = struct{}{}
unionIDs = append(unionIDs, userID)
}
}
}
}
if len(allIDs) == 0 {
if len(unionIDs) == 0 {
continue
}
for _, offset := range offsets {
target := dashboardSQLDate(dayTime.AddDate(0, 0, offset.days))
if target > today {
continue
}
activeSet, err := s.loadActiveUserSet(ctx, target, allIDs)
if err != nil {
return err
}
for countryCode, userIDs := range byCountry {
activeSet, err := s.activeUserSet(ctx, day, unionIDs)
if err != nil {
return err
}
for index, offset := range offsets {
for countryCode, userIDs := range cohortsByOffset[index] {
if len(userIDs) == 0 {
continue
}
active := int64(0)
for _, userID := range userIDs {
if _, ok := activeSet[userID]; ok {

View File

@ -391,3 +391,78 @@ func assertMetricSourceAvailable(t *testing.T, sources []map[string]any, field s
}
t.Fatalf("metric source %s not found in %#v", field, sources)
}
// applyRetention 的观察日回看归属:区间内每天 D 的 DN 计数 = D-N 注册 cohort 在 D 的活跃,
// 归属观察日 D主 cohorts 与补载的 earlier cohorts 同日并存时必须合并;无 cohort 的天基数保持 0。
func TestAslanMongoApplyRetentionAttributesToObservationDay(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
startDate := time.Date(2026, 6, 10, 0, 0, 0, 0, location)
endDate := time.Date(2026, 6, 12, 0, 0, 0, 0, location)
grid := newLegacyMongoGrid(startDate, endDate)
// 主 cohorts 覆盖 [start, end)"2026-06-10" 同时出现在 earlier 里,验证合并。
cohorts := map[string]map[string][]int64{
"2026-06-10": {"SA": {1, 2}},
"2026-06-11": {"EG": {3}},
}
earlier := map[string]map[string][]int64{
"2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort
"2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort
"2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort
"2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort
"2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50}
}
activeByDay := map[string]map[int64]struct{}{
"2026-06-10": {10: {}, 20: {}, 30: {}},
"2026-06-11": {1: {}, 50: {}},
}
var cohortRangeFrom, cohortRangeTo string
requestedUnions := map[string]int{}
source := &AslanMongoDashboardSource{
statTimezone: "Asia/Shanghai",
retentionCohortsLoader: func(_ context.Context, from time.Time, to time.Time, _ externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
cohortRangeFrom, cohortRangeTo = dashboardSQLDate(from), dashboardSQLDate(to)
return earlier, nil
},
activeUserSetLoader: func(_ context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
requestedUnions[activeDate] = len(userIDs)
return activeByDay[activeDate], nil
},
}
if err := source.applyRetention(context.Background(), startDate, externalDashboardCountryFilter{}, cohorts, grid); err != nil {
t.Fatalf("apply retention: %v", err)
}
if cohortRangeFrom != "2026-05-11" || cohortRangeTo != "2026-06-10" {
t.Fatalf("expected earlier cohort range [start-30, start), got [%s, %s)", cohortRangeFrom, cohortRangeTo)
}
// 每个观察日只做一次活跃点查,且是三个 offset cohort 的并集。
// 06-10: D1{10,11}D7{20,21,22}D30{30}=606-11: D1 合并 cohort{1,2,50}D7{40}=4。
if requestedUnions["2026-06-10"] != 6 || requestedUnions["2026-06-11"] != 4 {
t.Fatalf("expected union sizes 6/4, got %#v", requestedUnions)
}
day1SA := grid.at("2026-06-10", "SA")
if day1SA.D1Users != 1 || day1SA.D1Base != 2 || day1SA.D7Users != 1 || day1SA.D7Base != 3 {
t.Fatalf("obs 06-10 SA retention mismatch: %+v", day1SA)
}
day1EG := grid.at("2026-06-10", "EG")
if day1EG.D30Users != 1 || day1EG.D30Base != 1 {
t.Fatalf("obs 06-10 EG d30 mismatch: %+v", day1EG)
}
day2SA := grid.at("2026-06-11", "SA")
if day2SA.D1Users != 2 || day2SA.D1Base != 3 {
t.Fatalf("obs 06-11 SA d1 (merged cohort) mismatch: %+v", day2SA)
}
day2EG := grid.at("2026-06-11", "EG")
if day2EG.D7Users != 0 || day2EG.D7Base != 1 {
t.Fatalf("obs 06-11 EG d7 real-zero mismatch: %+v", day2EG)
}
if day2EG.D30Base != 0 || day2SA.D30Base != 0 {
t.Fatalf("obs 06-11 d30 should have no cohort: SA=%+v EG=%+v", day2SA, day2EG)
}
}

View File

@ -6,11 +6,12 @@ import (
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
protected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview)
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
protected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
protected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)
protected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
func RegisterRoutes(appProtected *gin.RouterGroup, workspaceProtected *gin.RouterGroup, h *Handler) {
// 主后台画像严格跟随用户 App scope独立运营工作台继续使用自身的查询 App 和数据范围,允许零主站 App 用户进入通用入口。
appProtected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
appProtected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview)
workspaceProtected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
workspaceProtected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
workspaceProtected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)
workspaceProtected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
}

View File

@ -146,7 +146,7 @@ func TestUserProfileOverviewRecordsInternalErrorAgainstRequestID(t *testing.T) {
recordedErrors = c.Errors.String()
})
protected := router.Group("/api/v1")
RegisterRoutes(protected, NewWithService(service))
RegisterRoutes(protected, protected, NewWithService(service))
request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/user-profile-overview", nil)
recorder := httptest.NewRecorder()
@ -173,6 +173,6 @@ func newDashboardUserProfileTestRouter(handler *Handler, permissions []string) *
c.Next()
})
protected := router.Group("/api/v1")
RegisterRoutes(protected, handler)
RegisterRoutes(protected, protected, handler)
return router
}

View File

@ -22,6 +22,7 @@ type coinSellerTarget struct {
UserID int64
DisplayUserID string
CountryID int64
CountryCode string
RegionID int64
}
@ -125,18 +126,19 @@ func (w *mysqlLegacyCoinSellerRechargeWriter) resolveLegacyUser(ctx context.Cont
}
matchSQL += ")"
row := w.appDB.QueryRowContext(queryCtx, `
SELECT id, account, COALESCE(country_id, 0)
SELECT id, account, COALESCE(country_id, 0), COALESCE(country_code, '')
FROM user_base_info
WHERE origin_sys = ? AND `+matchSQL+` AND COALESCE(is_del, 0) = 0
ORDER BY id DESC
LIMIT 1`, args...)
var target coinSellerTarget
if err := row.Scan(&target.UserID, &target.DisplayUserID, &target.CountryID); err != nil {
if err := row.Scan(&target.UserID, &target.DisplayUserID, &target.CountryID, &target.CountryCode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return coinSellerTarget{}, errLegacyCoinSellerNotFound
}
return coinSellerTarget{}, err
}
target.CountryCode = strings.ToUpper(strings.TrimSpace(target.CountryCode))
if strings.TrimSpace(target.DisplayUserID) == "" {
target.DisplayUserID = formatInt64(target.UserID)
}

View File

@ -100,7 +100,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(
}, appDB, walletDB)
expectLegacyUserLookup(appMock, "ATYOU", "agent001").
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id"}).AddRow(int64(10001), "agent001", int64(63)))
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id", "country_code"}).AddRow(int64(10001), "agent001", int64(63), "ph"))
expectLegacyCoinSellerCheck(walletMock, "ATYOU", 10001).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(77)))
@ -108,7 +108,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(
if err != nil {
t.Fatalf("resolve legacy coin seller failed: %v", err)
}
if target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 {
if target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 || target.CountryCode != "PH" {
t.Fatalf("legacy coin seller target mismatch: %+v", target)
}
if err := appMock.ExpectationsWereMet(); err != nil {
@ -131,7 +131,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerRejectsNonDealer(t *test
}, appDB, walletDB)
expectLegacyUserLookup(appMock, "LIKEI", "visitor001").
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id"}).AddRow(int64(20002), "visitor001", int64(86)))
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id", "country_code"}).AddRow(int64(20002), "visitor001", int64(86), "ID"))
expectLegacyCoinSellerCheck(walletMock, "LIKEI", 20002).
WillReturnError(sql.ErrNoRows)
@ -153,7 +153,7 @@ func expectLegacyBalanceLock(mock sqlmock.Sqlmock, sysOrigin string, userID int6
}
func expectLegacyUserLookup(mock sqlmock.Sqlmock, sysOrigin string, keyword string) *sqlmock.ExpectedQuery {
return mock.ExpectQuery(`(?s)SELECT id, account, COALESCE\(country_id, 0\)\s+FROM user_base_info\s+WHERE origin_sys = \? AND \(account = \? OR CAST\(id AS CHAR\) = \?\).*LIMIT 1`).
return mock.ExpectQuery(`(?s)SELECT id, account, COALESCE\(country_id, 0\), COALESCE\(country_code, ''\)\s+FROM user_base_info\s+WHERE origin_sys = \? AND \(account = \? OR CAST\(id AS CHAR\) = \?\).*LIMIT 1`).
WithArgs(sysOrigin, keyword, keyword)
}

View File

@ -43,16 +43,29 @@ var (
)
type Service struct {
store *repository.Store
wallet walletclient.Client
user userclient.Client
now func() time.Time
statsRecorder financeCoinSellerRechargeStatsRecorder
legacyWriters map[string]LegacyCoinSellerRechargeWriter
store *repository.Store
wallet walletclient.Client
user userclient.Client
now func() time.Time
statsRecorder financeCoinSellerRechargeStatsRecorder
legacyWriters map[string]LegacyCoinSellerRechargeWriter
legacyRegionResolve LegacyCoinSellerRegionResolver
}
type Option func(*Service)
// LegacyCoinSellerRegionResolver 把 legacy 用户的国家码解析成财务/BI 通用的区域 ID
// (与 admin_user_money_scopes、账单区域分布同一套合成口径
// 没有它 Aslan/Yumi 的币商充值订单只有国家快照target_region_id 恒为 0
// 区域分布和分区域授权账号都无法归因这笔充值。
type LegacyCoinSellerRegionResolver func(ctx context.Context, appCode string, countryCode string) (int64, bool, error)
func WithLegacyCoinSellerRegionResolver(resolver LegacyCoinSellerRegionResolver) Option {
return func(service *Service) {
service.legacyRegionResolve = resolver
}
}
type financeCoinSellerRechargeStatsRecorder interface {
RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error
}
@ -555,7 +568,11 @@ func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceip
func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string, appCode string, rawTarget string) (coinSellerTarget, error) {
if writer := s.legacyWriters[appCode]; writer != nil {
return writer.ResolveCoinSeller(ctx, rawTarget)
target, err := writer.ResolveCoinSeller(ctx, rawTarget)
if err != nil {
return coinSellerTarget{}, err
}
return s.attachLegacyRegion(ctx, appCode, target)
}
if requiresLegacyWriter(appCode) {
return coinSellerTarget{}, errors.New("legacy coin seller recharge writer is not configured")
@ -590,6 +607,26 @@ func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string,
}, nil
}
// attachLegacyRegion 用国家码补齐 legacy 币商的区域归属。区域解析失败时创建必须失败:
// target_region_id=0 的订单会从所有区域分布和分区域授权账号的视图里消失,宁可让财务先补区域配置。
func (s *Service) attachLegacyRegion(ctx context.Context, appCode string, target coinSellerTarget) (coinSellerTarget, error) {
if target.CountryCode == "" {
return coinSellerTarget{}, errors.New("目标币商未配置国家,无法归因充值区域")
}
if s.legacyRegionResolve == nil {
return coinSellerTarget{}, errors.New("legacy coin seller region resolver is not configured")
}
regionID, found, err := s.legacyRegionResolve(ctx, appCode, target.CountryCode)
if err != nil {
return coinSellerTarget{}, err
}
if !found || regionID <= 0 {
return coinSellerTarget{}, fmt.Errorf("目标币商所在国家(%s)未划入任何区域,请先在区域配置中补齐", target.CountryCode)
}
target.RegionID = regionID
return target, nil
}
func (s *Service) resolveWalletUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
keyword := strings.TrimSpace(rawTarget)
if keyword == "" {

View File

@ -281,9 +281,16 @@ func TestResolveCoinSellerTargetUsesLegacyWriterForAslan(t *testing.T) {
UserID: 10001,
DisplayUserID: "agent001",
CountryID: 63,
CountryCode: "PH",
},
}
service := NewService(nil, nil, nil, WithLegacyCoinSellerRechargeWriters(writer))
var resolvedApp, resolvedCountry string
service := NewService(nil, nil, nil,
WithLegacyCoinSellerRechargeWriters(writer),
WithLegacyCoinSellerRegionResolver(func(_ context.Context, appCode string, countryCode string) (int64, bool, error) {
resolvedApp, resolvedCountry = appCode, countryCode
return 2049039964177891329, true, nil
}))
target, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001")
if err != nil {
t.Fatalf("resolve aslan legacy target failed: %v", err)
@ -291,6 +298,44 @@ func TestResolveCoinSellerTargetUsesLegacyWriterForAslan(t *testing.T) {
if writer.resolveKeyword != "agent001" || target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 {
t.Fatalf("legacy resolve mismatch: keyword=%q target=%+v", writer.resolveKeyword, target)
}
if resolvedApp != "aslan" || resolvedCountry != "PH" || target.RegionID != 2049039964177891329 {
t.Fatalf("legacy region attribution mismatch: app=%q country=%q target=%+v", resolvedApp, resolvedCountry, target)
}
}
func TestResolveCoinSellerTargetLegacyRequiresRegionAttribution(t *testing.T) {
writer := &fakeLegacyCoinSellerRechargeWriter{
appCode: "aslan",
resolveResult: coinSellerTarget{
UserID: 10001,
DisplayUserID: "agent001",
CountryID: 63,
CountryCode: "PH",
},
}
// 国家未划入任何区域:创建必须失败,否则订单以 target_region_id=0 落库后从区域视图消失。
service := NewService(nil, nil, nil,
WithLegacyCoinSellerRechargeWriters(writer),
WithLegacyCoinSellerRegionResolver(func(_ context.Context, _ string, _ string) (int64, bool, error) {
return 0, false, nil
}))
if _, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001"); err == nil || !strings.Contains(err.Error(), "未划入任何区域") {
t.Fatalf("expected unmapped country error, got %v", err)
}
// 用户没有国家码:同样必须失败。
writer.resolveResult.CountryCode = ""
if _, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001"); err == nil || !strings.Contains(err.Error(), "未配置国家") {
t.Fatalf("expected missing country error, got %v", err)
}
// 未接入区域解析器:显式报配置错误而不是静默回到 region 0。
writer.resolveResult.CountryCode = "PH"
service = NewService(nil, nil, nil, WithLegacyCoinSellerRechargeWriters(writer))
if _, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001"); err == nil || !strings.Contains(err.Error(), "region resolver is not configured") {
t.Fatalf("expected resolver configuration error, got %v", err)
}
}
func TestVerifyReceiptMatchesOrderAmounts(t *testing.T) {

View File

@ -11,36 +11,39 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
return
}
protected.GET("/admin/game/platforms", middleware.RequirePermission("game:view"), h.ListPlatforms)
protected.POST("/admin/game/platforms", middleware.RequirePermission("game:update"), h.CreatePlatform)
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequirePermission("game:update"), h.UpdatePlatform)
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequirePermission("game:update"), h.SyncPlatformGames)
protected.GET("/admin/game/games", middleware.RequirePermission("game:view"), h.ListCatalog)
protected.POST("/admin/game/games", middleware.RequirePermission("game:create"), h.CreateCatalog)
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
protected.PATCH("/admin/game/games/:game_id/whitelist", middleware.RequirePermission("game:update"), h.SetGameWhitelistEnabled)
protected.GET("/admin/game/games/:game_id/whitelist/users", middleware.RequirePermission("game:view"), h.ListGameWhitelistUsers)
protected.POST("/admin/game/games/:game_id/whitelist/users", middleware.RequirePermission("game:update"), h.AddGameWhitelistUser)
protected.DELETE("/admin/game/games/:game_id/whitelist/users/:user_id", middleware.RequirePermission("game:update"), h.DeleteGameWhitelistUser)
protected.GET("/admin/game/self-games", middleware.RequirePermission("game:view"), h.ListSelfGames)
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequirePermission("game:update"), h.UpdateDiceConfig)
protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:view"), h.GetSelfGameNewUserPolicy)
protected.PATCH("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:update"), h.UpdateSelfGameNewUserPolicy)
protected.GET("/admin/game/self-games/:game_id/stake-pools", middleware.RequirePermission("game:view"), h.ListSelfGameStakePools)
protected.PATCH("/admin/game/self-games/:game_id/stake-pools/:stake_coin", middleware.RequirePermission("game:update"), h.UpdateSelfGameStakePool)
protected.POST("/admin/game/self-games/:game_id/stake-pools/:stake_coin/adjust", middleware.RequirePermission("game:update"), h.AdjustSelfGameStakePool)
protected.GET("/admin/game/robots", middleware.RequirePermission("game:view"), h.ListDiceRobots)
protected.POST("/admin/game/robots/generate", middleware.RequirePermission("game:create"), h.GenerateDiceRobots)
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequirePermission("game:update"), h.SetDiceRobotStatus)
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteDiceRobot)
// 房内猜拳后台沿用游戏管理权限但路由、DTO 和 game-service RPC 都独立于骰子和独立猜拳,避免配置互相污染。
protected.GET("/admin/game/room-rps/config", middleware.RequirePermission("game:view"), h.GetRoomRPSConfig)
protected.PATCH("/admin/game/room-rps/config", middleware.RequirePermission("game:update"), h.UpdateRoomRPSConfig)
protected.GET("/admin/game/room-rps/challenges", middleware.RequirePermission("game:view"), h.ListRoomRPSChallenges)
protected.GET("/admin/game/room-rps/challenges/:challenge_id", middleware.RequirePermission("game:view"), h.GetRoomRPSChallenge)
// 结算重试和手动过期都是会推进订单状态的运维动作,必须使用 game:update并由 game-service 再做状态机保护。
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequirePermission("game:update"), h.RetryRoomRPSSettlement)
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequirePermission("game:update"), h.ExpireRoomRPSChallenge)
// 游戏列表、自研游戏、机器人和房内猜拳是四个独立授权模块legacy game:* 只用于兼容存量自定义角色。
protected.GET("/admin/game/platforms", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListPlatforms)
protected.POST("/admin/game/platforms", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.CreatePlatform)
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.UpdatePlatform)
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.SyncPlatformGames)
protected.GET("/admin/game/games", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListCatalog)
protected.POST("/admin/game/games", middleware.RequireAnyPermission("game-catalog:create", "game:create"), h.CreateCatalog)
protected.PATCH("/admin/game/games/:game_id", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequireAnyPermission("game-catalog:status", "game:status"), h.SetGameStatus)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game-catalog:delete", "game:delete", "game:update"), h.DeleteCatalog)
protected.PATCH("/admin/game/games/:game_id/whitelist", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.SetGameWhitelistEnabled)
protected.GET("/admin/game/games/:game_id/whitelist/users", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListGameWhitelistUsers)
protected.POST("/admin/game/games/:game_id/whitelist/users", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.AddGameWhitelistUser)
protected.DELETE("/admin/game/games/:game_id/whitelist/users/:user_id", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.DeleteGameWhitelistUser)
protected.GET("/admin/game/self-games", middleware.RequireAnyPermission("self-game:view", "game:view"), h.ListSelfGames)
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateDiceConfig)
protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequireAnyPermission("self-game:view", "game:view"), h.GetSelfGameNewUserPolicy)
protected.PATCH("/admin/game/self-games/:game_id/new-user-policy", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateSelfGameNewUserPolicy)
protected.GET("/admin/game/self-games/:game_id/stake-pools", middleware.RequireAnyPermission("self-game:view", "game:view"), h.ListSelfGameStakePools)
protected.PATCH("/admin/game/self-games/:game_id/stake-pools/:stake_coin", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateSelfGameStakePool)
protected.POST("/admin/game/self-games/:game_id/stake-pools/:stake_coin/adjust", middleware.RequireAnyPermission("self-game:update", "game:update"), h.AdjustSelfGameStakePool)
protected.GET("/admin/game/robots", middleware.RequireAnyPermission("game-robot:view", "game:view"), h.ListDiceRobots)
protected.POST("/admin/game/robots/generate", middleware.RequireAnyPermission("game-robot:create", "game:create"), h.GenerateDiceRobots)
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequireAnyPermission("game-robot:update", "game:update"), h.SetDiceRobotStatus)
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game-robot:delete", "game:delete", "game:update"), h.DeleteDiceRobot)
protected.GET("/admin/game/room-rps/config", middleware.RequireAnyPermission("room-rps-config:view", "game:view"), h.GetRoomRPSConfig)
protected.PATCH("/admin/game/room-rps/config", middleware.RequireAnyPermission("room-rps-config:update", "game:update"), h.UpdateRoomRPSConfig)
protected.GET("/admin/game/room-rps/challenges", middleware.RequireAnyPermission("room-rps-order:view", "game:view"), h.ListRoomRPSChallenges)
protected.GET("/admin/game/room-rps/challenges/:challenge_id", middleware.RequireAnyPermission("room-rps-order:view", "game:view"), h.GetRoomRPSChallenge)
// 结算重试和手动过期都会推进订单状态,必须使用订单更新权限,并由 game-service 再做状态机保护。
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequireAnyPermission("room-rps-order:update", "game:update"), h.RetryRoomRPSSettlement)
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequireAnyPermission("room-rps-order:update", "game:update"), h.ExpireRoomRPSChallenge)
}

View File

@ -122,6 +122,22 @@ type tierRequest struct {
DisplayConfigJSON *string `json:"display_config_json"`
}
type batchLevelConfigRequest struct {
Rules []batchRuleRequest `json:"rules"`
Tiers []batchTierRequest `json:"tiers"`
}
type batchRuleRequest struct {
Track string `json:"track"`
Level int32 `json:"level"`
ruleRequest
}
type batchTierRequest struct {
TierID int64 `json:"tier_id"`
tierRequest
}
// ListLevelConfig 返回用户管理 > 等级配置页面需要的三条等级轨道完整配置。
func (h *Handler) ListLevelConfig(c *gin.Context) {
track := normalizeTrack(c.Query("track"))
@ -340,6 +356,166 @@ func (h *Handler) UpdateTier(c *gin.Context) {
h.upsertTier(c, tierID)
}
// BatchUpsertLevelConfig 把上下两张配置表的一次编辑合并提交;任一行失败时 activity-service 会整体回滚。
func (h *Handler) BatchUpsertLevelConfig(c *gin.Context) {
var req batchLevelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil || (len(req.Rules) == 0 && len(req.Tiers) == 0) || len(req.Rules)+len(req.Tiers) > 500 {
response.BadRequest(c, "批量等级配置参数不正确")
return
}
config, err := h.loadConfig(c, "", "")
if err != nil {
response.ServerError(c, "读取等级配置失败")
return
}
grpcRequest := &activityv1.BatchUpsertLevelConfigRequest{
Meta: h.meta(c), Rules: make([]*activityv1.LevelRule, 0, len(req.Rules)), Tiers: make([]*activityv1.LevelTier, 0, len(req.Tiers)),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
validatedResources := make(map[string]error)
for _, input := range req.Rules {
track := normalizeTrack(input.Track)
current, exists := findRule(config.GetRules(), track, input.Level)
if !validTrack(track) || input.Level < 0 || !exists {
response.BadRequest(c, "批量编辑包含不存在的等级规则")
return
}
item, err := h.batchRuleProto(c, track, input.Level, current, input.ruleRequest, validatedResources)
if err != nil {
response.BadRequest(c, err.Error())
return
}
grpcRequest.Rules = append(grpcRequest.Rules, item)
}
for _, input := range req.Tiers {
current, exists := findTier(config.GetTiers(), input.TierID)
if input.TierID <= 0 || !exists {
response.BadRequest(c, "批量编辑包含不存在的等级层")
return
}
item, err := batchTierProto(input.TierID, current, input.tierRequest)
if err != nil {
response.BadRequest(c, err.Error())
return
}
grpcRequest.Tiers = append(grpcRequest.Tiers, item)
}
resp, err := h.activity.BatchUpsertLevelConfig(c.Request.Context(), grpcRequest)
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "batch-upsert-level-config", "growth_level_config", appctx.FromContext(c.Request.Context()), fmt.Sprintf("rules=%d tiers=%d", len(req.Rules), len(req.Tiers)))
response.OK(c, map[string]any{"rules": rulesFromProto(resp.GetRules()), "tiers": tiersFromProto(resp.GetTiers()), "server_time_ms": resp.GetServerTimeMs()})
}
func (h *Handler) batchRuleProto(c *gin.Context, track string, level int32, current *activityv1.LevelRule, req ruleRequest, validatedResources map[string]error) (*activityv1.LevelRule, error) {
requiredValue, name, status := current.GetRequiredValue(), current.GetName(), current.GetStatus()
rewardResourceGroupID, sortOrder := current.GetRewardResourceGroupId(), current.GetSortOrder()
if req.RequiredValue != nil {
requiredValue = *req.RequiredValue
}
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if req.Status != nil {
status = normalizeStatus(*req.Status)
}
if req.RewardResourceGroupID != nil {
rewardResourceGroupID = *req.RewardResourceGroupID
}
if req.SortOrder != nil {
sortOrder = *req.SortOrder
}
if requiredValue < 0 || name == "" || !validStatus(status) || rewardResourceGroupID < 0 {
return nil, fmt.Errorf("等级规则参数不正确")
}
displayConfig := current.GetDisplayConfigJson()
if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
}
displayConfig, err := mergeRuleDisplayResources(displayConfig, req.LongBadgeResourceID, req.AvatarFrameResourceID, req.ShortBadgeResourceID)
if err != nil {
return nil, fmt.Errorf("展示配置 JSON 不正确")
}
if err := h.validateBatchRuleResources(c, track, current.GetDisplayConfigJson(), displayConfig, req, validatedResources); err != nil {
return nil, err
}
return &activityv1.LevelRule{Track: track, Level: level, RequiredValue: requiredValue, Name: name, Status: status, RewardResourceGroupId: rewardResourceGroupID, SortOrder: sortOrder, DisplayConfigJson: displayConfig}, nil
}
func (h *Handler) validateBatchRuleResources(c *gin.Context, track string, currentDisplayConfig string, nextDisplayConfig string, req ruleRequest, cache map[string]error) error {
checks := []struct {
changed bool
kind string
id int64
check func(*gin.Context, string, int64) error
}{
{changed: req.LongBadgeResourceID != nil && longBadgeResourceID(currentDisplayConfig) != longBadgeResourceID(nextDisplayConfig), kind: "long-badge", id: longBadgeResourceID(nextDisplayConfig), check: h.validateLevelLongBadgeResource},
{changed: req.AvatarFrameResourceID != nil && avatarFrameResourceID(currentDisplayConfig) != avatarFrameResourceID(nextDisplayConfig), kind: "avatar-frame", id: avatarFrameResourceID(nextDisplayConfig), check: h.validateLevelAvatarFrameResource},
{changed: req.ShortBadgeResourceID != nil && shortBadgeResourceID(currentDisplayConfig) != shortBadgeResourceID(nextDisplayConfig), kind: "short-badge", id: shortBadgeResourceID(nextDisplayConfig), check: h.validateLevelShortBadgeResource},
}
for _, item := range checks {
if !item.changed || item.id <= 0 {
continue
}
key := fmt.Sprintf("%s:%s:%d", item.kind, track, item.id)
if cached, ok := cache[key]; ok {
if cached != nil {
return cached
}
continue
}
err := item.check(c, track, item.id)
cache[key] = err
if err != nil {
return err
}
}
return nil
}
func batchTierProto(tierID int64, current *activityv1.LevelTier, req tierRequest) (*activityv1.LevelTier, error) {
track, minLevel, maxLevel, name, status := current.GetTrack(), current.GetMinLevel(), current.GetMaxLevel(), current.GetName(), current.GetStatus()
avatarFrameID, badgeID, rewardGroupID := current.GetDisplayAvatarFrameResourceId(), current.GetDisplayBadgeResourceId(), current.GetRewardResourceGroupId()
if req.Track != nil {
track = normalizeTrack(*req.Track)
}
if req.MinLevel != nil {
minLevel = *req.MinLevel
}
if req.MaxLevel != nil {
maxLevel = *req.MaxLevel
}
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if req.Status != nil {
status = normalizeStatus(*req.Status)
}
if req.DisplayAvatarFrameResourceID != nil {
avatarFrameID = *req.DisplayAvatarFrameResourceID
}
if req.DisplayBadgeResourceID != nil {
badgeID = *req.DisplayBadgeResourceID
}
if req.RewardResourceGroupID != nil {
rewardGroupID = *req.RewardResourceGroupID
}
if !validTrack(track) || minLevel < 0 || maxLevel < minLevel || name == "" || !validStatus(status) || avatarFrameID < 0 || badgeID < 0 || rewardGroupID < 0 {
return nil, fmt.Errorf("等级层参数不正确")
}
displayConfig := current.GetDisplayConfigJson()
if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
}
displayConfig, err := normalizeJSONObject(displayConfig)
if err != nil {
return nil, fmt.Errorf("展示配置 JSON 不正确")
}
return &activityv1.LevelTier{TierId: tierID, Track: track, MinLevel: minLevel, MaxLevel: maxLevel, Name: name, DisplayAvatarFrameResourceId: avatarFrameID, DisplayBadgeResourceId: badgeID, RewardResourceGroupId: rewardGroupID, Status: status, DisplayConfigJson: displayConfig}, nil
}
// upsertTier 复用已有等级段配置能力,保持 1-10、11-20 这类展示徽章仍由 activity-service 拥有。
func (h *Handler) upsertTier(c *gin.Context, tierID int64) {
var req tierRequest
@ -688,6 +864,22 @@ func tierFromProto(item *activityv1.LevelTier) tierDTO {
}
}
func rulesFromProto(items []*activityv1.LevelRule) []ruleDTO {
result := make([]ruleDTO, 0, len(items))
for _, item := range items {
result = append(result, ruleFromProto(item))
}
return result
}
func tiersFromProto(items []*activityv1.LevelTier) []tierDTO {
result := make([]tierDTO, 0, len(items))
for _, item := range items {
result = append(result, tierFromProto(item))
}
return result
}
func findTrack(items []*activityv1.LevelTrack, track string) (*activityv1.LevelTrack, bool) {
for _, item := range items {
if item.GetTrack() == track {

View File

@ -14,6 +14,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/users/level-config", middleware.RequirePermission("level-config:view"), h.ListLevelConfig)
protected.PUT("/admin/users/level-config/tracks/:track", middleware.RequirePermission("level-config:update"), h.UpdateTrack)
protected.PUT("/admin/users/level-config/rules/:track/:level", middleware.RequirePermission("level-config:update"), h.UpsertRule)
protected.PUT("/admin/users/level-config/batch", middleware.RequirePermission("level-config:update"), h.BatchUpsertLevelConfig)
protected.POST("/admin/users/level-config/tiers", middleware.RequirePermission("level-config:update"), h.CreateTier)
protected.PUT("/admin/users/level-config/tiers/:tier_id", middleware.RequirePermission("level-config:update"), h.UpdateTier)
}

View File

@ -39,8 +39,30 @@ type financeCoinSellerRechargeStats struct {
const (
financeCoinSellerRechargeMaxPageSize = 100
financeRechargeMergeMaxPrefetch = 5000
// 2^53 以内的 int64 在 float64 里精确可表示,不会被浏览器 JSON 圆整;
// 只有 legacy 合成/雪花区域 ID(远大于 2^53)需要按目录归一后才能做 SQL 精确等值。
maxExactFloat64RegionID = int64(1) << 53
)
// normalizeFinanceCoinSellerRegionID 把请求里可能被前端 float64 圆整过的 legacy 区域 ID
// 还原成区域目录里的精确值;目录不可用或没有对应项时原样返回(与 databi normalizeRegionID 同语义)。
func (h *Handler) normalizeFinanceCoinSellerRegionID(ctx context.Context, appCode string, regionID int64) int64 {
if h == nil || regionID <= maxExactFloat64RegionID {
return regionID
}
regions, err := h.listRechargeBillRegions(ctx, appCode)
if err != nil {
return regionID
}
for _, region := range regions {
if repository.RegionIDLooseEqual(region.RegionID, regionID) {
return region.RegionID
}
}
return regionID
}
func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, summary *rechargeBillSummaryDTO) error {
if summary == nil {
return nil
@ -122,6 +144,7 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return stats, nil
}
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
if h != nil && h.store != nil {
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode,
@ -258,6 +281,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return []rechargeBillDTO{}, 0, nil
}
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*2)

View File

@ -1,6 +1,7 @@
package payment
import (
"context"
"testing"
"hyapp-admin-server/internal/model"
@ -21,3 +22,20 @@ func TestFinanceCoinSellerRechargeOrderBillUsesBusinessTime(t *testing.T) {
t.Fatalf("finance bill must use selected business time: %+v", bill)
}
}
func TestNormalizeFinanceCoinSellerRegionIDRestoresRoundedLegacyID(t *testing.T) {
handler := New(&mockPaymentWallet{}, nil, nil, nil, nil, WithMoneyRegionSources(&staticMoneyRegionSource{
regions: []moneyRegionDTO{{AppCode: "aslan", RegionID: 2049040141349486594, RegionCode: "IN", Name: "印度"}},
}))
// 浏览器 JSON 把 2049040141349486594 圆整成 ...600SQL 精确等值前必须先还原成目录精确值。
if got := handler.normalizeFinanceCoinSellerRegionID(context.Background(), "aslan", 2049040141349486600); got != 2049040141349486594 {
t.Fatalf("rounded legacy region id should normalize to catalog value, got %d", got)
}
// 目录没有的 ID 原样返回2^53 以内的 hyapp 区域 ID 不查目录直接返回。
if got := handler.normalizeFinanceCoinSellerRegionID(context.Background(), "aslan", 9_100_000_000_000_000_001); got != 9_100_000_000_000_000_001 {
t.Fatalf("unknown region id should stay unchanged, got %d", got)
}
if got := handler.normalizeFinanceCoinSellerRegionID(context.Background(), "lalu", 10); got != 10 {
t.Fatalf("small region id should stay unchanged, got %d", got)
}
}

View File

@ -160,6 +160,56 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
return nil, false, nil
}
// RegionIDForCountryCode 把 legacy 用户的国家码解析成该 App 的区域 ID。
// 遍历顺序与 ListMoneyMasterData 相同createTime 倒序、先到先得),
// 与账单区域分布的 legacyCountryRegionIndex 保持同一优先级,保证同一个国家
// 在账单归因和币商订单归因里落进同一个区域(如 MM 同时挂在多个区域时)。
func (s *MongoMoneyRegionSource) RegionIDForCountryCode(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != appctx.Normalize(appCode) {
return 0, false, nil
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {
return 0, false, nil
}
_, regions, _, err := s.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return 0, false, err
}
return regionIDForCountryCodeInRegions(regions, countryCode)
}
func regionIDForCountryCodeInRegions(regions []moneyRegionDTO, countryCode string) (int64, bool, error) {
for _, region := range regions {
for _, item := range region.Countries {
if item == countryCode {
return region.RegionID, true, nil
}
}
}
return 0, false, nil
}
// RegionIDForCountryCode 在多个区域源中解析国家码归属;一个 App 只会命中一个源。
func RegionIDForCountryCode(ctx context.Context, sources []MoneyRegionSource, appCode string, countryCode string) (int64, bool, error) {
for _, source := range sources {
resolver, ok := source.(interface {
RegionIDForCountryCode(ctx context.Context, appCode string, countryCode string) (int64, bool, error)
})
if !ok {
continue
}
regionID, found, err := resolver.RegionIDForCountryCode(ctx, appCode, countryCode)
if err != nil {
return 0, false, err
}
if found {
return regionID, true, nil
}
}
return 0, false, nil
}
// RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态;
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
type RegionCatalogEntry struct {

View File

@ -177,3 +177,54 @@ func TestMoneyRegionSourceConfigDefaults(t *testing.T) {
t.Fatalf("default money region source mismatch: %+v", cfg.MoneyRegionSources)
}
}
func TestRegionIDForCountryCodeInRegionsFirstMatchWins(t *testing.T) {
// 目录顺序即 createTime 倒序MM 同时挂在缅甸区和巴基斯坦区时必须命中最新的(与账单
// legacyCountryRegionIndex 同优先级),否则同一用户的账单和币商订单会归进两个区域。
regions := []moneyRegionDTO{
{AppCode: "aslan", RegionID: 2064044089182887937, RegionCode: "MM", Countries: []string{"MM"}},
{AppCode: "aslan", RegionID: 2049040320781811714, RegionCode: "PK", Countries: []string{"PK", "MM"}},
}
regionID, found, err := regionIDForCountryCodeInRegions(regions, "MM")
if err != nil || !found || regionID != 2064044089182887937 {
t.Fatalf("MM should resolve to the newest region: id=%d found=%v err=%v", regionID, found, err)
}
regionID, found, err = regionIDForCountryCodeInRegions(regions, "PK")
if err != nil || !found || regionID != 2049040320781811714 {
t.Fatalf("PK region mismatch: id=%d found=%v err=%v", regionID, found, err)
}
if _, found, _ = regionIDForCountryCodeInRegions(regions, "BR"); found {
t.Fatalf("unmapped country should not resolve")
}
}
type staticCountryRegionResolver struct {
staticMoneyRegionSource
appCode string
}
func (s *staticCountryRegionResolver) RegionIDForCountryCode(_ context.Context, appCode string, countryCode string) (int64, bool, error) {
if appCode != s.appCode {
return 0, false, nil
}
return regionIDForCountryCodeInRegions(s.regions, strings.ToUpper(strings.TrimSpace(countryCode)))
}
func TestRegionIDForCountryCodeSkipsSourcesWithoutResolver(t *testing.T) {
sources := []MoneyRegionSource{
&staticMoneyRegionSource{},
&staticCountryRegionResolver{
appCode: "yumi",
staticMoneyRegionSource: staticMoneyRegionSource{
regions: []moneyRegionDTO{{AppCode: "yumi", RegionID: 1002, RegionCode: "INDIA", Countries: []string{"IN"}}},
},
},
}
regionID, found, err := RegionIDForCountryCode(context.Background(), sources, "yumi", "IN")
if err != nil || !found || regionID != 1002 {
t.Fatalf("cross-source resolve mismatch: id=%d found=%v err=%v", regionID, found, err)
}
if _, found, _ = RegionIDForCountryCode(context.Background(), sources, "aslan", "IN"); found {
t.Fatalf("other app should not resolve")
}
}

View File

@ -6,31 +6,33 @@ import (
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
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)
protected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
protected.POST("/admin/payment/temporary-links", middleware.RequirePermission(temporaryPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-methods/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
protected.POST("/admin/payment/third-party-rates/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentRates)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)
protected.DELETE("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:delete"), h.DeleteRechargeProduct)
workspaceProtected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
workspaceProtected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
workspaceProtected.GET("/admin/payment/recharge-bills/overview", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillOverview)
// 导出、刷新和配置写操作必须与列表查看解耦,避免只读产品角色获得副作用入口。
workspaceProtected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:export"), h.ExportRechargeBills)
workspaceProtected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:refresh"), h.RefreshGoogleRechargePaidDetails)
workspaceProtected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
workspaceProtected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
workspaceProtected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
workspaceProtected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)
workspaceProtected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
workspaceProtected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
workspaceProtected.POST("/admin/payment/temporary-links", middleware.RequirePermission(temporaryPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
workspaceProtected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
appProtected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
appProtected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequireAnyPermission("payment-third-party:update-method", "payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
appProtected.POST("/admin/payment/third-party-methods/sync", middleware.RequireAnyPermission("payment-third-party:sync-methods", "payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
appProtected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequireAnyPermission("payment-third-party:update-rate", "payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
appProtected.POST("/admin/payment/third-party-rates/sync", middleware.RequireAnyPermission("payment-third-party:sync-rates", "payment-third-party:update"), h.SyncThirdPartyPaymentRates)
appProtected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
appProtected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
appProtected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)
appProtected.DELETE("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:delete"), h.DeleteRechargeProduct)
}

View File

@ -34,22 +34,23 @@ func UserDTO(user model.User) map[string]any {
teamName := UserTeamName(user)
return map[string]any{
"id": user.ID,
"account": user.Username,
"username": user.Username,
"name": user.Name,
"team": teamName,
"teamId": user.TeamID,
"teamName": teamName,
"status": user.Status,
"mfa": BoolText(user.MFAEnabled),
"mfaEnabled": user.MFAEnabled,
"lastLogin": lastLogin,
"lastLoginMs": user.LastLoginAtMS,
"roles": user.Roles,
"role": role,
"roleIds": roleIDs,
"permissions": PermissionsForUser(user),
"id": user.ID,
"account": user.Username,
"username": user.Username,
"name": user.Name,
"team": teamName,
"teamId": user.TeamID,
"teamName": teamName,
"status": user.Status,
"appScopeMode": user.AppScopeMode,
"mfa": BoolText(user.MFAEnabled),
"mfaEnabled": user.MFAEnabled,
"lastLogin": lastLogin,
"lastLoginMs": user.LastLoginAtMS,
"roles": user.Roles,
"role": role,
"roleIds": roleIDs,
"permissions": PermissionsForUser(user),
}
}

View File

@ -0,0 +1,130 @@
package repository
import (
"errors"
"sort"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
)
type AppAccess struct {
Mode string
AppCodes []string
}
func (access AppAccess) Allows(appCode string) bool {
appCode = strings.ToLower(strings.TrimSpace(appCode))
if appCode == "" {
return false
}
if access.Mode == model.UserAppScopeModeAll {
return true
}
for _, allowed := range access.AppCodes {
if allowed == appCode {
return true
}
}
return false
}
func (s *Store) AppAccessForUser(userID uint) (AppAccess, error) {
if userID == 0 {
return AppAccess{Mode: model.UserAppScopeModeNone}, nil
}
var user model.User
if err := s.db.Select("id", "app_scope_mode").First(&user, userID).Error; err != nil {
return AppAccess{}, err
}
mode := normalizeAppScopeMode(user.AppScopeMode)
if mode != model.UserAppScopeModeSelected {
return AppAccess{Mode: mode}, nil
}
var scopes []model.UserAppScope
if err := s.db.Where("user_id = ?", userID).Order("app_code ASC").Find(&scopes).Error; err != nil {
return AppAccess{}, err
}
appCodes := make([]string, 0, len(scopes))
for _, scope := range scopes {
appCodes = append(appCodes, scope.AppCode)
}
return AppAccess{Mode: mode, AppCodes: appCodes}, nil
}
func (s *Store) ReplaceUserAppScopes(userID uint, mode string, appCodes []string) (AppAccess, error) {
if userID == 0 {
return AppAccess{}, errors.New("user id is required")
}
rawMode := strings.ToLower(strings.TrimSpace(mode))
if rawMode != model.UserAppScopeModeAll && rawMode != model.UserAppScopeModeSelected && rawMode != model.UserAppScopeModeNone {
return AppAccess{}, errors.New("app scope mode must be all, selected, or none")
}
mode = rawMode
normalizedCodes := normalizeAppCodes(appCodes)
if mode == model.UserAppScopeModeSelected && len(normalizedCodes) == 0 {
return AppAccess{}, errors.New("selected app scope requires app codes")
}
if mode != model.UserAppScopeModeSelected {
normalizedCodes = nil
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.First(&user, userID).Error; err != nil {
return err
}
// 先删除旧明细再更新模式,使 all/none 不残留会被未来代码误读的 selected 行。
if err := tx.Where("user_id = ?", userID).Delete(&model.UserAppScope{}).Error; err != nil {
return err
}
if len(normalizedCodes) > 0 {
scopes := make([]model.UserAppScope, 0, len(normalizedCodes))
for _, appCode := range normalizedCodes {
scopes = append(scopes, model.UserAppScope{UserID: userID, AppCode: appCode})
}
if err := tx.Create(&scopes).Error; err != nil {
return err
}
}
return tx.Model(&user).Update("app_scope_mode", mode).Error
})
if err != nil {
return AppAccess{}, err
}
return AppAccess{Mode: mode, AppCodes: normalizedCodes}, nil
}
func normalizeAppScopeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case model.UserAppScopeModeAll:
return model.UserAppScopeModeAll
case model.UserAppScopeModeSelected:
return model.UserAppScopeModeSelected
default:
return model.UserAppScopeModeNone
}
}
func normalizeAppCodes(appCodes []string) []string {
seen := make(map[string]struct{}, len(appCodes))
out := make([]string, 0, len(appCodes))
for _, value := range appCodes {
// appctx.Normalize 会把空值回退 laluApp scope 的空值代表无授权,必须先独立过滤。
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
value = appctx.Normalize(value)
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}

View File

@ -0,0 +1,68 @@
package repository
import (
"testing"
"hyapp-admin-server/internal/model"
"github.com/DATA-DOG/go-sqlmock"
)
func TestAppAccessForSelectedUser(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT `id`,`app_scope_mode` FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "app_scope_mode"}).AddRow(7, model.UserAppScopeModeSelected))
mock.ExpectQuery("SELECT \\* FROM `admin_user_app_scopes` WHERE user_id = \\? ORDER BY app_code ASC").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "app_code", "created_at_ms", "updated_at_ms"}).
AddRow(1, 7, "huwaa", int64(1), int64(1)).
AddRow(2, 7, "lalu", int64(1), int64(1)))
access, err := store.AppAccessForUser(7)
if err != nil {
t.Fatalf("get app access: %v", err)
}
if !access.Allows("LALU") || !access.Allows("huwaa") || access.Allows("fami") {
t.Fatalf("unexpected selected access: %+v", access)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestReplaceUserAppScopesNormalizesSelectedCodes(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "app_scope_mode"}).AddRow(9, model.UserAppScopeModeNone))
mock.ExpectExec("DELETE FROM `admin_user_app_scopes` WHERE user_id = \\?").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("INSERT INTO `admin_user_app_scopes`").WillReturnResult(sqlmock.NewResult(1, 2))
mock.ExpectExec("UPDATE `admin_users` SET `app_scope_mode`=\\?,`updated_at_ms`=\\? WHERE `id` = \\?").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
access, err := store.ReplaceUserAppScopes(9, model.UserAppScopeModeSelected, []string{" LALU ", "huwaa", "lalu", ""})
if err != nil {
t.Fatalf("replace app scopes: %v", err)
}
if len(access.AppCodes) != 2 || access.AppCodes[0] != "huwaa" || access.AppCodes[1] != "lalu" {
t.Fatalf("unexpected normalized access: %+v", access)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestReplaceUserAppScopesRejectsEmptySelected(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
if _, err := store.ReplaceUserAppScopes(9, model.UserAppScopeModeSelected, nil); err == nil {
t.Fatal("expected selected mode without app codes to fail")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -91,6 +91,12 @@ func (s *Store) ListUserMoneyScopes(userID uint) ([]model.UserMoneyScope, error)
return scopes, err
}
func (s *Store) ListAllUserMoneyScopes() ([]model.UserMoneyScope, error) {
var scopes []model.UserMoneyScope
err := s.db.Order("app_code ASC, region_id ASC, user_id ASC").Find(&scopes).Error
return scopes, err
}
func (s *Store) ReplaceUserMoneyScopes(userID uint, scopes []model.UserMoneyScope) error {
if userID == 0 {
return errors.New("user id is required")

View File

@ -60,6 +60,28 @@ func TestReplaceUserMoneyScopesRejectsInvalidScope(t *testing.T) {
}
}
func TestListAllUserMoneyScopes(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` ORDER BY app_code ASC, region_id ASC, user_id ASC").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"}).
AddRow(1, 7, "aslan", 2, int64(1700000000000), int64(1700000000000)).
AddRow(2, 9, "aslan", 5, int64(1700000000000), int64(1700000000000)).
AddRow(3, 9, "lalu", 0, int64(1700000000000), int64(1700000000000)))
scopes, err := store.ListAllUserMoneyScopes()
if err != nil {
t.Fatalf("list all money scopes failed: %v", err)
}
if len(scopes) != 3 || scopes[0].UserID != 7 || scopes[1].UserID != 9 || scopes[2].AppCode != "lalu" || scopes[2].RegionID != 0 {
t.Fatalf("all money scopes mismatch: %+v", scopes)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func newRepositorySQLMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()

View File

@ -76,33 +76,48 @@ func (s *Store) SyncDefaultPermissions() error {
if err := s.syncDefaultPermissions(tx); err != nil {
return err
}
return s.syncDefaultRolePermissionMigrations(tx)
return s.syncManagedDefaultRolePermissions(tx)
})
}
func (s *Store) syncDefaultRolePermissionMigrations(tx *gorm.DB) error {
var platformAdmin model.Role
err := tx.Where("code = ?", "platform-admin").First(&platformAdmin).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
func (s *Store) syncManagedDefaultRolePermissions(tx *gorm.DB) error {
var allPermissions []model.Permission
if err := tx.Find(&allPermissions).Error; err != nil {
return err
}
if platformAdmin.ID > 0 {
var permissions []model.Permission
if err := tx.Find(&permissions).Error; err != nil {
return err
}
if err := tx.Model(&platformAdmin).Association("Permissions").Replace(permissions); err != nil {
return err
}
}
var opsAdmin model.Role
err = tx.Where("code = ?", "ops-admin").First(&opsAdmin).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if opsAdmin.ID > 0 {
return s.appendRolePermissionsByCode(tx, &opsAdmin, defaultRolePermissionMigrationCodes(opsAdmin.Code))
for _, definition := range managedDefaultRoles {
var role model.Role
result := tx.Where("code = ?", definition.Code).Limit(1).Find(&role)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
role = definition
if err := tx.Create(&role).Error; err != nil {
return err
}
} else if err := tx.Model(&role).Updates(map[string]any{
"name": definition.Name,
"description": definition.Description,
}).Error; err != nil {
return err
}
permissions := allPermissions
if definition.Code != roleCodePlatformAdmin {
permissions = nil
codes := defaultRolePermissionCodes(definition.Code)
if len(codes) > 0 {
if err := tx.Where("code IN ?", codes).Find(&permissions).Error; err != nil {
return err
}
}
}
// “同步权限”是恢复固定岗位模板的显式管理动作,必须删除历史跨模块授权,不能只追加缺失项。
if err := tx.Model(&role).Association("Permissions").Replace(permissions); err != nil {
return err
}
}
return nil
}

View File

@ -84,6 +84,7 @@ func (s *Store) AutoMigrate() error {
&model.LoginLog{},
&model.OperationLog{},
&model.DataScope{},
&model.UserAppScope{},
&model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},

View File

@ -0,0 +1,242 @@
package repository
import "hyapp-admin-server/internal/model"
const (
roleCodePlatformAdmin = "platform-admin"
roleCodeOperationsLead = "ops-admin"
roleCodeOperationsSpecialist = "operations-specialist"
roleCodeProductLead = "product-lead"
roleCodeProductSpecialist = "product-specialist"
roleCodeFinanceLead = "finance-lead"
roleCodeFinanceSpecialist = "finance-specialist"
)
// managedDefaultRoles 对应产品确认的七类固定岗位。platform-admin 与 ops-admin 保留历史 code
// 避免存量用户关联、财务全量数据范围判断以及已执行迁移中的角色引用失效;展示名称按新岗位体系收敛。
var managedDefaultRoles = []model.Role{
{Name: "超级管理员", Code: roleCodePlatformAdmin, Description: "拥有管理后台全部权限"},
{Name: "运营负责人", Code: roleCodeOperationsLead, Description: "负责运营、团队、活动和日常配置管理"},
{Name: "运营专员", Code: roleCodeOperationsSpecialist, Description: "负责日常运营执行,不含后台设置和高风险财务配置"},
{Name: "产品负责人", Code: roleCodeProductLead, Description: "负责产品配置、资源、活动和游戏管理"},
{Name: "产品专员", Code: roleCodeProductSpecialist, Description: "负责资源配置,并只读查看关联业务模块"},
{Name: "财务负责人", Code: roleCodeFinanceLead, Description: "负责财务看板、充值对账和提现审核"},
{Name: "财务专员", Code: roleCodeFinanceSpecialist, Description: "负责财务看板、充值对账和提现审核"},
}
var (
workbenchOverview = []string{"overview:view"}
workbenchOperations = []string{
"finance-order:coin-seller-recharge:view",
"finance-order:coin-seller-recharge:create",
"finance-order:coin-seller-recharge:verify",
"finance-order:coin-seller-recharge:grant",
"finance-withdrawal:view",
"finance-withdrawal:audit",
}
workbenchFinance = []string{
"finance:view",
"payment-bill:view",
"payment-bill:export",
"finance-withdrawal:view",
"finance-withdrawal:audit",
"room-rps-order:view",
}
appUserRead = []string{
"app-user:view", "app-user:export",
"level-config:view", "pretty-id:view", "risk-config:view", "region-block:view",
}
appUserOperationsManage = []string{
"app-user:update", "app-user:level", "app-user:status", "app-user:password",
"level-config:update", "pretty-id:update", "pretty-id:grant", "risk-config:update", "region-block:update",
}
appUserProductManage = []string{
"level-config:update", "pretty-id:update", "pretty-id:grant", "risk-config:update", "region-block:update",
}
teamRead = []string{
"bd:view", "agency:view", "host:view",
"host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view",
"coin-seller:view", "coin-seller-sub-application:view", "host-withdrawal:view", "point-withdrawal-config:view",
}
teamStructureManage = []string{
"bd:create", "bd:update", "agency:create", "agency:status", "agency:delete",
}
teamLeadManage = []string{
"host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish",
"team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete",
"host-salary-settlement:settle",
"coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-seller-sub-application:audit", "point-withdrawal-config:update",
}
roomRead = []string{
"room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view",
}
roomManage = []string{
"room:update", "room:delete", "room-pin:create", "room-pin:cancel",
"room-config:update", "room-whitelist:update", "room-robot:create", "room-robot:update",
}
appConfigRead = []string{
"app-config:view", "app-version:view", "full-server-notice:view",
}
appConfigManage = []string{
"app-config:update", "full-server-notice:send",
}
appVersionManage = []string{
"app-version:create", "app-version:update", "app-version:delete",
}
resourceRead = []string{
"resource:view", "resource-shop:view", "resource-group:view",
"gift:view", "resource-grant:view", "emoji-pack:view",
}
resourceManage = []string{
"resource:create", "resource:update", "resource:delete", "upload:create",
"resource-shop:update", "resource-group:create", "resource-group:update",
"gift:create", "gift:update", "gift:status", "gift:delete",
"resource-grant:create", "resource-grant:revoke", "emoji-pack:create",
}
operationsRead = []string{
"coin-ledger:view", "coin-seller-ledger:view", "report:view",
}
operationsProductManage = []string{
"gift-diamond:view", "gift-diamond:update",
}
operationsLeadManage = []string{
"coin-adjustment:view", "coin-adjustment:create",
"gift-diamond:view", "gift-diamond:update",
"policy-template:view", "policy-template:create",
"policy-instance:create", "policy-instance:update", "policy-instance:publish",
}
paymentRead = []string{
"payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view",
}
paymentBillExport = []string{"payment-bill:export"}
paymentBillRefresh = []string{"payment-bill:refresh"}
paymentOperationsManage = []string{
"payment-third-party:update-method", "payment-third-party:sync-methods",
"payment-product:create", "payment-product:update", "payment-product:delete",
}
activityRead = []string{
"daily-task:view", "first-recharge-reward:view", "cumulative-recharge-reward:view",
"invite-activity-reward:view", "seven-day-checkin:view", "room-rocket:view", "room-turnover-reward:view",
"wheel:view", "weekly-star:view", "agency-opening:view", "user-leaderboard:view", "red-packet:view",
"cp-config:view", "cp-weekly-rank:view", "vip-config:view", "achievement:view",
}
activityManage = []string{
"daily-task:create", "daily-task:update", "daily-task:status",
"first-recharge-reward:update", "cumulative-recharge-reward:update", "invite-activity-reward:update",
"seven-day-checkin:update", "room-rocket:update", "room-turnover-reward:update", "room-turnover-reward:retry",
"wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:create", "agency-opening:update", "red-packet:update",
"cp-config:update", "cp-weekly-rank:update", "vip-config:update",
"achievement:create", "achievement:update",
}
gameRead = []string{
"game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view",
}
gameManage = []string{
"game-catalog:platform", "game-catalog:create", "game-catalog:update", "game-catalog:status", "game-catalog:delete",
"self-game:update",
"game-robot:create", "game-robot:update", "game-robot:delete",
"room-rps-config:update", "room-rps-order:update",
}
gameOperationsSpecialistManage = []string{
"game-robot:create", "game-robot:update", "game-robot:delete", "room-rps-config:update",
}
geoRead = []string{"country:view", "region:view"}
geoManage = []string{"country:create", "country:update", "country:status", "region:create", "region:update", "region:status"}
)
// defaultRolePermissionCodes 只组合表格中明确勾选的模块。相同页面曾复用 game:* 或
// payment-third-party:update 的地方已经拆成页面级权限,避免为了一个按钮把相邻模块一起授权。
func defaultRolePermissionCodes(code string) []string {
switch code {
case roleCodeOperationsLead:
return mergePermissionCodes(
workbenchOverview, workbenchOperations,
appUserRead, appUserOperationsManage,
teamRead, teamStructureManage, teamLeadManage,
roomRead, roomManage,
[]string{"app-config:view"}, appConfigManage,
resourceRead, resourceManage,
operationsRead, operationsLeadManage,
paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage,
activityRead, activityManage,
gameRead, gameManage,
geoRead, geoManage,
)
case roleCodeOperationsSpecialist:
return mergePermissionCodes(
workbenchOverview, workbenchOperations,
appUserRead, appUserOperationsManage,
teamRead, teamStructureManage,
roomRead, roomManage,
[]string{"app-config:view"}, appConfigManage,
resourceRead, resourceManage,
operationsRead,
paymentRead, paymentBillExport, paymentBillRefresh,
activityRead,
gameRead, gameOperationsSpecialistManage,
geoRead,
)
case roleCodeProductLead:
return mergePermissionCodes(
workbenchOverview,
appUserRead, appUserProductManage,
teamRead,
roomRead, roomManage,
appConfigRead, appConfigManage, appVersionManage,
resourceRead, resourceManage,
operationsRead, operationsProductManage,
paymentRead,
activityRead, activityManage,
gameRead, gameManage,
geoRead,
)
case roleCodeProductSpecialist:
return mergePermissionCodes(
workbenchOverview,
appUserRead,
roomRead, []string{"room-robot:create", "room-robot:update"},
appConfigRead,
resourceRead, resourceManage,
operationsRead,
paymentRead,
activityRead,
gameRead,
geoRead,
)
case roleCodeFinanceLead, roleCodeFinanceSpecialist:
return mergePermissionCodes(workbenchOverview, workbenchFinance)
default:
return nil
}
}
func mergePermissionCodes(groups ...[]string) []string {
total := 0
for _, group := range groups {
total += len(group)
}
out := make([]string, 0, total)
seen := make(map[string]struct{}, total)
for _, group := range groups {
for _, code := range group {
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
}
return out
}

View File

@ -111,6 +111,8 @@ var defaultPermissions = []model.Permission{
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
{Name: "币商提现白名单查看", Code: "point-withdrawal-config:view", Kind: "menu"},
{Name: "币商提现白名单编辑", Code: "point-withdrawal-config:update", Kind: "button"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
@ -124,8 +126,14 @@ var defaultPermissions = []model.Permission{
{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-bill:export", Kind: "button"},
{Name: "支付账单刷新", Code: "payment-bill:refresh", Kind: "button"},
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button", Description: "兼容旧角色的三方支付写权限"},
{Name: "三方支付方式更新", Code: "payment-third-party:update-method", Kind: "button"},
{Name: "三方支付方式同步", Code: "payment-third-party:sync-methods", Kind: "button"},
{Name: "三方支付汇率编辑", Code: "payment-third-party:update-rate", Kind: "button"},
{Name: "三方支付汇率同步", Code: "payment-third-party:sync-rates", Kind: "button"},
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
@ -134,15 +142,32 @@ var defaultPermissions = []model.Permission{
{Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"},
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
{Name: "用户提现申请审核", Code: "finance-withdrawal:audit", Kind: "button"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
{Name: "内购配置删除", Code: "payment-product:delete", Kind: "button"},
{Name: "游戏查看", Code: "game:view", Kind: "menu"},
{Name: "游戏创建", Code: "game:create", Kind: "button"},
{Name: "游戏更新", Code: "game:update", Kind: "button"},
{Name: "游戏状态", Code: "game:status", Kind: "button"},
{Name: "游戏删除", Code: "game:delete", Kind: "button"},
{Name: "游戏查看", Code: "game:view", Kind: "menu", Description: "兼容旧角色的游戏管理查看权限"},
{Name: "游戏创建", Code: "game:create", Kind: "button", Description: "兼容旧角色的游戏管理创建权限"},
{Name: "游戏更新", Code: "game:update", Kind: "button", Description: "兼容旧角色的游戏管理更新权限"},
{Name: "游戏状态", Code: "game:status", Kind: "button", Description: "兼容旧角色的游戏管理状态权限"},
{Name: "游戏删除", Code: "game:delete", Kind: "button", Description: "兼容旧角色的游戏管理删除权限"},
{Name: "游戏列表查看", Code: "game-catalog:view", Kind: "menu"},
{Name: "游戏平台配置", Code: "game-catalog:platform", Kind: "button"},
{Name: "游戏列表创建", Code: "game-catalog:create", Kind: "button"},
{Name: "游戏列表更新", Code: "game-catalog:update", Kind: "button"},
{Name: "游戏列表状态", Code: "game-catalog:status", Kind: "button"},
{Name: "游戏列表删除", Code: "game-catalog:delete", Kind: "button"},
{Name: "自研游戏查看", Code: "self-game:view", Kind: "menu"},
{Name: "自研游戏配置", Code: "self-game:update", Kind: "button"},
{Name: "全站机器人查看", Code: "game-robot:view", Kind: "menu"},
{Name: "全站机器人创建", Code: "game-robot:create", Kind: "button"},
{Name: "全站机器人更新", Code: "game-robot:update", Kind: "button"},
{Name: "全站机器人删除", Code: "game-robot:delete", Kind: "button"},
{Name: "房内猜拳配置查看", Code: "room-rps-config:view", Kind: "menu"},
{Name: "房内猜拳配置更新", Code: "room-rps-config:update", Kind: "button"},
{Name: "房内猜拳订单查看", Code: "room-rps-order:view", Kind: "menu"},
{Name: "房内猜拳订单更新", Code: "room-rps-order:update", Kind: "button"},
{Name: "每日任务查看", Code: "daily-task:view", Kind: "menu"},
{Name: "每日任务创建", Code: "daily-task:create", Kind: "button"},
{Name: "每日任务更新", Code: "daily-task:update", Kind: "button"},
@ -152,6 +177,12 @@ var defaultPermissions = []model.Permission{
{Name: "成就配置更新", Code: "achievement:update", Kind: "button"},
{Name: "注册奖励查看", Code: "registration-reward:view", Kind: "menu"},
{Name: "注册奖励更新", Code: "registration-reward:update", Kind: "button"},
{Name: "首充礼包查看", Code: "first-recharge-reward:view", Kind: "menu"},
{Name: "首充礼包更新", Code: "first-recharge-reward:update", Kind: "button"},
{Name: "累充奖励查看", Code: "cumulative-recharge-reward:view", Kind: "menu"},
{Name: "累充奖励更新", Code: "cumulative-recharge-reward:update", Kind: "button"},
{Name: "邀请活动查看", Code: "invite-activity-reward:view", Kind: "menu"},
{Name: "邀请活动更新", Code: "invite-activity-reward:update", Kind: "button"},
{Name: "七日签到查看", Code: "seven-day-checkin:view", Kind: "menu"},
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
@ -160,6 +191,9 @@ var defaultPermissions = []model.Permission{
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},
{Name: "房间火箭更新", Code: "room-rocket:update", Kind: "button"},
{Name: "房间流水奖励查看", Code: "room-turnover-reward:view", Kind: "menu"},
{Name: "房间流水奖励更新", Code: "room-turnover-reward:update", Kind: "button"},
{Name: "房间流水奖励重试", Code: "room-turnover-reward:retry", Kind: "button"},
{Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"},
{Name: "红包配置查看", Code: "red-packet:view", Kind: "menu"},
{Name: "红包配置更新", Code: "red-packet:update", Kind: "button"},
@ -338,9 +372,11 @@ func (s *Store) seedMenus() error {
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 79, Visible: true},
{ParentID: &activityID, Title: "代理开业活动", Code: "agency-opening", Path: "/activities/agency-opening", Icon: "workspace_premium", PermissionCode: "agency-opening:view", Sort: 80, Visible: true},
{ParentID: &activityID, Title: "转盘抽奖", Code: "wheel", Path: "/activities/wheel", Icon: "casino", PermissionCode: "wheel:view", Sort: 81, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "game:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game-catalog:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "self-game:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game-robot:view", Sort: 90, Visible: true},
{ParentID: &gameID, Title: "房内猜拳配置", Code: "room-rps-config", Path: "/games/room-rps/config", Icon: "settings", PermissionCode: "room-rps-config:view", Sort: 100, Visible: true},
{ParentID: &gameID, Title: "房内猜拳订单", Code: "room-rps-challenges", Path: "/games/room-rps/challenges", Icon: "history", PermissionCode: "room-rps-order:view", Sort: 110, Visible: true},
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
{ParentID: &hostOrgID, Title: "经理列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "bd:view", Sort: 80, Visible: true},
@ -437,31 +473,34 @@ func (s *Store) seedRoles() error {
return err
}
roles := []model.Role{
{Name: "平台管理员", Code: "platform-admin", Description: "拥有管理后台全部权限"},
{Name: "运维管理员", Code: "ops-admin", Description: "管理用户状态和后台操作"},
{Name: "审计员", Code: "auditor", Description: "查看日志和审计记录"},
{Name: "只读用户", Code: "readonly", Description: "只读查看后台数据"},
}
for _, role := range roles {
if err := s.db.Where("code = ?", role.Code).FirstOrCreate(&role).Error; err != nil {
for _, definition := range managedDefaultRoles {
role := model.Role{}
result := s.db.Where("code = ?", definition.Code).Limit(1).Find(&role)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
role = definition
if err := s.db.Create(&role).Error; err != nil {
return err
}
} else if err := s.db.Model(&role).Updates(map[string]any{
"name": definition.Name,
"description": definition.Description,
}).Error; err != nil {
return err
}
if role.Code == "platform-admin" {
if role.Code == roleCodePlatformAdmin {
if err := s.db.Model(&role).Association("Permissions").Replace(permissions); err != nil {
return err
}
continue
}
// 预置角色只在没有任何权限时写入默认模板,避免后续人工调整被启动种子覆盖。
if s.db.Model(&role).Association("Permissions").Count() == 0 {
if err := s.replaceRolePermissionsByCode(&role, defaultRolePermissionCodes(role.Code)); err != nil {
return err
}
continue
}
if err := s.appendRolePermissionsByCode(s.db, &role, defaultRolePermissionMigrationCodes(role.Code)); err != nil {
// 显式 bootstrap 的职责是恢复产品确认的岗位模板;普通启动不会执行 Seed
// 因此这里可以精确替换固定岗位权限,同时不影响用户自行创建的其他角色。
if err := s.replaceRolePermissionsByCode(&role, defaultRolePermissionCodes(role.Code)); err != nil {
return err
}
}
@ -486,7 +525,7 @@ func (s *Store) seedUsers(cfg config.Config) error {
}
var role model.Role
if err := s.db.Where("code = ?", "platform-admin").First(&role).Error; err != nil {
if err := s.db.Where("code = ?", roleCodePlatformAdmin).First(&role).Error; err != nil {
return err
}
@ -498,6 +537,8 @@ func (s *Store) seedUsers(cfg config.Config) error {
Team: "运营团队",
TeamID: &operationsTeamID,
Status: model.UserStatusActive,
// 全新环境的唯一 bootstrap 管理员必须能进入 App 主站,后续仍可在用户编辑中改成 selected/none。
AppScopeMode: model.UserAppScopeModeAll,
MFAEnabled: false,
}
if err := s.db.Create(&user).Error; err != nil {
@ -590,197 +631,3 @@ func (s *Store) replaceRolePermissionsByCode(role *model.Role, codes []string) e
}
return s.db.Model(role).Association("Permissions").Replace(permissions)
}
func (s *Store) appendRolePermissionsByCode(tx *gorm.DB, role *model.Role, codes []string) error {
if len(codes) == 0 {
return nil
}
var current model.Role
if err := tx.Preload("Permissions").First(&current, role.ID).Error; err != nil {
return err
}
existing := make(map[string]struct{}, len(current.Permissions))
for _, permission := range current.Permissions {
existing[permission.Code] = struct{}{}
}
missingCodes := make([]string, 0, len(codes))
for _, code := range codes {
if _, ok := existing[code]; !ok {
missingCodes = append(missingCodes, code)
}
}
if len(missingCodes) == 0 {
return nil
}
var permissions []model.Permission
if err := tx.Where("code IN ?", missingCodes).Find(&permissions).Error; err != nil {
return err
}
if len(permissions) == 0 {
return nil
}
return tx.Model(role).Association("Permissions").Append(permissions)
}
func defaultRolePermissionCodes(code string) []string {
switch code {
case "ops-admin":
return []string{
"overview:view",
"user:view", "user:status",
"team:view", "team:create", "team:update",
"app-user:view", "app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password", "app-user:token",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"risk-config:view", "risk-config:update",
"region-block:view", "region-block:update",
"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: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",
"gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
"emoji-pack:view", "emoji-pack:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update",
"seven-day-checkin:view", "seven-day-checkin:update",
"room-rocket:view", "room-rocket:update",
"red-packet:view", "red-packet:update",
"cp-config:view", "cp-config:update", "cp-weekly-rank:view", "cp-weekly-rank:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:view", "agency-opening:create", "agency-opening:update",
"log:view",
"job:view", "job:cancel", "export:create",
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller-sub-application:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"team:view",
"app-user:view",
"level-config:view",
"pretty-id:view",
"risk-config:view",
"region-block:view",
"room:view",
"room-pin:view",
"room-config:view",
"room-whitelist:view",
"room-robot:view",
"app-config:view",
"app-version:view",
"resource:view",
"resource-shop:view",
"resource-group:view",
"resource-grant:view",
"gift:view",
"emoji-pack:view",
"country:view",
"region:view",
"host:view",
"host-agency-policy:view",
"team-salary-policy:view",
"host-salary-settlement:view",
"host-withdrawal:view",
"agency:view",
"bd:view",
"coin-seller:view",
"coin-seller-sub-application:view",
"coin-ledger:view",
"coin-seller-ledger:view",
"coin-adjustment:view",
"report:view",
"gift-diamond:view",
"policy-template:view",
"full-server-notice:view",
"lucky-gift:view",
"wheel:view",
"payment-bill:view",
"payment-third-party:view",
"payment-temporary-link:view",
"finance-order:coin-seller-recharge:view",
"payment-product:view",
"game:view",
"daily-task:view",
"achievement:view",
"seven-day-checkin:view",
"room-rocket:view",
"red-packet:view",
"cp-config:view",
"cp-weekly-rank:view",
"vip-config:view",
"weekly-star:view",
"agency-opening:view",
"role:view",
"permission:view",
"menu:view",
"log:view",
"job:view",
}
default:
return nil
}
}
func defaultRolePermissionMigrationCodes(code string) []string {
switch code {
case "ops-admin":
return []string{
"team:view", "team:create", "team:update",
"app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password", "app-user:token",
"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",
"level-config:view", "level-config:update",
"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: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",
"gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
"emoji-pack:view", "emoji-pack:create",
"upload:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update",
"seven-day-checkin:view", "seven-day-checkin:update",
"room-rocket:view", "room-rocket:update",
"red-packet:view", "red-packet:update",
"cp-config:view", "cp-config:update", "cp-weekly-rank:view", "cp-weekly-rank:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:view", "agency-opening:create", "agency-opening:update",
}
case "auditor", "readonly":
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-seller-sub-application:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
default:
return nil
}
}

View File

@ -1,6 +1,12 @@
package repository
import "testing"
import (
"os"
"reflect"
"regexp"
"sort"
"testing"
)
func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
permissions := map[string]struct{}{}
@ -41,22 +47,22 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
}
func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
opsPermissions := seedTestPermissionSet(defaultRolePermissionCodes("ops-admin"))
opsMigrationPermissions := seedTestPermissionSet(defaultRolePermissionMigrationCodes("ops-admin"))
opsPermissions := seedTestPermissionSet(defaultRolePermissionCodes(roleCodeOperationsLead))
for _, code := range []string{
"payment-temporary-link:create",
"payment-bill:export",
"host-withdrawal:view",
} {
if _, ok := opsPermissions[code]; !ok {
t.Fatalf("ops-admin default permissions missing %s", code)
}
if _, ok := opsMigrationPermissions[code]; !ok {
t.Fatalf("ops-admin migration permissions missing %s", code)
}
}
for _, code := range []string{
"finance:view",
"payment-temporary-link:create",
"payment-third-party:update-rate",
"payment-third-party:sync-rates",
"finance-application:create",
"finance-application:audit",
"finance-operation:user-coin-credit",
@ -69,8 +75,153 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
if _, ok := opsPermissions[code]; ok {
t.Fatalf("ops-admin must not receive removed permission %s", code)
}
if _, ok := opsMigrationPermissions[code]; ok {
t.Fatalf("ops-admin migration must not append removed permission %s", code)
}
}
func TestManagedDefaultRolePermissionMatrix(t *testing.T) {
if len(managedDefaultRoles) != 7 {
t.Fatalf("managed role count = %d, want 7", len(managedDefaultRoles))
}
roleNames := map[string]string{}
for _, role := range managedDefaultRoles {
if previous, ok := roleNames[role.Code]; ok {
t.Fatalf("duplicate role code %s for %s and %s", role.Code, previous, role.Name)
}
roleNames[role.Code] = role.Name
}
wantNames := map[string]string{
roleCodePlatformAdmin: "超级管理员",
roleCodeOperationsLead: "运营负责人",
roleCodeOperationsSpecialist: "运营专员",
roleCodeProductLead: "产品负责人",
roleCodeProductSpecialist: "产品专员",
roleCodeFinanceLead: "财务负责人",
roleCodeFinanceSpecialist: "财务专员",
}
if !reflect.DeepEqual(roleNames, wantNames) {
t.Fatalf("managed roles = %#v, want %#v", roleNames, wantNames)
}
defined := map[string]struct{}{}
for _, permission := range defaultPermissions {
defined[permission.Code] = struct{}{}
}
for _, role := range managedDefaultRoles {
if role.Code == roleCodePlatformAdmin {
continue
}
seen := map[string]struct{}{}
for _, code := range defaultRolePermissionCodes(role.Code) {
if _, ok := seen[code]; ok {
t.Fatalf("role %s contains duplicate permission %s", role.Code, code)
}
seen[code] = struct{}{}
if _, ok := defined[code]; !ok {
t.Fatalf("role %s references permission %s missing from defaultPermissions", role.Code, code)
}
}
}
}
func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
assertRolePermissions(t, roleCodeOperationsLead,
[]string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update"},
[]string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view"},
)
assertRolePermissions(t, roleCodeOperationsSpecialist,
[]string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export"},
[]string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create"},
)
assertRolePermissions(t, roleCodeProductLead,
[]string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"},
[]string{"bd:create", "coin-adjustment:create", "country:update", "finance:view"},
)
assertRolePermissions(t, roleCodeProductSpecialist,
[]string{"resource:update", "room-robot:update", "game-catalog:view", "daily-task:view"},
[]string{"app-config:update", "room:update", "game-catalog:update", "daily-task:update", "bd:view"},
)
financeLead := defaultRolePermissionCodes(roleCodeFinanceLead)
financeSpecialist := defaultRolePermissionCodes(roleCodeFinanceSpecialist)
if !reflect.DeepEqual(financeLead, financeSpecialist) {
t.Fatalf("finance lead and specialist permissions differ: lead=%v specialist=%v", financeLead, financeSpecialist)
}
assertRolePermissions(t, roleCodeFinanceLead,
[]string{"finance:view", "payment-bill:view", "payment-bill:export", "finance-withdrawal:audit", "room-rps-order:view"},
[]string{"game-catalog:view", "room-rps-config:view", "app-user:view", "daily-task:view"},
)
}
func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql")
if err != nil {
t.Fatalf("read role permission migration: %v", err)
}
sqlText := string(content)
for _, roleCode := range []string{
roleCodeOperationsLead,
roleCodeOperationsSpecialist,
roleCodeProductLead,
roleCodeProductSpecialist,
} {
pattern := regexp.MustCompile(`(?s)WHERE role\.code = '` + regexp.QuoteMeta(roleCode) + `'\s+AND permission\.code IN \((.*?)\);`)
matches := pattern.FindStringSubmatch(sqlText)
if len(matches) != 2 {
t.Fatalf("migration permission block missing for %s", roleCode)
}
assertMigrationPermissionCodes(t, roleCode, matches[1])
}
financePattern := regexp.MustCompile(`(?s)WHERE role\.code IN \('finance-lead', 'finance-specialist'\)\s+AND permission\.code IN \((.*?)\);`)
financeMatches := financePattern.FindStringSubmatch(sqlText)
if len(financeMatches) != 2 {
t.Fatal("migration permission block missing for finance roles")
}
assertMigrationPermissionCodes(t, roleCodeFinanceLead, financeMatches[1])
assertMigrationPermissionCodes(t, roleCodeFinanceSpecialist, financeMatches[1])
}
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
matches := quotedCode.FindAllStringSubmatch(sqlList, -1)
actual := make([]string, 0, len(matches))
for _, match := range matches {
actual = append(actual, match[1])
}
expected := append([]string(nil), defaultRolePermissionCodes(roleCode)...)
sort.Strings(actual)
sort.Strings(expected)
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("migration permissions for %s differ\nactual-only=%v\nexpected-only=%v", roleCode, stringSetDifference(actual, expected), stringSetDifference(expected, actual))
}
}
func stringSetDifference(left []string, right []string) []string {
rightSet := make(map[string]struct{}, len(right))
for _, item := range right {
rightSet[item] = struct{}{}
}
out := make([]string, 0)
for _, item := range left {
if _, ok := rightSet[item]; !ok {
out = append(out, item)
}
}
return out
}
func assertRolePermissions(t *testing.T, roleCode string, required []string, forbidden []string) {
t.Helper()
permissions := seedTestPermissionSet(defaultRolePermissionCodes(roleCode))
for _, code := range required {
if _, ok := permissions[code]; !ok {
t.Errorf("role %s missing required permission %s", roleCode, code)
}
}
for _, code := range forbidden {
if _, ok := permissions[code]; ok {
t.Errorf("role %s must not have permission %s", roleCode, code)
}
}
}

View File

@ -60,6 +60,7 @@ import (
"hyapp-admin-server/internal/modules/weeklystar"
"hyapp-admin-server/internal/modules/wheel"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin"
@ -124,7 +125,7 @@ type Handlers struct {
Wheel *wheel.Handler
}
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
func New(cfg config.Config, auth *service.AuthService, store *repository.Store, h Handlers) *gin.Engine {
engine := gin.New()
engine.Use(middleware.RequestID(), middleware.AppCode(), logging.GinAccessLogger(), gin.Recovery(), middleware.CORS(cfg))
@ -134,62 +135,64 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
api := engine.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit))
appProtected := protected.Group("")
appProtected.Use(middleware.RequireAppScope(store))
authroutes.RegisterRoutes(api, protected, h.Auth)
agencyopening.RegisterRoutes(protected, h.AgencyOpening)
achievementconfig.RegisterRoutes(protected, h.AchievementConfig)
coinledger.RegisterRoutes(protected, h.CoinLedger)
agencyopening.RegisterRoutes(appProtected, h.AgencyOpening)
achievementconfig.RegisterRoutes(appProtected, h.AchievementConfig)
coinledger.RegisterRoutes(appProtected, h.CoinLedger)
menu.RegisterRoutes(protected, h.Menu)
rbac.RegisterRoutes(protected, h.RBAC)
redpacket.RegisterRoutes(protected, h.RedPacket)
reportmodule.RegisterRoutes(protected, h.Report)
resourcemodule.RegisterRoutes(protected, h.Resource)
redpacket.RegisterRoutes(appProtected, h.RedPacket)
reportmodule.RegisterRoutes(appProtected, h.Report)
resourcemodule.RegisterRoutes(appProtected, h.Resource)
adminuser.RegisterRoutes(protected, h.AdminUser)
appuser.RegisterRoutes(protected, h.AppUser)
appuser.RegisterRoutes(appProtected, h.AppUser)
appregistry.RegisterRoutes(protected, h.AppRegistry)
appconfig.RegisterRoutes(protected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion)
cprelation.RegisterRoutes(protected, h.CPRelation)
cpweeklyrank.RegisterRoutes(protected, h.CPWeeklyRank)
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
dailytask.RegisterRoutes(protected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
appconfig.RegisterRoutes(appProtected, h.AppConfig)
countryregion.RegisterRoutes(appProtected, h.CountryRegion)
cprelation.RegisterRoutes(appProtected, h.CPRelation)
cpweeklyrank.RegisterRoutes(appProtected, h.CPWeeklyRank)
cumulativerechargereward.RegisterRoutes(appProtected, h.CumulativeRecharge)
dailytask.RegisterRoutes(appProtected, h.DailyTask)
firstrechargereward.RegisterRoutes(appProtected, h.FirstRechargeReward)
financeorder.RegisterRoutes(protected, h.FinanceOrder)
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
regionblock.RegisterRoutes(protected, h.RegionBlock)
riskconfig.RegisterRoutes(protected, h.RiskConfig)
gamemanagement.RegisterRoutes(protected, h.Game)
giftdiamond.RegisterRoutes(protected, h.GiftDiamond)
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
roomrocket.RegisterRoutes(protected, h.RoomRocket)
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
dashboard.RegisterRoutes(protected, h.Dashboard)
fullservernotice.RegisterRoutes(appProtected, h.FullServerNotice)
registrationreward.RegisterRoutes(appProtected, h.RegistrationReward)
regionblock.RegisterRoutes(appProtected, h.RegionBlock)
riskconfig.RegisterRoutes(appProtected, h.RiskConfig)
gamemanagement.RegisterRoutes(appProtected, h.Game)
giftdiamond.RegisterRoutes(appProtected, h.GiftDiamond)
roomadmin.RegisterRoutes(appProtected, h.RoomAdmin)
roomrocket.RegisterRoutes(appProtected, h.RoomRocket)
roomturnoverreward.RegisterRoutes(appProtected, h.RoomTurnoverReward)
dashboard.RegisterRoutes(appProtected, protected, h.Dashboard)
databi.RegisterRoutes(protected, h.Databi)
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg)
hostwithdrawal.RegisterRoutes(protected, h.HostWithdrawal)
inviteactivityreward.RegisterRoutes(protected, h.InviteActivityReward)
hostagencypolicy.RegisterRoutes(appProtected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(appProtected, h.HostSalarySettlement)
hostorg.RegisterRoutes(appProtected, h.HostOrg)
hostwithdrawal.RegisterRoutes(appProtected, h.HostWithdrawal)
inviteactivityreward.RegisterRoutes(appProtected, h.InviteActivityReward)
audit.RegisterRoutes(protected, h.Audit)
payment.RegisterRoutes(protected, h.Payment)
policyconfig.RegisterRoutes(protected, h.PolicyConfig)
pointwithdrawalconfig.RegisterRoutes(protected, h.PointWithdrawalConfig)
prettyid.RegisterRoutes(protected, h.PrettyID)
payment.RegisterRoutes(protected, appProtected, h.Payment)
policyconfig.RegisterRoutes(appProtected, h.PolicyConfig)
pointwithdrawalconfig.RegisterRoutes(appProtected, h.PointWithdrawalConfig)
prettyid.RegisterRoutes(appProtected, h.PrettyID)
job.RegisterRoutes(protected, h.Job)
levelconfig.RegisterRoutes(protected, h.LevelConfig)
opscenter.RegisterRoutes(protected, h.OpsCenter)
upload.RegisterRoutes(protected, h.Upload)
levelconfig.RegisterRoutes(appProtected, h.LevelConfig)
opscenter.RegisterRoutes(appProtected, h.OpsCenter)
upload.RegisterRoutes(appProtected, h.Upload)
search.RegisterRoutes(protected, h.Search)
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
sevendaycheckin.RegisterRoutes(appProtected, h.SevenDayCheckIn)
team.RegisterRoutes(protected, h.Team)
teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy)
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
vipconfig.RegisterRoutes(protected, h.VIPConfig)
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
wheel.RegisterRoutes(protected, h.Wheel)
teamsalarypolicy.RegisterRoutes(appProtected, h.TeamSalaryPolicy)
teamsalarysettlement.RegisterRoutes(appProtected, h.TeamSalarySettlement)
userleaderboard.RegisterRoutes(appProtected, h.UserLeaderboard)
vipconfig.RegisterRoutes(appProtected, h.VIPConfig)
weeklystar.RegisterRoutes(appProtected, h.WeeklyStar)
wheel.RegisterRoutes(appProtected, h.Wheel)
return engine
}

View File

@ -0,0 +1,247 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 七类岗位及权限矩阵来自产品确认的《身份权限预览》:固定岗位使用精确模板,不能再通过旧 ops-admin 迁移不断追加跨模块权限。
-- 性能边界:本迁移只扫描 7 个 role code 和数百条 permission coderole.code、permission.code 均为唯一索引,
-- admin_role_permissions 以 (role_id, permission_id) 为主键DELETE JOIN 和 INSERT SELECT 不扫描业务流水表。
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
('支付账单导出', 'payment-bill:export', 'button', '允许导出充值账单,不随列表查看权限自动授予', @now_ms, @now_ms),
('支付账单刷新', 'payment-bill:refresh', 'button', '允许刷新 Google 实付明细,不随列表查看权限自动授予', @now_ms, @now_ms),
('三方支付方式更新', 'payment-third-party:update-method', 'button', '允许调整三方支付方式状态', @now_ms, @now_ms),
('三方支付方式同步', 'payment-third-party:sync-methods', 'button', '允许同步三方支付方式', @now_ms, @now_ms),
('三方支付汇率编辑', 'payment-third-party:update-rate', 'button', '允许编辑三方支付汇率', @now_ms, @now_ms),
('三方支付汇率同步', 'payment-third-party:sync-rates', 'button', '允许全局同步三方支付汇率', @now_ms, @now_ms),
('游戏列表查看', 'game-catalog:view', 'menu', '允许查看游戏列表和游戏平台', @now_ms, @now_ms),
('游戏平台配置', 'game-catalog:platform', 'button', '允许新增、编辑和同步游戏平台', @now_ms, @now_ms),
('游戏列表创建', 'game-catalog:create', 'button', '允许新增游戏', @now_ms, @now_ms),
('游戏列表更新', 'game-catalog:update', 'button', '允许编辑游戏和游戏白名单', @now_ms, @now_ms),
('游戏列表状态', 'game-catalog:status', 'button', '允许启用或停用游戏', @now_ms, @now_ms),
('游戏列表删除', 'game-catalog:delete', 'button', '允许删除游戏', @now_ms, @now_ms),
('自研游戏查看', 'self-game:view', 'menu', '允许查看自研游戏配置', @now_ms, @now_ms),
('自研游戏配置', 'self-game:update', 'button', '允许修改骰子、新手策略和档位奖池', @now_ms, @now_ms),
('全站机器人查看', 'game-robot:view', 'menu', '允许查看全站机器人', @now_ms, @now_ms),
('全站机器人创建', 'game-robot:create', 'button', '允许批量创建全站机器人', @now_ms, @now_ms),
('全站机器人更新', 'game-robot:update', 'button', '允许修改全站机器人状态', @now_ms, @now_ms),
('全站机器人删除', 'game-robot:delete', 'button', '允许删除全站机器人', @now_ms, @now_ms),
('房内猜拳配置查看', 'room-rps-config:view', 'menu', '允许查看房内猜拳配置', @now_ms, @now_ms),
('房内猜拳配置更新', 'room-rps-config:update', 'button', '允许修改房内猜拳配置', @now_ms, @now_ms),
('房内猜拳订单查看', 'room-rps-order:view', 'menu', '允许查看房内猜拳订单', @now_ms, @now_ms),
('房内猜拳订单更新', 'room-rps-order:update', 'button', '允许重试结算或手动过期房内猜拳订单', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
-- 历史粗粒度权限保留给用户自建角色兼容,但固定岗位不再绑定这些 code。
UPDATE admin_permissions
SET description = CASE code
WHEN 'payment-third-party:update' THEN '兼容旧角色的三方支付写权限'
WHEN 'game:view' THEN '兼容旧角色的游戏管理查看权限'
WHEN 'game:create' THEN '兼容旧角色的游戏管理创建权限'
WHEN 'game:update' THEN '兼容旧角色的游戏管理更新权限'
WHEN 'game:status' THEN '兼容旧角色的游戏管理状态权限'
WHEN 'game:delete' THEN '兼容旧角色的游戏管理删除权限'
ELSE description
END,
updated_at_ms = @now_ms
WHERE code IN ('payment-third-party:update', 'game:view', 'game:create', 'game:update', 'game:status', 'game:delete');
UPDATE admin_menus
SET permission_code = CASE code
WHEN 'game-list' THEN 'game-catalog:view'
WHEN 'self-games' THEN 'self-game:view'
WHEN 'game-robots' THEN 'game-robot:view'
WHEN 'room-rps-config' THEN 'room-rps-config:view'
WHEN 'room-rps-challenges' THEN 'room-rps-order:view'
ELSE permission_code
END,
updated_at_ms = @now_ms
WHERE code IN ('game-list', 'self-games', 'game-robots', 'room-rps-config', 'room-rps-challenges');
INSERT INTO admin_roles (name, code, description, created_at_ms, updated_at_ms) VALUES
('超级管理员', 'platform-admin', '拥有管理后台全部权限', @now_ms, @now_ms),
('运营负责人', 'ops-admin', '负责运营、团队、活动和日常配置管理', @now_ms, @now_ms),
('运营专员', 'operations-specialist', '负责日常运营执行,不含后台设置和高风险财务配置', @now_ms, @now_ms),
('产品负责人', 'product-lead', '负责产品配置、资源、活动和游戏管理', @now_ms, @now_ms),
('产品专员', 'product-specialist', '负责资源配置,并只读查看关联业务模块', @now_ms, @now_ms),
('财务负责人', 'finance-lead', '负责财务看板、充值对账和提现审核', @now_ms, @now_ms),
('财务专员', 'finance-specialist', '负责财务看板、充值对账和提现审核', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
updated_at_ms = @now_ms;
-- 只清理产品管理的七类固定岗位;审计员、只读用户和用户自建角色保留原绑定,避免破坏历史兼容。
DELETE role_permission
FROM admin_role_permissions role_permission
JOIN admin_roles role ON role.id = role_permission.role_id
WHERE role.code IN (
'platform-admin', 'ops-admin', 'operations-specialist',
'product-lead', 'product-specialist', 'finance-lead', 'finance-specialist'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'platform-admin';
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'ops-admin'
AND permission.code IN (
'overview:view',
'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant',
'finance-withdrawal:view', 'finance-withdrawal:audit',
'app-user:view', 'app-user:export', 'app-user:update', 'app-user:level', 'app-user:status', 'app-user:password',
'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant',
'risk-config:view', 'risk-config:update', 'region-block:view', 'region-block:update',
'bd:view', 'bd:create', 'bd:update', 'agency:view', 'agency:create', 'agency:status', 'agency:delete', 'host:view',
'host-agency-policy:view', 'host-agency-policy:create', 'host-agency-policy:update', 'host-agency-policy:delete', 'host-agency-policy:publish',
'team-salary-policy:view', 'team-salary-policy:create', 'team-salary-policy:update', 'team-salary-policy:delete',
'host-salary-settlement:view', 'host-salary-settlement:settle',
'coin-seller:view', 'coin-seller:create', 'coin-seller:update', 'coin-seller:stock-credit', 'coin-seller:exchange-rate',
'coin-seller-sub-application:view', 'coin-seller-sub-application:audit', 'host-withdrawal:view',
'point-withdrawal-config:view', 'point-withdrawal-config:update',
'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', 'full-server-notice:send',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'coin-adjustment:view', 'coin-adjustment:create', 'report:view',
'gift-diamond:view', 'gift-diamond:update', 'policy-template:view', 'policy-template:create',
'policy-instance:create', 'policy-instance:update', 'policy-instance:publish',
'payment-bill:view', 'payment-bill:export', 'payment-bill:refresh',
'payment-third-party:view', 'payment-third-party:update-method', 'payment-third-party:sync-methods',
'payment-temporary-link:view', 'payment-product:view', 'payment-product:create', 'payment-product:update', 'payment-product:delete',
'daily-task:view', 'daily-task:create', 'daily-task:update', 'daily-task:status',
'first-recharge-reward:view', 'first-recharge-reward:update',
'cumulative-recharge-reward:view', 'cumulative-recharge-reward:update',
'invite-activity-reward:view', 'invite-activity-reward:update',
'seven-day-checkin:view', 'seven-day-checkin:update', 'room-rocket:view', 'room-rocket:update',
'room-turnover-reward:view', 'room-turnover-reward:update', 'room-turnover-reward:retry',
'wheel:view', 'wheel:update', 'weekly-star:view', 'weekly-star:create', 'weekly-star:update', 'weekly-star:settle',
'agency-opening:view', 'agency-opening:create', 'agency-opening:update', 'user-leaderboard:view',
'red-packet:view', 'red-packet:update', 'cp-config:view', 'cp-config:update',
'cp-weekly-rank:view', 'cp-weekly-rank:update', 'vip-config:view', 'vip-config:update',
'achievement:view', 'achievement:create', 'achievement:update',
'game-catalog:view', 'game-catalog:platform', 'game-catalog:create', 'game-catalog:update', 'game-catalog:status', 'game-catalog:delete',
'self-game:view', 'self-game:update', 'game-robot:view', 'game-robot:create', 'game-robot:update', 'game-robot:delete',
'room-rps-config:view', 'room-rps-config:update', 'room-rps-order:view', 'room-rps-order:update',
'country:view', 'country:create', 'country:update', 'country:status',
'region:view', 'region:create', 'region:update', 'region:status'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'operations-specialist'
AND permission.code IN (
'overview:view',
'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant',
'finance-withdrawal:view', 'finance-withdrawal:audit',
'app-user:view', 'app-user:export', 'app-user:update', 'app-user:level', 'app-user:status', 'app-user:password',
'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant',
'risk-config:view', 'risk-config:update', 'region-block:view', 'region-block:update',
'bd:view', 'bd:create', 'bd:update', 'agency:view', 'agency:create', 'agency:status', 'agency:delete', 'host:view',
'host-agency-policy:view', 'team-salary-policy:view', 'host-salary-settlement:view',
'coin-seller:view', 'coin-seller-sub-application:view', 'host-withdrawal:view', 'point-withdrawal-config:view',
'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', 'full-server-notice:send',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'report:view',
'payment-bill:view', 'payment-bill:export', 'payment-bill:refresh',
'payment-third-party:view', 'payment-temporary-link:view', 'payment-product:view',
'daily-task:view', 'first-recharge-reward:view', 'cumulative-recharge-reward:view', 'invite-activity-reward:view',
'seven-day-checkin:view', 'room-rocket:view', 'room-turnover-reward:view', 'wheel:view', 'weekly-star:view',
'agency-opening:view', 'user-leaderboard:view', 'red-packet:view', 'cp-config:view', 'cp-weekly-rank:view', 'vip-config:view', 'achievement:view',
'game-catalog:view', 'self-game:view', 'game-robot:view', 'game-robot:create', 'game-robot:update', 'game-robot:delete',
'room-rps-config:view', 'room-rps-config:update', 'room-rps-order:view',
'country:view', 'region:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'product-lead'
AND permission.code IN (
'overview:view',
'app-user:view', 'app-user:export',
'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant',
'risk-config:view', 'risk-config:update', 'region-block:view', 'region-block:update',
'bd:view', 'agency:view', 'host:view', 'host-agency-policy:view', 'team-salary-policy:view',
'host-salary-settlement:view', 'coin-seller:view', 'coin-seller-sub-application:view', 'host-withdrawal:view', 'point-withdrawal-config:view',
'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',
'full-server-notice:view', 'full-server-notice:send',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'report:view', 'gift-diamond:view', 'gift-diamond:update',
'payment-bill:view', 'payment-third-party:view', 'payment-temporary-link:view', 'payment-product:view',
'daily-task:view', 'daily-task:create', 'daily-task:update', 'daily-task:status',
'first-recharge-reward:view', 'first-recharge-reward:update',
'cumulative-recharge-reward:view', 'cumulative-recharge-reward:update',
'invite-activity-reward:view', 'invite-activity-reward:update',
'seven-day-checkin:view', 'seven-day-checkin:update', 'room-rocket:view', 'room-rocket:update',
'room-turnover-reward:view', 'room-turnover-reward:update', 'room-turnover-reward:retry',
'wheel:view', 'wheel:update', 'weekly-star:view', 'weekly-star:create', 'weekly-star:update', 'weekly-star:settle',
'agency-opening:view', 'agency-opening:create', 'agency-opening:update', 'user-leaderboard:view',
'red-packet:view', 'red-packet:update', 'cp-config:view', 'cp-config:update',
'cp-weekly-rank:view', 'cp-weekly-rank:update', 'vip-config:view', 'vip-config:update',
'achievement:view', 'achievement:create', 'achievement:update',
'game-catalog:view', 'game-catalog:platform', 'game-catalog:create', 'game-catalog:update', 'game-catalog:status', 'game-catalog:delete',
'self-game:view', 'self-game:update', 'game-robot:view', 'game-robot:create', 'game-robot:update', 'game-robot:delete',
'room-rps-config:view', 'room-rps-config:update', 'room-rps-order:view', 'room-rps-order:update',
'country:view', 'region:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'product-specialist'
AND permission.code IN (
'overview:view',
'app-user:view', 'app-user:export', 'level-config:view', 'pretty-id:view', 'risk-config:view', 'region-block:view',
'room:view', 'room-pin:view', 'room-config:view', 'room-whitelist:view', 'room-robot:view', 'room-robot:create', 'room-robot:update',
'app-config:view', 'app-version:view', 'full-server-notice:view',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'report:view',
'payment-bill:view', 'payment-third-party:view', 'payment-temporary-link:view', 'payment-product:view',
'daily-task:view', 'first-recharge-reward:view', 'cumulative-recharge-reward:view', 'invite-activity-reward:view',
'seven-day-checkin:view', 'room-rocket:view', 'room-turnover-reward:view', 'wheel:view', 'weekly-star:view',
'agency-opening:view', 'user-leaderboard:view', 'red-packet:view', 'cp-config:view', 'cp-weekly-rank:view', 'vip-config:view', 'achievement:view',
'game-catalog:view', 'self-game:view', 'game-robot:view', 'room-rps-config:view', 'room-rps-order:view',
'country:view', 'region:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code IN ('finance-lead', 'finance-specialist')
AND permission.code IN (
'overview:view', 'finance:view', 'payment-bill:view', 'payment-bill:export',
'finance-withdrawal:view', 'finance-withdrawal:audit', 'room-rps-order:view'
);

View File

@ -0,0 +1,21 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- App 可见范围与财务区域范围分开保存。已有用户保持历史全 App 行为;新建用户默认无 App
-- 避免创建用户成功但后续授权保存失败时短暂获得全量 App。
ALTER TABLE admin_users
ADD COLUMN app_scope_mode VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '主后台 App 范围all/selected/none' AFTER status;
ALTER TABLE admin_users
ALTER COLUMN app_scope_mode SET DEFAULT 'none';
CREATE TABLE admin_user_app_scopes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID',
app_code VARCHAR(32) NOT NULL COMMENT '允许在主后台切换的 App 编码',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_user_app_scope (user_id, app_code),
KEY idx_admin_user_app_scopes_user (user_id),
KEY idx_admin_user_app_scopes_app (app_code),
CONSTRAINT fk_admin_user_app_scopes_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户主站 App 可见范围';

View File

@ -400,3 +400,10 @@ type TierCommand struct {
DisplayConfigJSON string
OperatorAdminID int64
}
// BatchConfigCommand 是一次批量编辑的完整写集合repository 必须在同一个事务中提交或回滚。
type BatchConfigCommand struct {
Rules []RuleCommand
Tiers []TierCommand
OperatorAdminID int64
}

View File

@ -46,6 +46,7 @@ type Repository interface {
UpsertLevelTrack(ctx context.Context, command domain.TrackCommand, nowMS int64) (domain.Track, bool, error)
UpsertLevelRule(ctx context.Context, command domain.RuleCommand, nowMS int64) (domain.Rule, bool, error)
UpsertLevelTier(ctx context.Context, command domain.TierCommand, nowMS int64) (domain.Tier, bool, error)
BatchUpsertLevelConfig(ctx context.Context, command domain.BatchConfigCommand, nowMS int64) (domain.Config, error)
ClaimPendingLevelRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error)
MarkLevelRewardGranted(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, nowMS int64) error
MarkLevelRewardFailed(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, failureReason string, nextRetryAtMS int64, nowMS int64) error
@ -450,6 +451,44 @@ func (s *Service) UpsertLevelTier(ctx context.Context, command domain.TierComman
return s.repository.UpsertLevelTier(ctx, command, s.now().UnixMilli())
}
// BatchUpsertLevelConfig 先完整校验全部行,再交给 repository 原子提交,避免半张表保存成功。
func (s *Service) BatchUpsertLevelConfig(ctx context.Context, command domain.BatchConfigCommand) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
if command.OperatorAdminID <= 0 || (len(command.Rules) == 0 && len(command.Tiers) == 0) || len(command.Rules)+len(command.Tiers) > 500 {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "level config batch command is invalid")
}
seenRules := make(map[string]struct{}, len(command.Rules))
seenTiers := make(map[int64]struct{}, len(command.Tiers))
for index := range command.Rules {
item := &command.Rules[index]
item.Track = normalizeTrack(item.Track)
item.Name = strings.TrimSpace(item.Name)
item.Status = normalizeStatus(item.Status)
item.DisplayConfigJSON = normalizeJSON(item.DisplayConfigJSON)
item.OperatorAdminID = command.OperatorAdminID
key := fmt.Sprintf("%s:%d", item.Track, item.Level)
if _, duplicated := seenRules[key]; duplicated || !validTrack(item.Track) || item.Level < 0 || item.RequiredValue < 0 || item.Name == "" || !validStatus(item.Status) || item.RewardResourceGroupID < 0 {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "level rule batch item is invalid")
}
seenRules[key] = struct{}{}
}
for index := range command.Tiers {
item := &command.Tiers[index]
item.Track = normalizeTrack(item.Track)
item.Name = strings.TrimSpace(item.Name)
item.Status = normalizeStatus(item.Status)
item.DisplayConfigJSON = normalizeJSON(item.DisplayConfigJSON)
item.OperatorAdminID = command.OperatorAdminID
if _, duplicated := seenTiers[item.TierID]; duplicated || item.TierID <= 0 || !validTrack(item.Track) || item.MinLevel < 0 || item.MaxLevel < item.MinLevel || item.Name == "" || !validStatus(item.Status) || item.DisplayAvatarFrameResourceID < 0 || item.DisplayBadgeResourceID < 0 || item.RewardResourceGroupID < 0 {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "level tier batch item is invalid")
}
seenTiers[item.TierID] = struct{}{}
}
return s.repository.BatchUpsertLevelConfig(ctx, command, s.now().UnixMilli())
}
func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err

View File

@ -857,6 +857,72 @@ func TestListLevelConfigReturnsAdminRulesAndTiers(t *testing.T) {
}
}
func TestBatchUpsertLevelConfigCommitsRulesAndTiersTogether(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackCharm)
config, err := svc.ListLevelConfig(ctx, domain.ConfigQuery{Track: domain.TrackCharm})
if err != nil || len(config.Tiers) == 0 {
t.Fatalf("seeded level config missing: config=%+v err=%v", config, err)
}
tier := config.Tiers[0]
updated, err := svc.BatchUpsertLevelConfig(ctx, domain.BatchConfigCommand{
OperatorAdminID: 90001,
Rules: []domain.RuleCommand{{
Track: domain.TrackCharm, Level: 1, RequiredValue: 1234, Name: "charm 1 batch", Status: domain.StatusActive,
RewardResourceGroupID: 7001, SortOrder: 1, DisplayConfigJSON: `{"long_badge_resource_id":701}`,
}},
Tiers: []domain.TierCommand{{
TierID: tier.TierID, Track: tier.Track, MinLevel: tier.MinLevel, MaxLevel: tier.MaxLevel,
Name: "charm tier batch", Status: tier.Status, DisplayConfigJSON: tier.DisplayConfigJSON,
}},
})
if err != nil {
t.Fatalf("BatchUpsertLevelConfig failed: %v", err)
}
if rule := batchTestRule(updated.Rules, domain.TrackCharm, 1); rule.RequiredValue != 1234 || rule.UpdatedByAdminID != 90001 {
t.Fatalf("batch rule mismatch: %+v", rule)
}
if got := batchTestTier(updated.Tiers, tier.TierID); got.Name != "charm tier batch" || got.UpdatedByAdminID != 90001 {
t.Fatalf("batch tier mismatch: %+v", got)
}
// 第二行更新失败时,前面已执行的规则 UPSERT 也必须回滚,不能留下半张表的新值。
_, err = svc.BatchUpsertLevelConfig(ctx, domain.BatchConfigCommand{
OperatorAdminID: 90001,
Rules: []domain.RuleCommand{{Track: domain.TrackCharm, Level: 1, RequiredValue: 9999, Name: "must rollback", Status: domain.StatusActive, SortOrder: 1}},
Tiers: []domain.TierCommand{{TierID: 999999, Track: domain.TrackCharm, MinLevel: 0, MaxLevel: 9, Name: "missing", Status: domain.StatusActive}},
})
if err == nil {
t.Fatal("batch with missing tier should fail")
}
afterRollback, listErr := svc.ListLevelConfig(ctx, domain.ConfigQuery{Track: domain.TrackCharm})
if listErr != nil {
t.Fatalf("ListLevelConfig after rollback failed: %v", listErr)
}
if rule := batchTestRule(afterRollback.Rules, domain.TrackCharm, 1); rule.RequiredValue != 1234 || rule.Name != "charm 1 batch" {
t.Fatalf("batch rollback left partial rule update: %+v", rule)
}
}
func batchTestRule(items []domain.Rule, track string, level int32) domain.Rule {
for _, item := range items {
if item.Track == track && item.Level == level {
return item
}
}
return domain.Rule{}
}
func batchTestTier(items []domain.Tier, tierID int64) domain.Tier {
for _, item := range items {
if item.TierID == tierID {
return item
}
}
return domain.Tier{}
}
func TestZeroLevelRuleAndTierAreValid(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")

View File

@ -597,6 +597,81 @@ func (r *Repository) UpsertLevelTier(ctx context.Context, command domain.TierCom
return item, created, err
}
// BatchUpsertLevelConfig 在一个短事务里写入最多 500 行。两条语句都命中现有唯一键/主键,
// prepared statement 避免 101 级配置重复解析 SQL每条轨道只在全部写入后校验一次控制锁时长和查询次数。
func (r *Repository) BatchUpsertLevelConfig(ctx context.Context, command domain.BatchConfigCommand, nowMS int64) (domain.Config, error) {
if r == nil || r.db == nil {
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Config{}, err
}
defer func() { _ = tx.Rollback() }()
ruleStatement, err := tx.PrepareContext(ctx, `
INSERT INTO growth_level_rules (
app_code, track, level, required_value, name, status, reward_resource_group_id,
sort_order, display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
required_value = VALUES(required_value), name = VALUES(name), status = VALUES(status),
reward_resource_group_id = VALUES(reward_resource_group_id), sort_order = VALUES(sort_order),
display_config_json = VALUES(display_config_json), updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`)
if err != nil {
return domain.Config{}, err
}
defer ruleStatement.Close()
tierStatement, err := tx.PrepareContext(ctx, `
UPDATE growth_level_tiers
SET track = ?, min_level = ?, max_level = ?, name = ?, display_avatar_frame_resource_id = ?,
display_badge_resource_id = ?, reward_resource_group_id = ?, status = ?, display_config_json = ?,
updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND tier_id = ?`)
if err != nil {
return domain.Config{}, err
}
defer tierStatement.Close()
appCode := appcode.FromContext(ctx)
touchedTracks := make(map[string]struct{}, 3)
for _, item := range command.Rules {
if _, err := ruleStatement.ExecContext(ctx, appCode, item.Track, item.Level, item.RequiredValue, item.Name, item.Status,
item.RewardResourceGroupID, item.SortOrder, item.DisplayConfigJSON, item.OperatorAdminID,
item.OperatorAdminID, nowMS, nowMS); err != nil {
return domain.Config{}, err
}
touchedTracks[item.Track] = struct{}{}
}
for _, item := range command.Tiers {
result, err := tierStatement.ExecContext(ctx, item.Track, item.MinLevel, item.MaxLevel, item.Name,
item.DisplayAvatarFrameResourceID, item.DisplayBadgeResourceID, item.RewardResourceGroupID, item.Status,
item.DisplayConfigJSON, item.OperatorAdminID, nowMS, appCode, item.TierID)
if err != nil {
return domain.Config{}, err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return domain.Config{}, xerr.New(xerr.NotFound, "level tier not found")
}
touchedTracks[item.Track] = struct{}{}
}
for track := range touchedTracks {
if err := r.validateActiveLevelRules(ctx, tx, track); err != nil {
return domain.Config{}, err
}
if err := r.validateActiveLevelTiers(ctx, tx, track); err != nil {
return domain.Config{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.Config{}, err
}
return r.ListLevelConfig(ctx, domain.ConfigQuery{}, nowMS)
}
func (r *Repository) ClaimPendingLevelRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")

View File

@ -283,6 +283,34 @@ func (s *AdminGrowthLevelServer) UpsertLevelTier(ctx context.Context, req *activ
return &activityv1.UpsertLevelTierResponse{Tier: levelTierToProto(item), Created: created}, nil
}
// BatchUpsertLevelConfig 将单次 HTTP 批量保存保持为一次 gRPC 和一次数据库事务。
func (s *AdminGrowthLevelServer) BatchUpsertLevelConfig(ctx context.Context, req *activityv1.BatchUpsertLevelConfigRequest) (*activityv1.BatchUpsertLevelConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
command := domain.BatchConfigCommand{
Rules: make([]domain.RuleCommand, 0, len(req.GetRules())),
Tiers: make([]domain.TierCommand, 0, len(req.GetTiers())),
OperatorAdminID: req.GetOperatorAdminId(),
}
for _, item := range req.GetRules() {
command.Rules = append(command.Rules, domain.RuleCommand{Track: item.GetTrack(), Level: item.GetLevel(), RequiredValue: item.GetRequiredValue(), Name: item.GetName(), Status: item.GetStatus(), RewardResourceGroupID: item.GetRewardResourceGroupId(), SortOrder: item.GetSortOrder(), DisplayConfigJSON: item.GetDisplayConfigJson()})
}
for _, item := range req.GetTiers() {
command.Tiers = append(command.Tiers, domain.TierCommand{TierID: item.GetTierId(), Track: item.GetTrack(), MinLevel: item.GetMinLevel(), MaxLevel: item.GetMaxLevel(), Name: item.GetName(), DisplayAvatarFrameResourceID: item.GetDisplayAvatarFrameResourceId(), DisplayBadgeResourceID: item.GetDisplayBadgeResourceId(), RewardResourceGroupID: item.GetRewardResourceGroupId(), Status: item.GetStatus(), DisplayConfigJSON: item.GetDisplayConfigJson()})
}
config, err := s.svc.BatchUpsertLevelConfig(ctx, command)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
response := &activityv1.BatchUpsertLevelConfigResponse{Rules: make([]*activityv1.LevelRule, 0, len(config.Rules)), Tiers: make([]*activityv1.LevelTier, 0, len(config.Tiers)), ServerTimeMs: config.ServerTimeMS}
for _, item := range config.Rules {
response.Rules = append(response.Rules, levelRuleToProto(item))
}
for _, item := range config.Tiers {
response.Tiers = append(response.Tiers, levelTierToProto(item))
}
return response, nil
}
func levelTrackToProto(item domain.Track) *activityv1.LevelTrack {
return &activityv1.LevelTrack{
Track: item.Track,

View File

@ -985,6 +985,24 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
}
}
// 回看口径下留存归属观察日:观察日整天零活跃时没有 stat_app_day_country 载体行,
// 真实的 0% 留存点会随行缺失而消失。为有基数的观察日合成占位行(其余指标确为 0
existingDays := make(map[string]struct{}, len(out))
for index := range out {
existingDays[out[index].StatDay] = struct{}{}
}
for day, retention := range retentionByDay {
if _, ok := existingDays[day]; ok || !retention.hasRetentionBase() {
continue
}
item := DailySeriesItem{StatDay: day, Label: day}
item.D1RetentionUsers, item.D1RetentionBaseUsers, item.D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
item.D7RetentionUsers, item.D7RetentionBaseUsers, item.D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
item.D30RetentionUsers, item.D30RetentionBaseUsers, item.D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
applyDailySeriesDerivedMetrics(&item)
out = append(out, item)
}
sort.Slice(out, func(i, j int) bool { return out[i].StatDay < out[j].StatDay })
superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID)
if err != nil {
return nil, err
@ -1048,6 +1066,9 @@ type retentionCount struct {
D7Base int64
D30Users int64
D30Base int64
// RegionID 不是可加指标同一国家的注册行共享同一区域max 合并只为给
// 合成的占位行(观察日零活跃、无 stat_app_day_country 载体行)补上区域归属。
RegionID int64
}
func (count *retentionCount) add(next retentionCount) {
@ -1058,6 +1079,14 @@ func (count *retentionCount) add(next retentionCount) {
count.D7Base += next.D7Base
count.D30Users += next.D30Users
count.D30Base += next.D30Base
if next.RegionID > count.RegionID {
count.RegionID = next.RegionID
}
}
// hasRetentionBase 表示该计数至少有一个可观察的留存基数(合成占位行的门槛)。
func (count retentionCount) hasRetentionBase() bool {
return count.D1Base > 0 || count.D7Base > 0 || count.D30Base > 0
}
func (count retentionCount) toRetention() Retention {
@ -1100,13 +1129,25 @@ func (cube *retentionCube) add(day string, countryID int64, count retentionCount
cube.ByDayCountry[dayCountryKey] = dayCountryCount
}
// queryRetentionCube 按“观察日回看”口径计算留存:区间内每一天 D 的 DN 留存 =
// D-N 天注册的用户中在 D 当天活跃的比例,计数归属观察日 D与 Yumi 的 CDC 预聚合口径一致)。
// cohort 注册日无注册时该观察日的基数保持 0上层 retentionPointers 会把 0 基数转成 nil。
// RegisteredUsers 仍按注册日归属,只统计落在查询区间内的注册。
func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (retentionCube, error) {
tz := normalizeStatTZ(statTZ)
today := statDayIn(time.Now().UnixMilli(), tz)
// 观察日不能超过今天ISO 日期字符串可直接比较。
obsEnd := endDay
if today < obsEnd {
obsEnd = today
}
args := []any{
today, today,
today, today,
today, today,
startDay, obsEnd,
startDay, obsEnd,
startDay, obsEnd,
startDay, obsEnd,
startDay, obsEnd,
startDay, obsEnd,
app, tz, startDay, endDay,
}
filter := ""
@ -1118,19 +1159,20 @@ func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startD
filter += " AND r.region_id = ?"
args = append(args, regionID)
}
// 注册日扫描范围放宽到 start-30观察日落在区间内的 D30 cohort 最早注册于 start-30。
rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(r.registered_day, '%Y-%m-%d'), r.country_id, COUNT(*),
SELECT DATE_FORMAT(r.registered_day, '%Y-%m-%d'), r.country_id, COALESCE(MAX(r.region_id),0), COUNT(*),
COALESCE(SUM(CASE WHEN d1.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 1 DAY) BETWEEN ? AND ? THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN d7.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 7 DAY) BETWEEN ? AND ? THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN d30.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? THEN 1 ELSE 0 END),0)
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 30 DAY) BETWEEN ? AND ? THEN 1 ELSE 0 END),0)
FROM stat_user_registration r
LEFT JOIN stat_user_day_activity d1 ON DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? AND d1.app_code = r.app_code AND d1.stat_tz = r.stat_tz AND d1.stat_day = DATE_ADD(r.registered_day, INTERVAL 1 DAY) AND d1.user_id = r.user_id
LEFT JOIN stat_user_day_activity d7 ON DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? AND d7.app_code = r.app_code AND d7.stat_tz = r.stat_tz AND d7.stat_day = DATE_ADD(r.registered_day, INTERVAL 7 DAY) AND d7.user_id = r.user_id
LEFT JOIN stat_user_day_activity d30 ON DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? AND d30.app_code = r.app_code AND d30.stat_tz = r.stat_tz AND d30.stat_day = DATE_ADD(r.registered_day, INTERVAL 30 DAY) AND d30.user_id = r.user_id
WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+`
LEFT JOIN stat_user_day_activity d1 ON DATE_ADD(r.registered_day, INTERVAL 1 DAY) BETWEEN ? AND ? AND d1.app_code = r.app_code AND d1.stat_tz = r.stat_tz AND d1.stat_day = DATE_ADD(r.registered_day, INTERVAL 1 DAY) AND d1.user_id = r.user_id
LEFT JOIN stat_user_day_activity d7 ON DATE_ADD(r.registered_day, INTERVAL 7 DAY) BETWEEN ? AND ? AND d7.app_code = r.app_code AND d7.stat_tz = r.stat_tz AND d7.stat_day = DATE_ADD(r.registered_day, INTERVAL 7 DAY) AND d7.user_id = r.user_id
LEFT JOIN stat_user_day_activity d30 ON DATE_ADD(r.registered_day, INTERVAL 30 DAY) BETWEEN ? AND ? AND d30.app_code = r.app_code AND d30.stat_tz = r.stat_tz AND d30.stat_day = DATE_ADD(r.registered_day, INTERVAL 30 DAY) AND d30.user_id = r.user_id
WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN DATE_SUB(?, INTERVAL 30 DAY) AND ?`+filter+`
GROUP BY r.registered_day, r.country_id`, args...)
if err != nil {
return retentionCube{}, err
@ -1141,10 +1183,45 @@ func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startD
var day string
var countryID int64
var count retentionCount
if err := rows.Scan(&day, &countryID, &count.RegisteredUsers, &count.D1Users, &count.D1Base, &count.D7Users, &count.D7Base, &count.D30Users, &count.D30Base); err != nil {
if err := rows.Scan(&day, &countryID, &count.RegionID, &count.RegisteredUsers, &count.D1Users, &count.D1Base, &count.D7Users, &count.D7Base, &count.D30Users, &count.D30Base); err != nil {
return retentionCube{}, err
}
out.add(day, countryID, count)
registeredDay, err := time.Parse("2006-01-02", day)
if err != nil {
continue
}
// 注册数归属注册日;只保留查询区间内的注册日,区间前 30 天的 cohort 行不计入注册数。
if day >= startDay && day <= endDay && count.RegisteredUsers > 0 {
out.add(day, countryID, retentionCount{RegisteredUsers: count.RegisteredUsers, RegionID: count.RegionID})
}
// 留存计数归属观察日(注册日 + NSQL 侧的 BETWEEN 守卫保证出窗口的 offset 基数为 0。
for _, offset := range []struct {
days int
users int64
base int64
}{
{1, count.D1Users, count.D1Base},
{7, count.D7Users, count.D7Base},
{30, count.D30Users, count.D30Base},
} {
if offset.base <= 0 {
continue
}
obsDay := registeredDay.AddDate(0, 0, offset.days).Format("2006-01-02")
if obsDay < startDay || obsDay > obsEnd {
continue
}
partial := retentionCount{RegionID: count.RegionID}
switch offset.days {
case 1:
partial.D1Users, partial.D1Base = offset.users, offset.base
case 7:
partial.D7Users, partial.D7Base = offset.users, offset.base
case 30:
partial.D30Users, partial.D30Base = offset.users, offset.base
}
out.add(obsDay, countryID, partial)
}
}
if err := rows.Err(); err != nil {
return retentionCube{}, err
@ -1317,6 +1394,22 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
}
}
// 区间内整段零活跃的国家没有 stat_app_day_country 行;有留存基数时合成占位行,保住真实 0%。
existingCountries := make(map[int64]struct{}, len(out))
for index := range out {
existingCountries[out[index].CountryID] = struct{}{}
}
for retentionCountryID, retention := range retentionByCountry {
if _, ok := existingCountries[retentionCountryID]; ok || !retention.hasRetentionBase() {
continue
}
item := CountryBreakdown{CountryID: retentionCountryID, RegionID: retention.RegionID}
item.D1RetentionUsers, item.D1RetentionBaseUsers, item.D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
item.D7RetentionUsers, item.D7RetentionBaseUsers, item.D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
item.D30RetentionUsers, item.D30RetentionBaseUsers, item.D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
applyCountryBreakdownDerivedMetrics(&item)
out = append(out, item)
}
superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID)
if err != nil {
return nil, err
@ -1380,6 +1473,32 @@ func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
}
}
// 观察日×国家 当天零活跃时没有载体行;有留存基数时合成占位行,保住真实 0%。
existingDayCountries := make(map[string]struct{}, len(out))
for index := range out {
existingDayCountries[dailyCountryKey(out[index].StatDay, out[index].CountryID)] = struct{}{}
}
for key, retention := range retentionByDayCountry {
if _, ok := existingDayCountries[key]; ok || !retention.hasRetentionBase() {
continue
}
separator := strings.LastIndex(key, ":")
if separator <= 0 {
continue
}
keyCountryID, err := strconv.ParseInt(key[separator+1:], 10, 64)
if err != nil {
continue
}
item := DailyCountryStat{StatDay: key[:separator], Label: key[:separator]}
item.CountryID, item.RegionID = keyCountryID, retention.RegionID
item.D1RetentionUsers, item.D1RetentionBaseUsers, item.D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
item.D7RetentionUsers, item.D7RetentionBaseUsers, item.D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
item.D30RetentionUsers, item.D30RetentionBaseUsers, item.D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
applyCountryBreakdownDerivedMetrics(&item.CountryBreakdown)
out = append(out, item)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].StatDay < out[j].StatDay })
superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID)
if err != nil {
return nil, err

View File

@ -1114,14 +1114,17 @@ func TestQueryOverviewReturnsRetentionForAllBreakdowns(t *testing.T) {
if err != nil {
t.Fatalf("parse today: %v", err)
}
// 回看口径观察日today往前推 1 天的注册 cohort 在观察日的活跃比例,归属观察日。
registeredAt := today.AddDate(0, 0, -1)
registeredDay := registeredAt.Format("2006-01-02")
retainedDay := today.Format("2006-01-02")
observeDay := today.Format("2006-01-02")
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, new_users, active_users, updated_at_ms
) VALUES ('lalu', ?, 86, 210, 2, 2, 1)`, registeredDay); err != nil {
) VALUES
('lalu', ?, 86, 210, 2, 2, 1),
('lalu', ?, 86, 210, 0, 1, 1)`, registeredDay, observeDay); err != nil {
t.Fatalf("seed day country stats: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
@ -1135,14 +1138,14 @@ func TestQueryOverviewReturnsRetentionForAllBreakdowns(t *testing.T) {
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_user_day_activity (
app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms
) VALUES ('lalu', 'UTC', ?, 86, 210, 9001, 3)`, retainedDay); err != nil {
) VALUES ('lalu', 'UTC', ?, 86, 210, 9001, 3)`, observeDay); err != nil {
t.Fatalf("seed retained activity: %v", err)
}
overview, err := repository.QueryOverview(ctx, OverviewQuery{
AppCode: "lalu",
StartMS: registeredAt.UnixMilli(),
EndMS: registeredAt.AddDate(0, 0, 1).UnixMilli(),
EndMS: today.AddDate(0, 0, 1).UnixMilli(),
RegionID: 210,
})
if err != nil {
@ -1157,13 +1160,15 @@ func TestQueryOverviewReturnsRetentionForAllBreakdowns(t *testing.T) {
requireNilRetention(t, "overview d7", overview.Retention.Day7Users, overview.Retention.Day7BaseUsers, overview.Retention.Day7Rate)
requireNilRetention(t, "overview d30", overview.Retention.Day30Users, overview.Retention.Day30BaseUsers, overview.Retention.Day30Rate)
if len(overview.DailySeries) != 1 {
t.Fatalf("daily series missing retention day: %+v", overview.DailySeries)
if len(overview.DailySeries) != 2 {
t.Fatalf("daily series missing days: %+v", overview.DailySeries)
}
requireInt64Ptr(t, "daily d1 users", overview.DailySeries[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily d1 base", overview.DailySeries[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily d1 rate", overview.DailySeries[0].D1RetentionRate, 0.5)
requireNilRetention(t, "daily d7", overview.DailySeries[0].D7RetentionUsers, overview.DailySeries[0].D7RetentionBaseUsers, overview.DailySeries[0].D7RetentionRate)
// 注册日行没有可观察的 cohort其 D1 观察日是 today归属 today 行),留存应为 nil。
requireNilRetention(t, "register day d1", overview.DailySeries[0].D1RetentionUsers, overview.DailySeries[0].D1RetentionBaseUsers, overview.DailySeries[0].D1RetentionRate)
requireInt64Ptr(t, "observe day d1 users", overview.DailySeries[1].D1RetentionUsers, 1)
requireInt64Ptr(t, "observe day d1 base", overview.DailySeries[1].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "observe day d1 rate", overview.DailySeries[1].D1RetentionRate, 0.5)
requireNilRetention(t, "observe day d7", overview.DailySeries[1].D7RetentionUsers, overview.DailySeries[1].D7RetentionBaseUsers, overview.DailySeries[1].D7RetentionRate)
if len(overview.CountryBreakdown) != 1 {
t.Fatalf("country breakdown missing retention country: %+v", overview.CountryBreakdown)
@ -1172,12 +1177,13 @@ func TestQueryOverviewReturnsRetentionForAllBreakdowns(t *testing.T) {
requireInt64Ptr(t, "country d1 base", overview.CountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "country d1 rate", overview.CountryBreakdown[0].D1RetentionRate, 0.5)
if len(overview.DailyCountryBreakdown) != 1 {
t.Fatalf("daily country breakdown missing retention country: %+v", overview.DailyCountryBreakdown)
if len(overview.DailyCountryBreakdown) != 2 {
t.Fatalf("daily country breakdown missing rows: %+v", overview.DailyCountryBreakdown)
}
requireInt64Ptr(t, "daily country d1 users", overview.DailyCountryBreakdown[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily country d1 base", overview.DailyCountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily country d1 rate", overview.DailyCountryBreakdown[0].D1RetentionRate, 0.5)
requireNilRetention(t, "daily country register day d1", overview.DailyCountryBreakdown[0].D1RetentionUsers, overview.DailyCountryBreakdown[0].D1RetentionBaseUsers, overview.DailyCountryBreakdown[0].D1RetentionRate)
requireInt64Ptr(t, "daily country d1 users", overview.DailyCountryBreakdown[1].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily country d1 base", overview.DailyCountryBreakdown[1].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily country d1 rate", overview.DailyCountryBreakdown[1].D1RetentionRate, 0.5)
}
func TestQueryRetentionCubeRollsUpDailyCountryRows(t *testing.T) {
@ -1189,40 +1195,55 @@ func TestQueryRetentionCubeRollsUpDailyCountryRows(t *testing.T) {
t.Cleanup(func() { _ = db.Close() })
repository := &Repository{db: db}
today := statDayIn(time.Now().UnixMilli(), StatTZUTC)
// 回看口径SQL 返回按注册日分组的行Go 侧把 DN 计数归属到观察日(注册日+N
// 区间 [06-01, 06-02](历史区间,观察窗上限即 06-02
// 05-31 注册 cohort → D1 归属 06-0105-26 注册 cohort → D7 归属 06-02
// 05-02 注册 cohort → D30 归属 06-0106-01 注册 cohort → D1 归属 06-02
// 06-02 注册 cohort → 观察日全部出窗,仅贡献注册数。
rows := sqlmock.NewRows([]string{
"registered_day", "country_id", "registered_users",
"registered_day", "country_id", "region_id", "registered_users",
"d1_users", "d1_base", "d7_users", "d7_base", "d30_users", "d30_base",
}).
AddRow("2026-06-01", int64(86), int64(2), int64(1), int64(2), int64(0), int64(0), int64(0), int64(0)).
AddRow("2026-06-01", int64(66), int64(3), int64(0), int64(3), int64(1), int64(3), int64(0), int64(0)).
AddRow("2026-06-02", int64(86), int64(4), int64(2), int64(4), int64(0), int64(0), int64(0), int64(0))
AddRow("2026-05-31", int64(86), int64(210), int64(2), int64(1), int64(2), int64(0), int64(0), int64(0), int64(0)).
AddRow("2026-05-26", int64(66), int64(210), int64(3), int64(0), int64(0), int64(1), int64(3), int64(0), int64(0)).
AddRow("2026-05-02", int64(86), int64(210), int64(4), int64(0), int64(0), int64(0), int64(0), int64(1), int64(4)).
AddRow("2026-06-01", int64(86), int64(210), int64(5), int64(2), int64(5), int64(0), int64(0), int64(0), int64(0)).
AddRow("2026-06-02", int64(66), int64(210), int64(4), int64(0), int64(0), int64(0), int64(0), int64(0), int64(0))
mock.ExpectQuery("SELECT DATE_FORMAT").
WithArgs(today, today, today, today, today, today, "lalu", StatTZUTC, "2026-06-01", "2026-06-02", int64(210)).
WithArgs(
"2026-06-01", "2026-06-02", "2026-06-01", "2026-06-02", "2026-06-01", "2026-06-02",
"2026-06-01", "2026-06-02", "2026-06-01", "2026-06-02", "2026-06-01", "2026-06-02",
"lalu", StatTZUTC, "2026-06-01", "2026-06-02", int64(210),
).
WillReturnRows(rows)
cube, err := repository.queryRetentionCube(ctx, "lalu", StatTZUTC, "2026-06-01", "2026-06-02", 0, 210)
if err != nil {
t.Fatalf("query retention cube: %v", err)
}
if cube.Total.RegisteredUsers != 9 || cube.Total.D1Users != 3 || cube.Total.D1Base != 9 || cube.Total.D7Users != 1 || cube.Total.D7Base != 3 {
if cube.Total.RegisteredUsers != 9 || cube.Total.D1Users != 3 || cube.Total.D1Base != 7 || cube.Total.D7Users != 1 || cube.Total.D7Base != 3 || cube.Total.D30Users != 1 || cube.Total.D30Base != 4 {
t.Fatalf("total retention rollup mismatch: %+v", cube.Total)
}
retention := cube.Total.toRetention()
requireInt64Ptr(t, "total d1 users", retention.Day1Users, 3)
requireFloat64Ptr(t, "total d1 rate", retention.Day1Rate, 1.0/3.0)
requireNilRetention(t, "total d30", retention.Day30Users, retention.Day30BaseUsers, retention.Day30Rate)
requireFloat64Ptr(t, "total d1 rate", retention.Day1Rate, 3.0/7.0)
requireInt64Ptr(t, "total d30 users", retention.Day30Users, 1)
requireFloat64Ptr(t, "total d30 rate", retention.Day30Rate, 0.25)
day := cube.ByDay["2026-06-01"]
if day.RegisteredUsers != 5 || day.D1Users != 1 || day.D1Base != 5 || day.D7Users != 1 || day.D7Base != 3 {
if day.RegisteredUsers != 5 || day.D1Users != 1 || day.D1Base != 2 || day.D30Users != 1 || day.D30Base != 4 {
t.Fatalf("day retention rollup mismatch: %+v", day)
}
day2 := cube.ByDay["2026-06-02"]
if day2.RegisteredUsers != 4 || day2.D1Users != 2 || day2.D1Base != 5 || day2.D7Users != 1 || day2.D7Base != 3 {
t.Fatalf("day2 retention rollup mismatch: %+v", day2)
}
country := cube.ByCountry[86]
if country.RegisteredUsers != 6 || country.D1Users != 3 || country.D1Base != 6 {
if country.RegisteredUsers != 5 || country.D1Users != 3 || country.D1Base != 7 || country.D30Users != 1 || country.D30Base != 4 || country.RegionID != 210 {
t.Fatalf("country retention rollup mismatch: %+v", country)
}
dayCountry := cube.ByDayCountry[dailyCountryKey("2026-06-01", 66)]
if dayCountry.RegisteredUsers != 3 || dayCountry.D1Users != 0 || dayCountry.D1Base != 3 || dayCountry.D7Users != 1 || dayCountry.D7Base != 3 {
dayCountry := cube.ByDayCountry[dailyCountryKey("2026-06-02", 66)]
if dayCountry.RegisteredUsers != 4 || dayCountry.D7Users != 1 || dayCountry.D7Base != 3 {
t.Fatalf("daily country retention rollup mismatch: %+v", dayCountry)
}
if err := mock.ExpectationsWereMet(); err != nil {
@ -1496,3 +1517,84 @@ func requireNilRetention(t *testing.T, name string, users *int64, base *int64, r
t.Fatalf("%s should be nil before cohort matures: users=%v base=%v rate=%v", name, users, base, rate)
}
}
// 回看口径下观察日整天零活跃时没有 stat_app_day_country 载体行;
// 序列/按日国家下钻必须合成占位行,把真实的 0% 留存保住,而不是让该观察日消失。
func TestQueryOverviewSynthesizesRetentionRowsForZeroActivityDays(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_retention_zero_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
today, err := time.Parse("2006-01-02", statDayIn(time.Now().UnixMilli(), StatTZUTC))
if err != nil {
t.Fatalf("parse today: %v", err)
}
registeredAt := today.AddDate(0, 0, -1)
registeredDay := registeredAt.Format("2006-01-02")
observeDay := today.Format("2006-01-02")
// 只有注册日有载体行;观察日(today)整天零活跃,无 stat_app_day_country 行、无活跃行。
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, new_users, active_users, updated_at_ms
) VALUES ('lalu', ?, 86, 210, 2, 2, 1)`, registeredDay); err != nil {
t.Fatalf("seed day country stats: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_user_registration (
app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms
) VALUES
('lalu', 'UTC', 9101, ?, 86, 210, 1),
('lalu', 'UTC', 9102, ?, 86, 210, 2)`, registeredDay, registeredDay); err != nil {
t.Fatalf("seed registrations: %v", err)
}
overview, err := repository.QueryOverview(ctx, OverviewQuery{
AppCode: "lalu",
StartMS: registeredAt.UnixMilli(),
EndMS: today.AddDate(0, 0, 1).UnixMilli(),
RegionID: 210,
})
if err != nil {
t.Fatalf("query overview: %v", err)
}
// 整段:真实 0%(基数 2、0 人留存),不是 nil。
requireInt64Ptr(t, "overview d1 users", overview.Retention.Day1Users, 0)
requireInt64Ptr(t, "overview d1 base", overview.Retention.Day1BaseUsers, 2)
requireFloat64Ptr(t, "overview d1 rate", overview.Retention.Day1Rate, 0)
if len(overview.DailySeries) != 2 {
t.Fatalf("expected synthesized observe-day row in daily series: %+v", overview.DailySeries)
}
synthesized := overview.DailySeries[1]
if synthesized.StatDay != observeDay || synthesized.NewUsers != 0 || synthesized.ActiveUsers != 0 {
t.Fatalf("synthesized daily row mismatch: %+v", synthesized)
}
requireInt64Ptr(t, "synthesized d1 users", synthesized.D1RetentionUsers, 0)
requireInt64Ptr(t, "synthesized d1 base", synthesized.D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "synthesized d1 rate", synthesized.D1RetentionRate, 0)
if len(overview.CountryBreakdown) != 1 {
t.Fatalf("country breakdown rows mismatch: %+v", overview.CountryBreakdown)
}
requireInt64Ptr(t, "country d1 base", overview.CountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "country d1 rate", overview.CountryBreakdown[0].D1RetentionRate, 0)
if len(overview.DailyCountryBreakdown) != 2 {
t.Fatalf("expected synthesized observe-day country row: %+v", overview.DailyCountryBreakdown)
}
synthesizedCountry := overview.DailyCountryBreakdown[1]
if synthesizedCountry.StatDay != observeDay || synthesizedCountry.CountryID != 86 || synthesizedCountry.RegionID != 210 {
t.Fatalf("synthesized daily country row mismatch: %+v", synthesizedCountry)
}
requireInt64Ptr(t, "synthesized country d1 base", synthesizedCountry.D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "synthesized country d1 rate", synthesizedCountry.D1RetentionRate, 0)
}