增加后台数据方案
This commit is contained in:
parent
8a665c11f4
commit
1fbbe2758f
@ -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
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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。业务切日使用 UTC,Flutter 只在展示层转本地时间。
|
||||
- 客户端展示当前身份只读 `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 StoreKit:currentEntitlements](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 都有 VIP1–VIP9。
|
||||
- 套餐是否可买使用 `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。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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. 旧权限码只能作为自定义历史角色的兼容入口,不能重新分配给七类固定岗位。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
52
server/admin/internal/middleware/app_scope_test.go
Normal file
52
server/admin/internal/middleware/app_scope_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
}
|
||||
|
||||
protected.GET("/admin/apps", h.ListApps)
|
||||
protected.GET("/admin/apps/visible", h.ListVisibleApps)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}=6;06-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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 圆整成 ...600;SQL 精确等值前必须先还原成目录精确值。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
server/admin/internal/repository/app_scope_repository.go
Normal file
130
server/admin/internal/repository/app_scope_repository.go
Normal 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 会把空值回退 lalu;App 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
&model.DataScope{},
|
||||
&model.UserAppScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
|
||||
242
server/admin/internal/repository/role_permission_matrix.go
Normal file
242
server/admin/internal/repository/role_permission_matrix.go
Normal 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
|
||||
}
|
||||
@ -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(¤t, 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
247
server/admin/migrations/093_role_permission_matrix.sql
Normal file
247
server/admin/migrations/093_role_permission_matrix.sql
Normal file
@ -0,0 +1,247 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 七类岗位及权限矩阵来自产品确认的《身份权限预览》:固定岗位使用精确模板,不能再通过旧 ops-admin 迁移不断追加跨模块权限。
|
||||
-- 性能边界:本迁移只扫描 7 个 role code 和数百条 permission code;role.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'
|
||||
);
|
||||
21
server/admin/migrations/094_admin_user_app_scope.sql
Normal file
21
server/admin/migrations/094_admin_user_app_scope.sql
Normal 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 可见范围';
|
||||
@ -400,3 +400,10 @@ type TierCommand struct {
|
||||
DisplayConfigJSON string
|
||||
OperatorAdminID int64
|
||||
}
|
||||
|
||||
// BatchConfigCommand 是一次批量编辑的完整写集合;repository 必须在同一个事务中提交或回滚。
|
||||
type BatchConfigCommand struct {
|
||||
Rules []RuleCommand
|
||||
Tiers []TierCommand
|
||||
OperatorAdminID int64
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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})
|
||||
}
|
||||
// 留存计数归属观察日(注册日 + N);SQL 侧的 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
|
||||
|
||||
@ -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-01;05-26 注册 cohort → D7 归属 06-02;
|
||||
// 05-02 注册 cohort → D30 归属 06-01;06-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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user