--- name: free credits 后台统计页 overview: 实现需求 §18 的 Free Credits 后台统计与展示:在 slot_console 增强 innerapi(加筛选、加列表字段、富化累计充值与档位聚合),slot_lib 新增 FreeCreditsService 并给 WalletService 补一个 statistics 批量方法,slot_admin 新增 FreeCreditsStatsController 代理,slot_admin_vue 新增 views/game/freeCreditsStats/index.vue 一页搞定,顶部统计走 sa-table 的 otherData 模式。 todos: - id: slot-console-stats-logic content: slot_console 新建 innerapi/logic/FreeCreditsStatsLogic.php,封装 statistics + list 的查询编排(含 status>=2 口径、批量 package 聚合 SQL) status: completed - id: slot-console-list-enrich content: slot_console FreeCreditsController::list 加筛选 + 字段富化(first_cash_status / progress / claimed / first_cashout / remaining / total_recharge) status: completed - id: slot-console-stats-filter content: slot_console FreeCreditsController::statistics 加 source / first_recharge_time / status 等筛选;frozen_user_count 改为 status>=2 status: completed - id: slot-lib-wallet-stats content: slot_lib WalletService 新增 statistics(uids, currency) 方法 → 调 /api/wallet/statistics status: completed - id: slot-lib-free-credits-service content: slot_lib 新建 FreeCreditsService(basePath=innerapi/free-credits,含 list + statistics) status: completed - id: slot-admin-controller content: slot_admin 新建 game/controller/FreeCreditsStatsController.php,index 一次调两个 service 合并 otherData 返回 status: completed - id: slot-admin-vue-api content: slot_admin_vue 新建 api/game/freeCreditsStats.js,导出 getPageList status: completed - id: slot-admin-vue-page content: "slot_admin_vue 新建 views/game/freeCreditsStats/index.vue:sa-table + #tableAfterButtons 顶部 7 统计 + 12 列表 + 筛选 + 排序白名单" status: completed - id: menu-permission content: 在 sm_system_menu 加 1 条 L 菜单 + 1 条 B 按钮码(/game/freeCreditsStats/index),并分配角色 status: completed - id: manual-verify content: docker 内 A/B/C 三种玩家手测:默认排序、7 项统计、筛选联动、排序白名单、权限拦截 status: completed isProject: false --- ## 数据流 ```mermaid flowchart LR vue[freeCreditsStats/index.vue sa-table] vue -->|"POST /game/FreeCreditsStats/index"| ctrl[FreeCreditsStatsController] ctrl -->|"FreeCreditsService::list"| svc[slot_lib FreeCreditsService] ctrl -->|"FreeCreditsService::statistics"| svc svc -->|"POST innerapi/free-credits/list"| console[slot_console FreeCreditsController] svc -->|"POST innerapi/free-credits/statistics"| console console -->|"WalletService statistics uids currency"| wallet["slot_wallet api/wallet/statistics"] console -->|"sum on free_credits_package"| db[(s_common.free_credits_player + free_credits_package)] ``` slot_admin Controller 一次代理两个 innerapi,合并成 `{ data, total, otherData: }` 给 sa-table,避免前端两次请求。 ## 字段契约(最终对外) ### `/game/FreeCreditsStats/index` 请求 筛选(与 §18.3 对齐) - `uid` 精确 - `source` 字符串(沿用 channel_game_model + source 二级选择) - `activity_id` 可选(默认取当前生效的 type=11 活动) - `first_recharge_time` 范围 `[start, end]` → `whereBetween` - `status` 数组(player.status:0/1/2/3/4/5/6/7/10) - `first_cash_status` 数组(package.status:0/1/2/3/4/5,限定 `package_no=1`) - `is_completed` 0/1(status=10 与否) - `page`, `limit`, `orderBy`, `orderType` 排序 - DB 可排序:`frozen_amount_qf`、`first_recharge_time`(默认 `first_recharge_time desc`) - 其它字段(进度、剩余、累计充值)派生,前端不开启排序 ### 响应 ```json { "data": [{ "uid": 123, "source": "us_01", "frozen_amount_qf": 78500, "first_cash_amount_qf": 20000, "total_recharge_amount_qf": 30000, "first_cash_status": 3, "progress_done": 3, "progress_total": 7, "status": 7, "remaining_amount_qf": 28500, "claimed_amount_qf": 30000, "first_recharge_time": "2026-05-12 11:23:01", "completed_time": null }], "total": 240, "otherData": { "frozen_user_count": 240, "first_cashout_user_count": 180, "completed_user_count": 35, "frozen_amount_qf": 18650000, "first_cashout_amount_qf": 3520000, "claimed_amount_qf": 8800000, "pending_amount_qf": 6330000 } } ``` 金额字段一律以 `*_qf`(千分位整数)传到 Vue,Vue 用 `/ 1000` + `toFixed(2)` 显示(与 edit.vue 同口径)。 --- ## 改动清单 ### 1. slot_console — 增强 innerapi 文件:[slot_console/app/innerapi/controller/FreeCreditsController.php](slot_console/app/innerapi/controller/FreeCreditsController.php) #### 1.1 `statistics()` 加筛选 参照需求 §18.1,新增可选参数并贯穿到所有子查询: - `activity_id`、`source`、`first_recharge_time_start`、`first_recharge_time_end`、`uid` - 子查询 `frozenTotal / completedCount / firstCashoutCount / firstCashoutAmount / claimedAmount` 全部基于同一个 `playerIds` 数组(按筛选后取出) - `frozen_user_count` 口径改为 `status >= STATUS_FROZEN(2)` 的玩家数(与需求 "已创建 Free Credits Pool" 一致;当前实现把 status=1 也计入,定义不准) #### 1.2 `list()` 加筛选 + 字段富化 - 新增筛选参数:`source`、`first_recharge_time` 范围、`status[]`、`first_cash_status[]`、`is_completed`、`orderBy/orderType`(白名单:`first_recharge_time`, `frozen_amount_qf`) - 输出每行追加: - `first_cash_status`:对应 `free_credits_package.status` where `player_id=? AND package_no=1` - `progress_done` / `progress_total`:`SUM(CASE WHEN status=3 THEN 1 ELSE 0 END)` / `COUNT(*)`,一次 `GROUP BY player_id` 跑完 - `claimed_amount_qf`:`SUM(amount_qf) WHERE package_type=2 AND status=3 GROUP BY player_id` - `first_cashout_amount_qf`:`SUM(amount_qf) WHERE package_type=1 AND status=3 GROUP BY player_id` - `remaining_amount_qf`:`frozen_amount_qf - first_cashout_amount_qf - claimed_amount_qf` - `total_recharge_amount_qf`:调用 `WalletService::statistics(uids, currency='USD')`,把 `total_deposit` 映射进来。currency 默认 'USD'(活动只面向美国 RMG,currency 字段后续如多币种再扩展) - 一次性聚合查询,避免 N+1: ```text SELECT player_id, SUM(CASE WHEN status=3 THEN 1 ELSE 0 END) AS progress_done, COUNT(*) AS progress_total, SUM(CASE WHEN package_type=1 AND status=3 THEN amount_qf ELSE 0 END) AS first_cashout_amount_qf, SUM(CASE WHEN package_type=2 AND status=3 THEN amount_qf ELSE 0 END) AS claimed_amount_qf, MAX(CASE WHEN package_no=1 THEN status END) AS first_cash_status FROM s_common.free_credits_package WHERE player_id IN (...) GROUP BY player_id ``` #### 1.3 控制器分层 按 backend-layering 规则,把 1.1 / 1.2 的查询编排沉到 Logic:新增 [slot_console/app/innerapi/logic/FreeCreditsStatsLogic.php](slot_console/app/innerapi/logic/FreeCreditsStatsLogic.php)(或直接挂在已有 `api/logic/FreeCreditsLogic.php`,建议新建避免膨胀),Controller 只负责接参 / 调 Logic / 返回。 --- ### 2. slot_lib — 新增 FreeCreditsService + 补 WalletService #### 2.1 [slot_lib/src/services/FreeCreditsService.php](slot_lib/src/services/FreeCreditsService.php) 新建 ```php class FreeCreditsService extends BaseApiService { use SingletonService; protected $hostKey = 'consoleApiHost'; protected $basePath = 'innerapi/free-credits'; public function statistics(array $params) { return $this->postAction('statistics', $params); } public function list(array $params) { return $this->postAction('list', $params); } } ``` 理由:不与既有 `ActivityService::join/add/update` 混杂;basePath 完全独立,匹配 slot_console 的路由。 #### 2.2 [slot_lib/src/services/WalletService.php](slot_lib/src/services/WalletService.php) 补 `statistics` ```php public function statistics(array $uids, string $currency) { return $this->postAction('statistics', ['uids' => $uids, 'currency' => $currency]); } ``` action 名 `statistics`,URL `{walletApiHost}/api/wallet/statistics`,对应 slot_wallet `WalletController::statistics`。slot_console 侧 `FreeCreditsStatsLogic` 调用此方法。 --- ### 3. slot_admin — 新增代理 Controller 文件:[backend/slot_admin/app/game/controller/FreeCreditsStatsController.php](backend/slot_admin/app/game/controller/FreeCreditsStatsController.php) 只做接参 + Service 调用 + 合并: ```php class FreeCreditsStatsController extends AdminController { public function index(Request $request): Response { $params = $request->all(); $params['page'] = (int)$request->get('page', 1); $params['limit'] = (int)$request->get('limit', 20); $list = FreeCreditsService::getInstance()->list($params); $stats = FreeCreditsService::getInstance()->statistics($params); $list['otherData'] = $stats; return $this->success($list); } } ``` - 不需要 Validate(参数全部可选;非法值 slot_console 侧拦) - 自动路由路径 `/game/freeCreditsStats/index` - 不新增 `export()` action(V1 不做导出) --- ### 4. slot_admin_vue — 新页 + API #### 4.1 [backend/slot_admin_vue/src/api/game/freeCreditsStats.js](backend/slot_admin_vue/src/api/game/freeCreditsStats.js) 新建 ```js import request from '@/utils/request' const url = '/game/freeCreditsStats' export default { getPageList: (params) => request.get(`${url}/index`, params), } ``` #### 4.2 [backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue) 新建 骨架仿 `views/game/order/recharge/index.vue`: - `defineOptions({ name: 'game/freeCreditsStats/index' })` - `` `options.api = api.getPageList`,`options.add` / `options.delete` 关闭 - `searchForm`:`uid`、`source`(用 `commonStore.allSourcesOptionsNoAll`)、`first_recharge_time`(`a-range-picker`)、`status`、`first_cash_status`、`is_completed` - 顶部统计走 `#tableAfterButtons`: ```vue ``` - `stats = computed(() => crudRef.value?.getTableOtherData() ?? {})` - 列定义:12 列对齐 §18.2;`status`、`first_cash_status` 直接用前端 map 表(不新建字典): ```js const PLAYER_STATUS_MAP = { 0: '未触发', 1: 'Home Withdraw 已解锁', 2: '已定格', 3: '充值进行中', 4: '第一档可提现', 5: '第一档处理中', 6: '第一档已提现', 7: '后续档释放中', 10: '全部完成', } const PACKAGE_STATUS_MAP = { 0: '未解锁', 1: '可操作', 2: '处理中', 3: '已完成', 4: '失败', 5: '已拒绝', } ``` - `qfToDollar = (v) => ((Number(v||0))/1000).toFixed(2)` - 不再调单独的 stats 接口,`tableAfterButtons` 数据从 `otherData` 取 --- ### 5. 字典与菜单 #### 5.1 字典:不新增 `status` / `first_cash_status` 直接走前端 map 表,避免与 `RechargeGiftConfigModel` 共用字典污染。 #### 5.2 菜单:DB 表 `sm_system_menu` 追加(运营在「系统管理 → 菜单管理」操作即可) 需要的 2 条数据(指引性 SQL,实际可走 UI): ```sql INSERT INTO sm_system_menu (parent_id, type, title, name, path, component, sort) VALUES (, 'L', 'Free Credits 统计', 'game/freeCreditsStats/index', '/game/freeCreditsStats', 'game/freeCreditsStats/index', 50); INSERT INTO sm_system_menu (parent_id, type, title, code) VALUES (, 'B', '列表', '/game/freeCreditsStats/index'); ``` 写完后给「超级管理员 / 运营」角色分配该菜单与按钮码。 --- ## 不动的地方 - `slot_console` 既有 `FreeCreditsLogic` C 端逻辑、`free_credits_player` / `free_credits_package` 表结构、`ext_config` 配置读取等不动 - `s_recharge_gift_config` 不需要新增列 - `slot_pay` 不新增 innerapi(不需要订单表精确口径,wallet `total_deposit` 已足够) - 现有 `ActivityController` 编辑表单(type=11 分支)已落地,不动 - `RechargeOrderController` / `withdrawal` 等无关模块不动 ## 验收(手测) 按 [dev-environment](.cursor/rules/dev-environment.mdc) 在 docker 内调试: 1. 准备 3 个测试账号:A 未定格、B 已定格未提现首档、C 全部完成;各 source 至少一个 2. 访问 `/game/freeCreditsStats` 列表: - 不传参 → 默认按 `first_recharge_time desc`,A 不出现(status<2 被过滤),B / C 都在 - 顶部 7 个统计数字与逐条手算结果一致 - 列表里 `total_recharge_amount_qf` 与 `slot_wallet.api/wallet/statistics` 直接拉的 `total_deposit` 一致 - `progress_done/total` 等于 DB 中 `free_credits_package` 实际行数 3. 筛选: - 选 `source=us_01` 列表与统计同步收敛 - 选 `first_recharge_time` 范围,统计 7 项随之变化 - 选 `is_completed=1`,列表只剩 C - 选 `first_cash_status=3`,列表只剩第一档已成功的玩家 4. 排序:分别点击「定格金额」「定格时间」列头,order 切换;其它字段不可排序 5. 权限:用未授权账号访问 → 403;授权后正常 6. 仅改活动状态时(已存在的活动编辑场景)回归一遍,确认未触发 type=11 ext 校验路径回归