This commit is contained in:
ray zhou
2026-05-29 11:21:40 +08:00
parent 5d6d482efe
commit f71a5c59af
447 changed files with 32245 additions and 116 deletions

View File

@@ -0,0 +1,39 @@
---
name: console返水过期兜底
overview: 在 C 端返水信息接口增加“近7天过期兜底”逻辑若存在未领取且已过期记录进入接口时自动标记为 expired避免依赖定时脚本。
todos:
- id: locate-info-hook
content: 在 DailyRebateLogic::info() 增加近7天过期兜底调用点查询记录前
status: pending
- id: add-scope-expire-method
content: 新增用户+近7天范围的过期更新方法仅更新 claimable 且已过期记录
status: pending
- id: verify-behavior
content: 按过期/未过期/超范围三类样例验证 info 返回状态符合预期
status: pending
isProject: false
---
# Console返水信息过期兜底改造
## 目标
在用户访问返水信息接口时对该用户“近7天”记录执行一次轻量过期兜底`status=claimable``expire_at<=当前时间` 的记录更新为 `expired`,避免脚本漏跑导致前端状态不一致。
## 改动方案
- 在 [`/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php`](/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php) 的 `info()` 开头(`$unlocked` 判定后、组装 records 前)新增一步:
- 仅针对当前 `uid`、统计日期范围“今天往前 6 天到今天”即接口展示的近7天执行过期更新。
- 更新条件:`status = STATUS_CLAIMABLE``expire_at <= now()`
- 更新结果无需抛错,作为兜底处理(可选记录 debug/info 日志)。
- 在同文件新增受保护方法(例如 `expireRecentClaimableForInfo(int $uid): int`)承载该逻辑,保持 `info()` 主流程清晰,符合 Logic 分层。
- 保留现有 `expireDueRecords()`(全量定时任务用),新方法仅用于 C 端接口的“用户级小范围补偿”,两者职责互补,不互相替代。
## 细节约束
- 范围严格限定为近7天避免接口触发全表更新。
- 仅改 `claimable -> expired`,不触碰 `claimed` / `pending`
- 执行顺序放在读取记录前,保证本次 `records` 返回的状态已是最新。
## 验证计划
- 构造 1 条近7天内 `claimable + expire_at 已过期` 数据,调用 `info` 后应返回 `expired`
- 构造 1 条近7天内 `claimable + expire_at 未到期` 数据,调用 `info` 后仍为 `claimable`
- 构造 1 条 7 天外过期 `claimable` 数据,调用 `info` 后不应被本接口更新(仍由定时任务处理)。
- 回归 `claim` 接口:已过期记录仍不可领取(现有保护逻辑保持不变)。

View File

@@ -0,0 +1,59 @@
---
name: Create Cursor Workspace
overview: 在 `/Users/ray/Documents/project/www/slot` 创建一个 Cursor/VS Code workspace 文件,并把该目录下的一级项目文件夹加入 workspace。默认排除 `.vscode` 和普通文件。
todos:
- id: check-existing
content: 检查 `/Users/ray/Documents/project/www/slot/slot.code-workspace` 是否已存在
status: completed
- id: write-workspace
content: 创建或合并 workspace folders 列表
status: completed
- id: validate-json
content: 校验 `.code-workspace` JSON 格式
status: completed
isProject: false
---
# Create Slot Workspace
将在 [`/Users/ray/Documents/project/www/slot/slot.code-workspace`](/Users/ray/Documents/project/www/slot/slot.code-workspace) 创建 workspace 文件,内容使用标准 `.code-workspace` JSON 格式。
计划加入这些一级非隐藏文件夹:
- [`backend`](/Users/ray/Documents/project/www/slot/backend)
- [`monitor`](/Users/ray/Documents/project/www/slot/monitor)
- [`slot-foundation`](/Users/ray/Documents/project/www/slot/slot-foundation)
- [`slot_agent`](/Users/ray/Documents/project/www/slot/slot_agent)
- [`slot_agent_vue`](/Users/ray/Documents/project/www/slot/slot_agent_vue)
- [`slot_center`](/Users/ray/Documents/project/www/slot/slot_center)
- [`slot_console`](/Users/ray/Documents/project/www/slot/slot_console)
- [`slot_gateway`](/Users/ray/Documents/project/www/slot/slot_gateway)
- [`slot_hub`](/Users/ray/Documents/project/www/slot/slot_hub)
- [`slot_lib`](/Users/ray/Documents/project/www/slot/slot_lib)
- [`slot_notification`](/Users/ray/Documents/project/www/slot/slot_notification)
- [`slot_pay`](/Users/ray/Documents/project/www/slot/slot_pay)
- [`slot_pwa`](/Users/ray/Documents/project/www/slot/slot_pwa)
- [`slot_risk`](/Users/ray/Documents/project/www/slot/slot_risk)
- [`slot_sdk`](/Users/ray/Documents/project/www/slot/slot_sdk)
- [`slot_user`](/Users/ray/Documents/project/www/slot/slot_user)
- [`slot_wallet`](/Users/ray/Documents/project/www/slot/slot_wallet)
Workspace 文件结构会类似:
```json
{
"folders": [
{ "path": "backend" },
{ "path": "monitor" },
{ "path": "slot-foundation" }
],
"settings": {}
}
```
实施步骤:
1. 检查目标文件是否已存在,避免覆盖已有 workspace 配置。
2. 如果不存在,创建 [`slot.code-workspace`](/Users/ray/Documents/project/www/slot/slot.code-workspace)。
3. 如果已存在,先读取现有内容,再合并缺失的文件夹,保留已有设置。
4. 校验生成的 JSON 格式可被 Cursor 打开。

View File

@@ -0,0 +1,122 @@
---
name: C端 packages 响应精简
overview: 在 slot_console 的 Free Credits 定格状态接口(`/api/free-credits/status``claim` 共用 `buildStatus`)中,精简 `packages[]` 每项字段,并将档位 status 4/5 映射为 3使 C 端仅见 03。后台 innerapi 与库表语义不变。
todos:
- id: model-list-for-client
content: 在 FreeCreditsPackageModel 实现 mapStatusForClient并精简 listForClient 返回字段
status: in_progress
- id: update-phpdoc
content: 更新 FreeCreditsLogic、FreeCreditsController 的 packages/status PHPDoc
status: pending
- id: update-unit-tests
content: 调整 FreeCreditsClientStatusTest / Harness并补充 status 4/5→3 用例
status: pending
- id: run-phpunit
content: 在 php82 容器内跑 FreeCreditsClientStatusTest 验证
status: pending
isProject: false
---
# C 端定格状态 packages 响应调整
## 背景与范围
目标接口在 [slot_console/app/api/controller/FreeCreditsController.php](slot_console/app/api/controller/FreeCreditsController.php)
- `POST /api/free-credits/status``FreeCreditsLogic::status()``buildStatus()`
- `POST /api/free-credits/claim` 成功后的 `data` 结构相同
`packages[]` 当前由 [slot_console/app/model/common/FreeCreditsPackageModel.php](slot_console/app/model/common/FreeCreditsPackageModel.php) 的 `listForClient()` 组装,经 `FreeCreditsLogic::clientPackagesForPlayer()` 注入响应:
```72:86:slot_console/app/model/common/FreeCreditsPackageModel.php
public static function listForClient(int $playerId, callable $formatAmount): array
{
// ...
$packages[] = [
'id' => intval($row->id),
'package_no' => intval($row->package_no),
'package_type' => intval($row->package_type),
'amount' => $formatAmount(intval($row->amount_qf)),
'status' => intval($row->status),
];
```
库表档位 status[install.sql](slot_console/db/install.sql)`0` 锁定、`1` 可操作、`2` 处理中、`3` 已完成、`4` 失败、`5` 风控拒绝。
**不在本次范围**`innerapi/free-credits/*`(后台仍用 `package_no` / `package_type` 聚合)、库表与 Logic 内按 `package_no` / `package_type` 的业务判断。
## 目标契约
`packages[]` 每项仅保留:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | 档位主键claim / 第一档提现仍传 `package_id` |
| `amount` | float | 展示大单位,逻辑不变 |
| `status` | int | **仅可能为 0、1、2、3**DB 为 4 或 5 时对外返回 3 |
列表仍按 `package_no` 升序(查询不变,仅响应不暴露 `package_no`。C 端可用数组下标区分第一档(`[0]`)与后续档。
Status 映射(仅 C 端展示层):
```mermaid
flowchart LR
db0[DB 0 locked] --> c0[C 0]
db1[DB 1 ready] --> c1[C 1]
db2[DB 2 processing] --> c2[C 2]
db3[DB 3 completed] --> c3[C 3]
db4[DB 4 failed] --> c3
db5[DB 5 rejected] --> c3
```
## 实现步骤
### 1. 修改 `FreeCreditsPackageModel::listForClient()`
文件:[FreeCreditsPackageModel.php](slot_console/app/model/common/FreeCreditsPackageModel.php)
- 新增私有/公有静态方法(建议 `mapStatusForClient(int $status): int`),将 `STATUS_FAILED(4)`、`STATUS_REJECTED(5)` 转为 `STATUS_COMPLETED(3)`,其余原样返回。
- `listForClient()` 返回项改为 `{ id, amount, status }``status` 走映射方法。
- 更新 `@return` PHPDoc`list<array{id:int,amount:float,status:int}>`,并注明 C 端 status 取值 03。
### 2. 同步 Logic / Controller 文档
- [FreeCreditsLogic.php](slot_console/app/api/logic/FreeCreditsLogic.php)`clientPackagesForPlayer()` 的 `@return` 与注释。
- [FreeCreditsController.php](slot_console/app/api/controller/FreeCreditsController.php)`status()` / `claim()` 中 `packages` 字段说明——去掉 `package_no`、`package_type``status` 改为 `0 locked ~ 3 completed失败/拒绝对外亦为 3`。
### 3. 单测
- [FreeCreditsClientStatusTest.php](slot_console/tests/Unit/FreeCreditsClientStatusTest.php)`PACKAGE_KEYS` 改为 `['id','amount','status']`;注入样例去掉 `package_no` / `package_type`。
- 新增用例(二选一或都做):
- 对 `mapStatusForClient`:断言 `4→3`、`5→3`、`03` 不变;
- 或在 harness 注入 `status=4/5` 的 package断言 `buildStatus` 输出为 `3`。
- [FreeCreditsLogicHarness.php](slot_console/tests/Support/FreeCreditsLogicHarness.php):更新 `injectClientPackages` 相关 PHPDoc 类型。
运行Docker
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/FreeCreditsClientStatusTest.php
```
## 数据流(变更后)
```mermaid
sequenceDiagram
participant C as C端
participant API as FreeCreditsController
participant Logic as FreeCreditsLogic
participant Model as FreeCreditsPackageModel
C->>API: status / claim
API->>Logic: buildStatus
Logic->>Model: listForClient
Model->>Model: mapStatusForClient
Model-->>Logic: id, amount, status
Logic-->>C: packages[]
```
## 风险与说明
- **C 端若已依赖 `package_no` / `package_type` 或区分 4/5**:需同步改 UI服务端 claim 仍用 `package_id`,顺序校验仍在 Logic/DB不依赖响应里的 `package_no`。
- **失败/拒绝与成功完成在 UI 上同为 status=3**:符合当前需求;若以后要区分展示,需另加字段或改映射规则。
- 单测 [FreeCreditsClientStatusTest](slot_console/tests/Unit/FreeCreditsClientStatusTest.php) 期望顶层含 `first_cash_amount`,但 [buildStatus](slot_console/app/api/logic/FreeCreditsLogic.php) 当前未返回该字段——与本次改动无关,不纳入本 PR除非你希望一并补齐。

View File

@@ -0,0 +1,156 @@
---
name: C端 status 响应补全
overview: 完成 Free Credits 定格状态接口 C 端响应packages 精简与 status 映射Model 已改、banner_image、首笔提现成功广播 broadcast真实 Top10 + 不足补假数据),并同步单测与文档。
todos:
- id: add-banner-image
content: 在 buildConfigClientFields / buildStatus / buildPreEnrollmentStatus 返回 banner_image
status: completed
- id: add-broadcast-list
content: 实现 broadcast 列表(真实首档 completed Top10 + 假数据补齐至 10 条)
status: completed
- id: finish-packages-docs
content: 更新 FreeCreditsController PHPDocpackages、banner_image、broadcast
status: completed
- id: update-unit-tests
content: 调整 FreeCreditsClientStatusTest / Harness补充 banner_image、status 映射、broadcast 用例
status: completed
- id: run-phpunit
content: php82 容器跑 FreeCreditsClientStatusTest 验证
status: completed
isProject: false
---
# C 端定格 status 响应补全
## 当前进度
[FreeCreditsPackageModel.php](slot_console/app/model/common/FreeCreditsPackageModel.php) **已完成**
- `mapStatusForClient()`DB status 4/5 → 3
- `listForClient()`:仅返回 `id``amount``status`
**待完成**`banner_image``broadcast`、Logic/Controller 文档、单测。
## 1. `banner_image`(活动 Banner
配置:`ext_config.banner_image`[activity/edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue)
在 [FreeCreditsLogic.php](slot_console/app/api/logic/FreeCreditsLogic.php) 的 `buildConfigClientFields()` 增加 `banner_image``buildStatus()` / `buildPreEnrollmentStatus()` 透传;`status=-1` 仍仅 `{ status: -1 }`
## 2. `broadcast`(首笔提现成功最近 10 人)
对齐需求文档 [§13 广播模块](docs/requirements/首充前免费余额定格与分档释放需求文档.md):弹窗展示最近 10 条;用户本次明确要求 **仅首笔提现成功**(非后续 claim
### 2.1 顶层字段
- 字段名:**`broadcast`**`array`,固定长度 **10**
- 每项结构:
```json
{ "username": "U1***3", "amount": 20.0 }
```
| 子字段 | 类型 | 说明 |
|--------|------|------|
| `username` | string | 脱敏账号,展示用 |
| `amount` | float | 该用户首笔免打码提现金额(展示大单位) |
C 端文案示例:`🎉 U1***3 just cashed out $20.00`(前端拼接,接口只给结构化数据)。
### 2.2 真实数据查询
在 [FreeCreditsPackageModel.php](slot_console/app/model/common/FreeCreditsPackageModel.php) 新增查询方法(仅查库,不做脱敏):
```php
public static function listRecentFirstCashoutCompleted(int $activityId, int $limit = 10): array
```
条件:
- `activity_id = ?`
- `package_type = TYPE_FIRST_CASH(1)`
- `status = STATUS_COMPLETED(3)`
- `ORDER BY completed_time DESC``completed_time` 为空则 fallback `update_time`
- `LIMIT 10`
返回行至少含:`uid``amount_qf`
### 2.3 组装与脱敏Logic
在 [FreeCreditsLogic.php](slot_console/app/api/logic/FreeCreditsLogic.php) 新增 `buildBroadcastList(?Model $config): array`
1.`activity_id` 时查真实记录,逐条:
- `amount` = `formatClientAmount(amount_qf)`
- `username` = `maskDisplayName(account)``UserService::getUserInfoEntity($uid)->account`,规则与 [GameLatestLogic::username()](slot_console/app/napi/logic/GameLatestLogic.php) 一致:`substr(0,2) + '***' + substr(-1)`account 为空时用 `strval(uid)` 再脱敏
2. 若真实条数 `< 10`,用 **`buildFakeBroadcastItems($need, $config)`** 补齐:
- `amount`:取当前活动 `first_cash_amount``configAmount` + `formatClientAmount`),可在 ±10% 内随机浮动(整数分位),避免 10 条完全相同
- `username`:随机生成 `U` + 58 位数字再脱敏(勿与真实 uid 重复)
3. 合并后 **截断/保证恰好 10 条**(真实在前,假数据在后)
`buildStatus()` / `buildPreEnrollmentStatus()` 增加:
```php
'broadcast' => $this->buildBroadcastList($config),
```
`status=-1` 不返回 `broadcast`
### 2.4 分层说明
- **Model**:只负责按条件查最近 N 条首档 completed
- **Logic**:脱敏、金额格式化、假数据补齐(业务展示规则,不抽到 Service
```mermaid
flowchart LR
pkgTable[free_credits_package]
modelQuery[listRecentFirstCashoutCompleted]
logicBuild[buildBroadcastList]
fakePad[buildFakeBroadcastItems]
statusAPI["status data.broadcast"]
pkgTable --> modelQuery --> logicBuild
logicBuild --> fakePad --> statusAPI
```
## 3. packages 文档收尾
[FreeCreditsController.php](slot_console/app/api/controller/FreeCreditsController.php)`packages` 项为 `id``amount``status(03)`;补充 `banner_image``broadcast`
## 4. 单测
[FreeCreditsClientStatusTest.php](slot_console/tests/Unit/FreeCreditsClientStatusTest.php)
| 项 | 调整 |
|---|---|
| `PLAYER_TOP_KEYS` | 增加 `banner_image``broadcast`**移除** `first_cash_amount`(与当前 `buildStatus` 一致) |
| `PACKAGE_KEYS` | `['id','amount','status']` |
| 新增 | `testMapStatusForClientMapsFailedAndRejected` |
| 新增 | `testBuildStatusIncludesBannerImageFromExtConfig` |
| 新增 | `testBuildBroadcastAlwaysReturnsTenItems`harness 注入空真实列表,断言 `count(broadcast)==10`、每项含 `username`/`amount` |
[FreeCreditsLogicHarness.php](slot_console/tests/Support/FreeCreditsLogicHarness.php):可覆写 `buildBroadcastList` 或注入 Model 查询结果,避免单测连库。
## 5. 验证
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/FreeCreditsClientStatusTest.php
```
## 响应示例status≥0
```json
{
"status": 2,
"frozen_amount": 78.5,
"win_threshold": 50.0,
"recharge_unlock_amount": 50.0,
"help": "...",
"banner_image": "https://cdn.example.com/xxx.png",
"broadcast": [
{ "username": "U1***3", "amount": 20.0 },
{ "username": "U9***2", "amount": 20.0 }
],
"packages": [
{ "id": 101, "amount": 20.0, "status": 1 }
]
}
```
注:`broadcast` 数组长度恒为 10示例仅展示 2 条。

View File

@@ -0,0 +1,37 @@
---
name: daily-rebate-info调整
overview: 恢复 `info``tiers` 输出,并明确继续使用共享 Redis 实时计算当天返水,补齐稳定性与返回一致性。
todos:
- id: restore-tiers
content: 在 DailyRebateLogic::info 恢复 tiers 返回并保持现有档位格式
status: completed
- id: harden-today-redis
content: 加固 readTodayBetFromRedis 容错,确保实时计算稳定
status: completed
- id: verify-info-contract
content: 核对 info 字段契约并运行 verify-slot-backend 脚本
status: completed
isProject: false
---
# DailyRebate Info 返回修正计划
## 目标
-`DailyRebate info` 中恢复 `tiers` 字段。
- 当天数据继续采用“共享 Redis 读取下注 + 本地按档位计算返水”的方案。
- 保持近 7 天固定返回与展示金额口径不变。
## 变更点
- 更新 [`/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php`](/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php)
-`info()` 返回结构中重新加入 `tiers`(复用现有档位格式化逻辑)。
- 保持 `records` 固定 7 天(无数据补 0 展示金额)。
- 明确当天逻辑:从 `readTodayBetFromRedis()` 读取共享 Redis 的当日下注,调用 `DailyRebateCalcService::calcRebateLi()` 计算当日 `rebate_display`
- 为 Redis 空值/脏值场景补轻量容错(反序列化失败时按 0 处理,避免接口抖动)。
- 只读一致性确认(不改跨服务架构)
- 依据 [`/Users/ray/Documents/project/www/slot/slot_pwa/app/service/user/UserProfitService.php`](/Users/ray/Documents/project/www/slot/slot_pwa/app/service/user/UserProfitService.php) 与 [`/Users/ray/Documents/project/www/slot/slot_pwa/app/service/RedisKeyManagerService.php`](/Users/ray/Documents/project/www/slot/slot_pwa/app/service/RedisKeyManagerService.php),继续对齐 `Redis::connection('share') + getUserProfitKey(date('Ymd'))` 键模型。
## 验证
- 检查 `info` 返回字段:包含 `tiers``title/subtitle/help``records(7条)``claimable`
- 抽样验证当天记录:有 Redis 下注时 `rebate_display` 非 0无下注时为 0。
- 执行门禁脚本:`~/.cursor/hooks/verify-slot-backend.sh`

View File

@@ -0,0 +1,132 @@
---
name: DailyRebateLogic重构
overview: 按 `php-code` 规范重构每日返水领取链路:`claim` 编排化、BusinessException、Model 条件更新;同步调整 Controller 异常映射。`settleDate`/`info` 仅做必要小抽取,不改业务语义。
todos:
- id: model-mark-claimed
content: DailyRebateRecordModel 增加 markClaimedIfClaimable + PHPDoc
status: pending
- id: logic-claim-refactor
content: DailyRebateLogicclaim 拆 assert/perform/formatBusinessException
status: pending
- id: logic-info-helper
content: 可选isClaimableRecord 供 info 与 assert 复用
status: pending
- id: controller-exception
content: DailyRebateControllercatch BusinessException → FAILED
status: pending
- id: verify-smoke
content: 跑 verify-slot-backend.sh确认无 RuntimeException 新增
status: pending
isProject: false
---
# DailyRebateLogic 规范重构
## 范围AskQuestion 中断,按推荐默认)
- **必做**[`claim`](slot_console/app/api/logic/DailyRebateLogic.php)、[`DailyRebateController::claim`](slot_console/app/api/controller/DailyRebateController.php)、[`DailyRebateRecordModel`](slot_console/app/model/common/DailyRebateRecordModel.php)
- **轻量**`info()` 抽取「是否可领」判断为 private`assertClaimableRecord` 复用逻辑
- **不改语义**`settleDate` / `expireDueRecords` / Redis 结算逻辑保持行为一致,仅在有重复代码时抽 1 个 private可选
## 现状问题(对照 php-code
[`claim`](slot_console/app/api/logic/DailyRebateLogic.php) L92157
- 6 段 `RuntimeException` if 墙
- CAS 写在 Logic 内联,未沉淀 Model
- Controller `catch RuntimeException` + `PARAMS_ERROR`verify 会拦新增行)
参照:[`FreeCreditsLogic::claim`](slot_console/app/api/logic/FreeCreditsLogic.php)`assert*` + `BusinessException` + 状态更新)
## 目标结构
```mermaid
sequenceDiagram
participant C as DailyRebateController
participant L as DailyRebateLogic
participant M as DailyRebateRecordModel
participant W as WalletService
C->>L: claim(uid, source, statDate)
L->>L: assertActivityEnabled
L->>L: assertUserDeposited
L->>M: findByUidAndDate
L->>L: assertClaimableRecord
L->>M: markClaimedIfClaimable
L->>W: gift(rebate, DAILY_REBATE, remark)
L-->>C: formatClaimResult
```
### 1. Model条件更新
在 [`DailyRebateRecordModel`](slot_console/app/model/common/DailyRebateRecordModel.php) 新增:
```php
/**
* 待领取状态下标记为已领取CAS
*
* @return int 影响行数1 表示成功
*/
public static function markClaimedIfClaimable(int $id): int
```
实现:`where id` + `where status = STATUS_CLAIMABLE``STATUS_CLAIMED` + `claimed_at`
### 2. Logic`claim` 拆分为编排 + assert + perform
| 方法 | 职责 |
|------|------|
| `claim()` public | 编排 ≤20 行:`resolveSource` → 默认 `statDate` → assert → `performClaim` → 返回 |
| `assertActivityEnabled(string $source)` | 活动未开 → `BusinessException` |
| `assertUserDeposited(int $uid)` | 未充值 → `BusinessException` |
| `assertClaimableRecord(?DailyRebateRecordModel $record)` | 不存在/状态/金额/过期 → 各一条 `BusinessException`(合并原 4 条 if |
| `performClaim(int $uid, DailyRebateRecordModel $record, string $statDate)` | 事务:`markClaimedIfClaimable``WalletService::gift` → commit`affected !== 1` 或 wallet 空 → `BusinessException` |
| `formatClaimResult(...)` | 返回 `stat_date` / `rebate_amount` / `display` / `balance` |
- 使用 `support\exception\BusinessException`(与 FreeCredits 一致)
- `@throws` 改为 `BusinessException``\Throwable`(钱包失败)
- **钱包 biz_id**`WalletService::gift()` 内部已 `generateOrderId`[`slot_lib/src/services/WalletService.php`](slot_lib/src/services/WalletService.php) L262265幂等依赖 **记录 CAS**remark 保持 `每日返水 {statDate}` 便于对账
### 3. Logic`info` 轻量复用(可选)
抽取 `isClaimableRecord(DailyRebateRecordModel $record): bool`status + amount + expire`info()``claimable` 块与 `assertClaimableRecord` 共用,避免两套判断漂移。
### 4. Controller异常映射
[`DailyRebateController::claim`](slot_console/app/api/controller/DailyRebateController.php)
```php
} catch (BusinessException $e) {
return $this->errorCode(ErrorCode::FAILED, $e->getMessage());
}
```
- 对齐 [`SignController`](slot_console/app/api/controller/SignController.php)(业务失败 `FAILED` + 文案)
- **API 变更**`code``40003`PARAMS_ERROR变为 `1`FAILED文案仍为中文业务提示。若 PWA 强依赖 `40003`,可在计划中改为保留 `PARAMS_ERROR`(实现时二选一,默认 `FAILED`
不采用 FreeCredits「无 catch、走全局 Handler」方式避免未捕获时变成 `SYSTEM_ERROR`50001
### 5. 不动 / 谨慎
- **事务边界**:维持「先 CAS 再 gift 再 commit」不在此 PR 改为「先 wallet 后 DB」或 Outbox
- **`settleDate`**:逻辑不变;可选抽 `settleOneUserFromRedis(...)` 降低 foreach 嵌套(非必须)
## 文件清单
| 文件 | 变更 |
|------|------|
| `slot_console/app/model/common/DailyRebateRecordModel.php` | +`markClaimedIfClaimable` |
| `slot_console/app/api/logic/DailyRebateLogic.php` | 重构 `claim`;可选 `isClaimableRecord` |
| `slot_console/app/api/controller/DailyRebateController.php` | `BusinessException` + `FAILED` |
## 验收
1. `docker exec -w /app/www/slot/slot_console php82 php webman dailyRebateSettle --date=...`(如有环境)结算后,已充值用户可领昨日返水
2. 重复领取 → 业务错误文案DB 仍为 `CLAIMED`,不重复入账
3. `~/.cursor/hooks/verify-slot-backend.sh` → PASS无新增 `RuntimeException` 业务态)
4. 最终回复含 `PHPDoc: checked`(触及符号补全 `@throws BusinessException`
## 风险与回滚
- **PWA 错误码**:若前端按 `code===40003` 分支,需同步前端或 Controller 保留 `PARAMS_ERROR`
- 回滚:还原 3 个文件即可

View File

@@ -0,0 +1,121 @@
---
name: Docker 环境 Cursor 规则
overview: 建议把 Docker 开发环境信息写成 Cursor 规则,但单独一条、尽量简短;优先放在 slot 项目级规则,只有跨多个仓库共用同一套 Docker 时才放到用户级规则。
todos:
- id: decide-scope
content: 确认 Docker 规则放项目级 (slot) 还是用户级 (~/.cursor/rules)
status: completed
- id: confirm-php-container
content: 确认 slot 后端默认容器 php82 与各服务 working_dir
status: completed
- id: create-dev-rule
content: 新建 dev-environment.mdc1530 行,含 compose 路径、容器名、端口、exec 示例)
status: completed
isProject: false
---
# Docker 环境是否写入 Cursor 规则
## 结论
**值得写进 Cursor 规则**,但**不必**和 [`backend-layering.mdc`](/Users/ray/.cursor/rules/backend-layering.mdc) 混在同一条里,也**不必**默认全部塞进「用户级 + alwaysApply」。
原因Agent 在帮你跑 `php``composer``artisan`、迁移、单测、连 Redis/MySQL 时,若不知道服务在容器里,常会错误地在宿主机执行,或连错端口(例如 MySQL 映射是 `3309:3306`)。
---
## 用户级 vs 项目级:怎么选
| 放置位置 | 路径 | 适用场景 |
| --- | --- | --- |
| **项目级(推荐)** | 例如在 slot 多根工作区根目录建 [`.cursor/rules/dev-environment.mdc`](file:///Users/ray/Documents/project/www/slot/.cursor/rules/dev-environment.mdc) | 只有 slot / `www/slot` 相关仓库用这套 Docker |
| **用户级** | [`~/.cursor/rules/dev-environment.mdc`](/Users/ray/.cursor/rules/dev-environment.mdc) | 多个不相关项目都共用 [`/Users/ray/Documents/project/docker/docker-compose.yml`](file:///Users/ray/Documents/project/docker/docker-compose.yml) |
你当前用户级只有分层规范一条,且 `alwaysApply: true`。Docker 信息属于**运行环境**,和编码规范是不同关注点:
- **分层规则**:继续 `alwaysApply: true`(跨项目仍有用)
- **Docker 规则**:建议 `alwaysApply: false`,或仅在 slot 工作区用项目级规则避免在写前端、文档、Figma 时也占用上下文
当前 slot 工作区下**没有** [`.cursor/rules/`](file:///Users/ray/Documents/project/www/slot/.cursor/rules),更适合为 slot 单独加一条 `dev-environment.mdc`
---
## 规则里应写什么(可操作、短)
根据你的 [`docker-compose.yml`](file:///Users/ray/Documents/project/docker/docker-compose.yml),建议只写 Agent **执行命令时必需** 的信息:
1. **Compose 位置**`/Users/ray/Documents/project/docker/docker-compose.yml`
2. **容器名 → 用途**(执行命令用 `docker exec`,不要用宿主机 PHP/CLI
- `php82` — PHP 8.2slot 后端主环境,按你实际版本确认)
- `php72` — PHP 7.2(若有老项目)
- `goMysql` — MySQL 8容器内 `3306`,宿主机 **`3309`**
- `redis``6379``goredis` — 宿主机 `6378`
3. **挂载路径**:宿主机 `/Users/ray/Documents/project` → 容器内 `/app`(项目在容器内路径如 `/app/www/slot/backend`
4. **命令约定**(示例,按你项目真实入口改):
- PHP`docker exec -w /app/www/slot/backend php82 php ...`
- Composer / artisan同样在 `php82` 内、对应 `working_dir` 执行
- MySQL CLI`docker exec -it goMysql mysql -uroot -proot ...`(或注明用宿主机 `127.0.0.1:3309`
5. **明确禁止/避免**:不要在 macOS 宿主机直接跑 `php`/`composer`(除非已确认本机也有同版本环境)
**不建议写进规则的内容**
- 完整 `docker-compose.yml` 复制(冗长、易过期)
- 所有服务密码细节compose 里已有;规则里写「以 compose 为准」即可)
- RabbitMQ、Milvus 等与当前 slot 任务无关的服务(除非经常用到)
---
## 与现有文档的关系
- **Cursor 规则**:给 Agent 的「默认假设」,每次对话自动带上(按 `alwaysApply` / `globs`
- **仓库 README / `docs/dev-setup.md`**:给人看的完整说明;规则可写一句「详细步骤见 xxx」
两者可并存:规则 1530 行,文档可更长。
---
## 建议的规则骨架(项目级示例)
```markdown
---
description: Slot 本地 Docker 开发环境PHP / MySQL / Redis
alwaysApply: true
---
# Local Dev (Docker)
- Compose: `/Users/ray/Documents/project/docker/docker-compose.yml`
- Host project root: `/Users/ray/Documents/project` → container `/app`
- Run PHP/Composer/Artisan inside container `php82`, not on macOS host.
- MySQL: container `goMysql`; from host use `127.0.0.1:3309`.
- Redis: container `redis`, host port `6379`.
- Example: `docker exec -w /app/www/slot/backend php82 php artisan ...`
```
若放在**用户级**,把 `alwaysApply` 改为 `false`或标题改成「Project docker (ray)」以免污染非 slot 项目。
---
## 推荐决策
```mermaid
flowchart TD
Q[多个仓库共用同一套 Docker?]
Q -->|是| UserRule["~/.cursor/rules/dev-environment.mdc\nalwaysApply: false"]
Q -->|否 仅 slot| ProjRule["www/slot/.cursor/rules/dev-environment.mdc\nalwaysApply: true 在该工作区"]
Both[保留 backend-layering 在用户级 alwaysApply]
UserRule --> Both
ProjRule --> Both
```
**对你当前情况**slot 多仓库工作区 + Docker 在 `project/docker`**优先项目级规则**;仅当你打开的其他 Cursor 工作区(非 slot也依赖同一 compose 时,再复制一份到用户级。
---
## 下一步(你确认后可执行)
1. 在 [`www/slot/.cursor/rules/`](file:///Users/ray/Documents/project/www/slot/.cursor/rules) 新建 `dev-environment.mdc`(约 20 行)
2. 确认 slot 后端默认用 `php82` 还是 `php72`,以及各子服务在容器内的 `working_dir`
3. 可选:在 [`docs/`](file:///Users/ray/Documents/project/www/slot/docs) 增加 `dev-setup.md` 供人查阅,规则里链过去
无需修改现有的 `backend-layering.mdc`

View File

@@ -0,0 +1,47 @@
---
name: first_cashout审核类型对齐
overview: 将 `FreeCreditsLogic::buildFirstCashoutWithdrawalInfo` 中写死的 `auditType=1` 改为与正常提现一致的判定流程:金额阈值 + 黑名单/倍率风控强制人工。
todos:
- id: inspect-normal-rule
content: 提炼 WithdrawService 中正常提现 auditType 判定口径(金额+风控)
status: completed
- id: add-first-cash-audit-evaluator
content: 在 FreeCreditsLogic 增加第一档提现 auditType 判定方法
status: completed
- id: wire-build-method
content: 替换 buildFirstCashoutWithdrawalInfo 中写死 auditType 为动态判定
status: completed
- id: run-verification-gate
content: 执行 verify-slot-backend.sh 并检查结果
status: completed
isProject: false
---
# First Cashout 审核类型对齐计划
## 目标
`slot_console` 中第一档独立提现的审核类型判定,从固定自动审核改为与正常提现流程一致,避免风控场景下误走自动审核。
## 现状结论
- 在 [`/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php`](/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php) 的 `buildFirstCashoutWithdrawalInfo()` 里目前写死:`'auditType' => 1`
- 正常提现在 [`/Users/ray/Documents/project/www/slot/slot_console/app/service/WithdrawService.php`](/Users/ray/Documents/project/www/slot/slot_console/app/service/WithdrawService.php) 的 `apply()` 中处理逻辑是:
- 先通过 `getAuditType($amount)``auto_audit_max` 决定自动/人工。
- 再结合 `BlackApiService::status()` 与提现倍率(`totalW / wallet->r` 对比 `recharge_times`)在命中时强制人工(`auditType=2`)。
## 实施方案
1. 在 [`/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php`](/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php) 新增一个“第一档提现审核类型判定”私有方法(复用正常提现判定口径)。
2. 判定方法内对齐正常流程:
- 读取提现配置 `auto_audit_max`,按第一档固定金额(`package->amount_qf` 转展示金额后)先算基础 `auditType`
- 查询黑名单状态,非白名单直接人工审核。
- 白名单时按当前钱包数据计算倍率并与 `recharge_times` 比较,命中则人工审核。
3.`buildFirstCashoutWithdrawalInfo()` 中移除写死值,改为调用新判定方法赋值 `auditType`
4. 保持第一档提现已有业务语义不变(固定金额、`fee=0``bizType` 不改),只修正审核类型来源。
5. 变更后执行门禁校验脚本:`~/.cursor/hooks/verify-slot-backend.sh`,并在结果中确认通过。
## 影响文件
- [`/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php`](/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php)
## 验证要点
- 第一档提现在低金额且白名单情况下:`auditType=1`
- 超过 `auto_audit_max` 或命中黑名单/倍率规则时:`auditType=2`
- 订单申请链路仍可正常调用 `PayService::apply()`,不影响原有字段结构。

View File

@@ -0,0 +1,181 @@
---
name: firstCashout 合并代码评审
overview: 你对「merge post + 统一 WithdrawService::apply」的改法方向正确但当前 WithdrawService 在 package_id>0 时仍执行 checkInfo/手续费/黑规则,且 Pay 失败无回滚,会导致第一档提现不可用或卡在 processing。
todos:
- id: fc-early-return
content: WithdrawService::apply 中 package_id>0 走独立 applyFreeCreditsFirstCashout 并 early return
status: completed
- id: skip-checkinfo-fc
content: FC 分支跳过 checkInfo、getAmountAndFee、黑规则或产品确认保留项
status: completed
- id: pay-fail-rollback
content: Pay apply 失败时 handleFirstCashoutResult 回滚 processing
status: completed
- id: fc-withdrawal-info
content: FC 使用 package amount_qf、fee=0、auditType=2 组 WithdrawalInfo
status: completed
isProject: false
---
# firstCashout 合并改动 — 代码评审
## 你的改动(理解正确)
```mermaid
sequenceDiagram
participant Ctrl as WithdrawController
participant FC as FreeCreditsLogic
participant WS as WithdrawService
participant Pay as slot_pay
Ctrl->>FC: mergeFirstCashoutIntoPost
Note over FC: amount + bizType + package_id
Ctrl->>WS: apply(DTO)
WS->>FC: firstCashout(uid, packageId, orderId)
Note over FC: markFirstCashoutProcessing
WS->>Pay: apply(WithdrawalInfo)
```
- [`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php):有 `package_id` 先 merge再**同一套** `validate($type)` — 符合预期。
- [`mergeFirstCashoutIntoPost`](slot_console/app/api/logic/FreeCreditsLogic.php):补 `amount` / `bizType` / `package_id` — 符合预期。
- [`firstCashout`](slot_console/app/api/logic/FreeCreditsLogic.php) 收窄为只 `markFirstCashoutProcessing` + 外部统一 `PayService::apply` — 思路可行。
---
## P0FC 仍会走 `checkInfo`,大概率直接失败
[`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) 当前顺序:
```php
$bankInfo = $this->checkBankInfo($applyDTO);
$this->checkInfo($applyDTO->type, $applyDTO->amount); // 始终执行
// ...
if ($applyDTO->package_id > 0) {
firstCashout(...); // 永远走不到checkInfo 已抛错)
}
```
`checkInfo` 会校验**钱包可提现余额** ≥ amount[`WithdrawService.php` L333-335](slot_console/app/service/WithdrawService.php))。
Free Credits 第一档资金在**活动池**,不在普通 `withdraw` 余额里 → 典型报错 **`Insufficient balance`**。
**结论**:与需求「不进入普通钱包」冲突,第一档线上基本提不了现。
**建议**`package_id > 0`(或 `bizType === free_credit_first_cashout`)时 **跳过** `checkInfo`(及与之绑定的首提 min/max 规则)。
---
## P0Pay 失败时档位已置为 processing无回滚
你现在在 **Pay 之前** 调用 `firstCashout``markFirstCashoutProcessing`status=2
原实现是mark → `PayService::apply`**catch 时** `handleFirstCashoutResult($orderId, false)` 恢复 ready。
当前 [`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) L236 调 pay **没有** try/catch 回滚 → 申请失败时 package 会一直 **processing**,用户无法重试。
**建议**
```php
if ($applyDTO->package_id > 0) {
$orderId = CommonFn::generateOrderId(4, $userTag->uid);
(new FreeCreditsLogic())->firstCashout($userTag->uid, $applyDTO->package_id, $orderId);
$withdrawalInfo->orderId = $orderId;
$withdrawalInfo->bizType = FreeCreditsLogic::BIZ_TYPE_FIRST_CASHOUT;
try {
$res = PayService::getInstance()->apply($withdrawalInfo->toArray());
return $res;
} catch (\Throwable $e) {
(new FreeCreditsLogic())->handleFirstCashoutResult($orderId, false);
throw $e;
}
}
```
FC 分支应 **early return**,不要继续走下面黑规则 + `getAmountAndFee`。)
---
## P1FC 仍走普通手续费 / 黑规则 / auditType
合并后 FC 路径仍执行:
- `getAuditType($applyDTO->amount)``BlackApiService`、提现倍数检测
- `getAmountAndFee($applyDTO->amount, $this->withdrawal)` — 可能扣手续费、按钱包余额改金额
原 [`firstCashout`](slot_console/app/api/logic/FreeCreditsLogic.php) 约定:
- `fee = 0`
- `auditType = 2`(人工审核)
- `amount = package->amount_qf`(千分位,不经手续费逻辑)
`merge` 写入的 `amount`**展示大单位**`getNumberFormat(qf)`),再经 `getAmountAndFee` 可能与 pay 侧期望的千分位不一致。
**建议**FC 分支单独组 `WithdrawalInfo`
| 字段 | FC 取值 |
|------|---------|
| amount | 从 package 读 `amount_qf`(或 merge 时额外缓存 `_amount_qf` |
| fee | 0 |
| auditType | 2 |
| bizType | `free_credit_first_cashout` |
| 黑规则 | 跳过(或产品确认是否要对 FC 也做) |
---
## P1`firstCashout` 与 `markFirstCashoutProcessing` 重复校验
[`firstCashout`](slot_console/app/api/logic/FreeCreditsLogic.php) 内再次查 `STATUS_READY` 后调用 `markFirstCashoutProcessing`,而 `markFirstCashoutProcessing` ** again** 要求 `STATUS_READY`L343
逻辑重复但无害;可简化为只调 `markFirstCashoutProcessing`,或只保留一处校验。
注意:`mergeFirstCashoutIntoPost` 已校验 ready`firstCashout` 时若并发重复提交,第二次会在 mark 阶段失败 — 符合「处理中不可重复提交」。
---
## P2行为变化需产品确认
| 项 | 原 FC | 现统一 apply |
|----|--------|----------------|
| `is_bind_name` | 不校验 | **校验**L157 |
| Redis 5s 频控 | 无 | **有** |
| 返回值 | `{order_id, amount}` | pay `apply` 原始结构 |
若 C 端依赖 `data.order_id` / `data.amount` 展示,需确认 pay 返回是否一致。
---
## 做得好的部分
- Controller 单入口 + merge post**正确**。
- 去掉独立 FC validator scene**正确**。
- [`FreeCreditsController::firstCashout`](slot_console/app/api/controller/FreeCreditsController.php) 兼容路径与主入口一致:**正确**。
- pay 层仍靠 `bizType` 跳过 `withdrawFrozen`**只要 bizType 确实传到 pay 就没问题**(当前 L184 已设)。
---
## 推荐修复结构(最小 diff
在 [`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) 开头(绑卡、频控之后):
```php
if ($applyDTO->package_id > 0) {
return $this->applyFreeCreditsFirstCashout($applyDTO, $bankInfo);
}
// 原有普通提现逻辑不变
```
`applyFreeCreditsFirstCashout` 内:
1. 跳过 `checkInfo`、黑规则、`getAmountAndFee`
2. `orderId``firstCashout`mark→ 组 `WithdrawalInfo`qf amount, fee=0, auditType=2, bizType
3. try/catch pay + `handleFirstCashoutResult` 回滚
**不要**在普通流程中间用 `if ($package_id > 0) { firstCashout; }` 再接着跑普通逻辑 — 这是当前问题的根源。
---
## 验收清单
- [ ] `package_id>0` 且钱包 withdraw=0能成功提交 pay不报 Insufficient balance
- [ ] pay 申请失败package 回到 ready可再次提现
- [ ] pay 成功 + 回调package completedplayer `FIRST_CASH_DONE`
- [ ] 普通提现无 `package_id`:行为与改前一致
- [ ] `remark`/bizType 在 pay 侧仍为 `free_credit_first_cashout`,不冻结钱包

View File

@@ -0,0 +1,174 @@
---
name: firstCashout 合并提现
overview: 可以合并:推荐在 Withdraw 入口与 Pay 下单层统一,用 package_id 区分;活动档位状态机仍留在 FreeCreditsLogic不能把 FC 逻辑硬塞进 WithdrawService::apply 主流程中间。
todos:
- id: dto-package-id
content: WithdrawApplyDTO 增加 package_idWithdrawValidator 增加 FC scenepackage_id 替代 amount
status: completed
- id: withdraw-fc-branch
content: WithdrawService::apply 增加 applyFreeCreditsFirstCashout early return跳过 checkInfo/手续费/黑规则)
status: completed
- id: controller-unify
content: WithdrawController::apply 统一入口FreeCreditsController::firstCashout 改为兼容 alias
status: completed
- id: bank-persist
content: FC 分支复用 checkBankInfo与普通提现绑卡行为一致
status: completed
- id: pay-rollback-test
content: 补单测/集成测FC 无 withdraw 余额可提交、Pay 失败回滚 ready
status: completed
isProject: false
---
# firstCashout 能否合并进现有提现逻辑
## 结论(后端)
**可以合并,且推荐合并「入口 + 校验 + 绑卡 + Pay 下单」;不应把 Free Credits 编排塞进 [`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) 主流程中间。**
当前仓库状态:**尚未合并**——仍是双入口:
| 入口 | 现状 |
|------|------|
| [`POST /api/free-credits/first-cashout`](slot_console/app/api/controller/FreeCreditsController.php) | `FreeCreditsLogic::firstCashout` 完整编排 |
| [`POST /api/withdraw/apply`](slot_console/app/api/controller/WithdrawController.php) | `WithdrawService::apply`,无 `package_id` |
Pay 层**已经统一**:两类提现最终都走 [`WithdrawalOrderEntity::apply`](slot_pay/app/entity/WithdrawalOrderEntity.php),靠 `bizType=free_credit_first_cashout` 跳过 `withdrawFrozen` 并走独立 MQ 回调。
---
## 为什么「可以」合并
C 端参数与需求 §11 一致:独立提现页 = 普通提现页精简版,收款字段相同,仅差:
| 字段 | 普通提现 | Free Credits 第一档 |
|------|----------|----------------------|
| `amount` | 用户输入 | **不传**,服务端取 package |
| `package_id` | 无 | **必填** |
因此用 **同一 `POST /api/withdraw/apply` + 可选 `package_id`** 区分业务是合理契约。
---
## 为什么不能「整段并入」WithdrawService::apply
[`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) 当前顺序L164219
1. `checkBankInfo`
2. **`checkInfo`(校验钱包 withdraw 余额 ≥ amount**
3. 黑规则、`getAmountAndFee`(手续费)
4. `PayService::apply`
Free Credits 第一档资金在**活动池**,不在普通 `withdraw` 余额。若在中间插入 `firstCashout` 而不 **early return**,会先被 `checkInfo` 打成 `Insufficient balance`(评审计划已记录为 P0
此外 FC 固定规则与普通提现不同:
| 项 | 普通提现 | FC 第一档 |
|----|----------|-----------|
| 金额来源 | 用户 `amount` | `package->amount_qf` |
| 手续费 | `getAmountAndFee` | **0** |
| 审核 | 动态 `getAuditType` + 黑规则 | **auditType=2** |
| 钱包冻结 | 有 | **无**pay 侧 bizType 分支) |
| 活动状态 | 无 | `markFirstCashoutProcessing` / 失败回滚 |
| Pay 失败回滚 | 钱包解冻 | **`handleFirstCashoutResult` 恢复 ready** |
这些差异属于 **Logic 编排**,符合 [`backend-layering`](file:///Users/ray/.cursor/rules/backend-layering.mdc):不应把 `FreeCreditsLogic` 整段搬进 `WithdrawService` 当「又一层 Service」。
---
## 推荐合并结构
```mermaid
flowchart TB
Client["POST /api/withdraw/apply"]
Client --> Branch{package_id 存在?}
Branch -->|否| Normal["WithdrawService::apply 原逻辑"]
Branch -->|是| FC["WithdrawService::applyFreeCreditsFirstCashout"]
FC --> Mark["FreeCreditsLogic::markFirstCashoutProcessing"]
FC --> Pay["PayService::apply bizType=free_credit_first_cashout"]
Normal --> Pay2["PayService::apply 普通"]
Pay --> PayEntity["slot_pay WithdrawalOrderEntity"]
Pay2 --> PayEntity
```
### 合并层(推荐做)
1. **[`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php)**
-`package_id` → 走 FC 分支(或 DTO 带 `package_id` 后交给 Service early return
-`package_id` → 现有普通提现
2. **[`WithdrawValidator`](slot_console/app/api/validator/WithdrawValidator.php)**
- 新增 FC scene与普通 apply 对称,把 `amount` 换成 `package_id``SCENE_APPLY_CASH_FC` 等)
3. **[`WithdrawApplyDTO`](slot_console/app/api/dto/request/WithdrawApplyDTO.php)**
- 增加可选 `package_id`
4. **绑卡**
- FC 复用 `WithdrawService::checkBankInfo`(当前 `firstCashout` 只读绑卡、不写库,合并后应对齐普通提现)
5. **[`FreeCreditsController::firstCashout`](slot_console/app/api/controller/FreeCreditsController.php)**
- 保留为 **兼容 alias**(内部转调 withdraw apply或标记 deprecated
### 保持分离(必须)
- [`FreeCreditsLogic`](slot_console/app/api/logic/FreeCreditsLogic.php)`markFirstCashoutProcessing``handleFirstCashoutResult`、package 状态机、`assertEligibleParticipant`
- [`WithdrawService::applyFreeCreditsFirstCashout`](slot_console/app/service/WithdrawService.php)(新建私有方法):
- 跳过 `checkInfo`、黑规则、`getAmountAndFee`
- `amount = package->amount_qf``fee = 0``auditType = 2`
- try/catch Pay失败时 `handleFirstCashoutResult($orderId, false)`
### 已统一、无需再改
- **slot_pay**`bizType` / `remark` 分支跳过冻结、Success/Fail/Rejected → Console MQ
- **slot_console EventBus**`FreeCreditsFirstCashoutSuccess/Fail/Rejected``handleFirstCashoutResult`
---
## 不推荐的做法
在 [`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) 中间写:
```php
$this->checkInfo(...);
// ...
if ($package_id > 0) {
(new FreeCreditsLogic())->firstCashout(...);
}
$amountInfo = $this->getAmountAndFee(...);
PayService::apply(...);
```
会导致余额校验失败、手续费错误、Pay 失败不回滚档位processing 卡死)。
---
## 与「单独提现」需求的关系
| 需求 | 合并后是否满足 |
|------|----------------|
| 不进入普通钱包§15.3 | 是,仍靠 pay `bizType` |
| 固定第一档金额§11.3 | 是,服务端取 package |
| 处理中不可重复提交§5.6 | 是,仍由 package status 控制 |
| 成功/失败/拒绝状态§20.2 | 是,回调逻辑不变 |
合并的是 **HTTP 入口与收款参数校验**,不是改掉「独立提现」的账务语义。
---
## 实施要点(若执行)
1. `WithdrawApplyDTO` 增加 `package_id`
2. `WithdrawService::apply` 在绑卡 + Redis 频控后 **early return**`applyFreeCreditsFirstCashout`
3. FC 分支内:先 `markFirstCashoutProcessing`,再 Paycatch 回滚
4. 单测FC 在 `withdraw=0` 时可提交Pay 失败 package 回 ready普通 apply 无回归
5. 文档:`/api/free-credits/first-cashout` → 指向 `/api/withdraw/apply` + `package_id`
---
## 验收清单
- `package_id>0` 且钱包可提现余额为 0可成功提交 pay
- pay 申请失败package 回到 `ready`,可重试
- pay 回调成功package `completed`player `FIRST_CASH_DONE`
- 不传 `package_id`:普通提现与现网一致
- pay 订单 `remark` 仍为 `free_credit_first_cashout`,不触发 `withdrawFrozen`

View File

@@ -0,0 +1,135 @@
---
name: firstCashout 合并评估
overview: 客户端入参与普通提现几乎一致(仅多 package_id、不传 amount推荐在 WithdrawController::apply 做薄入口合并并复用 WithdrawValidator编排仍留 FreeCreditsLogic不并入 WithdrawService::apply 内部。
todos:
- id: merge-withdraw-entry
content: WithdrawController::apply 增加 package_id 分支,委托 FreeCreditsLogic::firstCashout复用 WithdrawValidator 新增 scenetype 1/2/3/6 + package_id无 amount
status: completed
- id: align-bank-persist
content: firstCashout 改为复用 WithdrawService::checkBankInfo或抽 helper与普通提现一致写绑卡信息
status: completed
- id: deprecate-fc-endpoint
content: /api/free-credits/first-cashout 保留作兼容 alias 或标记 deprecated文档指向 /api/withdraw/apply?package_id=
status: completed
- id: keep-logic-split
content: FreeCreditsLogic 仍负责 package 状态机 + bizTypeWithdrawService::apply 不增加 Free Credits 分支
status: completed
isProject: false
---
# firstCashout 合并进现有提现接口 — 修订评估
## 用户反馈:客户端参数基本一致
对照 [`WithdrawValidator`](slot_console/app/api/validator/WithdrawValidator.php) 与 [`FreeCreditsValidator::SCENE_FIRST_CASHOUT`](slot_console/app/api/validator/FreeCreditsValidator.php)
| 字段 | 普通提现 apply | Free Credits firstCashout |
|------|----------------|---------------------------|
| `type` | require (1/2/3/6) | require (1/2/3/6) |
| `pay_net` | require (1/2/3) | Logic 使用Validator scene 未含(可补齐) |
| `user_name` / `cash_tag` | type=1 时 require | post 传入Logic 读取 |
| `btc` / `usdt` | type=2/3 时 require | 同上 |
| `paypal_*` / `email` | type=6 时 require | 同上 |
| `amount` | **require用户输入** | **不传,服务端取 package 金额** |
| `package_id` | 无 | **require活动档位 id** |
**结论**C 端独立提现页(需求 §11本来就是对普通提现页的精简——同一套收款字段只是隐藏 amount 输入框。从接口契约看,**完全可以用同一个 apply 入口**,用 `package_id` 有无区分业务类型。
---
## 仍建议合并的范围:入口 + 校验 + 组单,不是 WithdrawService 内部
```mermaid
flowchart TB
Client["C 端 POST /api/withdraw/apply"]
Client --> Branch{package_id 存在?}
Branch -->|是| FC["FreeCreditsLogic::firstCashout"]
Branch -->|否| WS["WithdrawService::apply"]
FC --> Pay["PayService::apply bizType=free_credit_first_cashout"]
WS --> Pay2["PayService::apply 普通"]
Pay --> PayEntity["WithdrawalOrderEntity::apply 已统一"]
Pay2 --> PayEntity
```
### 可以合并(推荐)
1. **统一入口**[`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php) 检测 `package_id`,有则委托 `FreeCreditsLogic::firstCashout`,无则走 `WithdrawService::apply`
2. **统一校验**:在 `WithdrawValidator` 新增 scene与普通 apply 对称,仅把 `amount` 换成 `package_id`
- `SCENE_APPLY_CASH_FC` => `['package_id', 'type', 'user_name', 'cash_tag']`
- `SCENE_APPLY_BTC_FC` => `['package_id', 'type', 'btc']`
-
3. **统一绑卡写库**`firstCashout` 目前只读 `UserBankCardModel`;普通提现通过 `WithdrawService::checkBankInfo` 会更新绑卡。合并入口后应 **复用同一绑卡逻辑**,避免两套行为。
4. **统一 DTO**`WithdrawApplyDTO` 增加可选 `package_id` 字段即可,不必维护两套 post 结构。
### 不应合并进 WithdrawService::apply
编排差异仍在 Logic 层,不应塞进 [`WithdrawService::apply`](slot_console/app/service/WithdrawService.php)
| 仍分离的逻辑 | 原因 |
|-------------|------|
| 金额 | 普通:用户 amountFCpackage->amount_qf |
| 余额/手续费/VIP/黑规则 | 普通checkInfo + getAmountAndFeeFC跳过 |
| 活动状态 | FCmarkFirstCashoutProcessing / 失败回滚 |
| pay 回调 | 已通过 bizType 在 pay 层分支,无需 console 再分 |
---
## 推荐实现(修订后方案 B
[`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php)
```php
public function apply(Request $request)
{
$post = $request->post();
$type = input('type', 1);
if (!empty($post['package_id'])) {
// 复用 WithdrawValidator 的 FC scene无 amount
$error = $this->validate(WithdrawValidator::SCENE_APPLY_CASH_FC /* 按 type */, $post);
if ($error !== true) {
return $this->errorCode(ErrorCode::PARAMS_ERROR, $error);
}
return $this->success(
(new FreeCreditsLogic())->firstCashout(
$request->userEntity->uid,
(int) $post['package_id'],
$post
),
'Submitted successfully! ...'
);
}
// 原有普通提现
$error = $this->validate($type, $post);
// ...
}
```
[`FreeCreditsLogic::firstCashout`](slot_console/app/api/logic/FreeCreditsLogic.php) 内部改动:
- 绑卡:改为调用 `WithdrawService``checkBankInfo`(需将 `checkBankInfo` 改为 `protected` 公开方法,或抽到 helper
- 其余不变package 校验、固定金额、`bizType`、状态机。
[`FreeCreditsController::firstCashout`](slot_console/app/api/controller/FreeCreditsController.php)
- 保留为 **兼容 alias**(内部同样调 Logic或直接 deprecated 指向 withdraw apply。
---
## 与初版评估的差异
| 初版 | 修订 |
|------|------|
| 方案 A 推荐保持双入口 | **方案 B 升为推荐** — 参数一致,双入口无必要 |
| 强调「参数/校验不同故难合并」 | 参数 **高度重合**,差异仅 `package_id` vs `amount`;校验可共用 Validator scene |
| 合并障碍在入口层 | 合并障碍仅在 **WithdrawService 内部编排**,入口层应合并 |
---
## 验收
- C 端独立提现页调用 `POST /api/withdraw/apply`,传 `package_id + type + 收款字段`**不传 amount**
- 普通提现不传 `package_id`,行为与现网一致
- Free Credits 仍不走 `withdrawFrozen`,回调仍更新 package/player
- 绑卡信息与走普通 apply 后一致落库

View File

@@ -0,0 +1,247 @@
---
name: free credits activity edit
overview: 在管理后台活动配置编辑表单中为活动类型 11首充前免费余额定格与分档释放 / Free Credits增加专属表单分支并补齐字典与服务端校验金额输入沿用 type==10 的「美元小数 → 千分位整数」模式,写入 ext_config 的 _qf 后缀键,与 slot_console `FreeCreditsLogic::configAmount()` 的读优先级一致。
todos:
- id: dict-activity-type-11
content: 字典 sm_system_dict_data 追加 code='activity_type' 的 value=11 / label='Free Credits首充前免费余额'DB 或字典管理页)
status: completed
- id: edit-vue-type11-form
content: edit.vue 增加 v-if=type==11 的表单块6 字段 + Banner
status: completed
- id: edit-vue-skip-goods
content: edit.vue 让 type==11 跳过 goods 区块:扩展 onlyGift / 调整「添加赠送」与 goods 卡片的 v-if
status: completed
- id: edit-vue-submit-conv
content: edit.vue submit() 增加 type==11 的金额 ×1000 写入 _qf 键、清空 goods
status: completed
- id: edit-vue-setform-conv
content: edit.vue setFormData() 增加 type==11 的 _qf ÷1000 回填还原
status: completed
- id: validate-free-credits-ext
content: ActivityValidate 增加 checkFreeCreditsExt(extConfig) 方法含必填、非负、max_unlock_per_recharge≥1、first_cash ≤ recharge_unlock 约束
status: completed
- id: controller-trigger-ext-check
content: ActivityController::save / update 在 checkData 后按 type==11 调用 checkFreeCreditsExtupdate 兼容仅改状态请求
status: completed
- id: verify-end-to-end
content: 本地验证:新建/编辑/必填/业务约束/类型切换/C 端 FreeCreditsLogic 读取
status: completed
isProject: false
---
# Free Credits 活动后台编辑落地计划
## 目标
让运营在「活动管理」编辑弹窗里选活动类型 `11`(首充前免费余额)时,能直接编辑需求文档第 17 节列出的所有配置项;保存后落到 `s_recharge_gift_config.ext_config` JSON 字段C 端 `FreeCreditsLogic` 立即生效。
## 现状要点
- `slot_console` 已实现 `RechargeGiftConfigModel::TYPE_FREE_CREDITS = 11``FreeCreditsLogic`、运行时配置读取(`ext_config.{key}_qf` 优先)。
- `slot_admin` 后端 [ActivityController](backend/slot_admin/app/game/controller/ActivityController.php) 走 `slotLib\services\ActivityService` 透传到 `slot_console innerapi/activity/*``ext_config` 已原样落库,**不需要改动 slot_console**。
- 管理前端 [edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue) 现仅对 6 / 9 / 10 做了 `v-if` 分支,`type==11` 无任何 UI字典 `activity_type` 也无 value=11 条目。
## 字段映射type=11 专属 `ext_config`
UI 输入用美元小数,提交时 ×1000 写入 `_qf` 键(与 type==10 模式一致;`FreeCreditsLogic::configAmount()` 优先读 `_qf`
- `win_threshold_qf` ← 首笔赢取门槛(默认 $50
- `recharge_unlock_amount_qf` ← 累计充值解锁第一档(默认 $50
- `first_cash_amount_qf` ← 第一档免打码金额(默认 $20
- `package_amount_qf` ← 后续每档拆分金额(默认 $10
- `subsequent_min_recharge_qf` ← 解锁下一档单笔充值下限(默认 $10
- `max_unlock_per_recharge` ← 整数,每笔充值最多解锁档数(默认 1**不走 _qf**
- `banner_image` ← Free Play to Go 弹窗 Banner 图片 URL可空
## 改动清单
### 1. 字典:追加 `activity_type` value=11
在系统管理「字典管理 → activity_type」以有不需要再执行了
### 2. 前端:[backend/slot_admin_vue/src/views/game/activity/edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue)
#### 2.1 增加 type==11 表单块
参考 [edit.vue line 108-123](backend/slot_admin_vue/src/views/game/activity/edit.vue) type==10 的写法,新增:
```vue
<template v-if="formData.type == 11">
<a-col :span="24">
<a-form-item label="首笔赢取门槛($)" help="免费余额曾达到该值后解锁首页 Withdraw" :rules="[{ required: true, message: '必填' }]">
<a-input-number v-model="formData.ext_config.win_threshold" placeholder="如 50" :min="0" />
</a-form-item>
<a-form-item label="充值解锁门槛($)" help="累计真实充值满该金额释放第一档" :rules="[{ required: true, message: '必填' }]">
<a-input-number v-model="formData.ext_config.recharge_unlock_amount" placeholder="如 50" :min="0" />
</a-form-item>
<a-form-item label="免打码提现额($)" help="第一档可免打码直接提现金额" :rules="[{ required: true, message: '必填' }]">
<a-input-number v-model="formData.ext_config.first_cash_amount" placeholder="如 20" :min="0" />
</a-form-item>
<a-form-item label="解锁拆分金额($)" help="后续每档释放金额" :rules="[{ required: true, message: '必填' }]">
<a-input-number v-model="formData.ext_config.package_amount" placeholder="如 10" :min="0" />
</a-form-item>
<a-form-item label="后续解锁最小充值($)" help="单笔充值达到该金额才解锁下一档">
<a-input-number v-model="formData.ext_config.subsequent_min_recharge" placeholder="如 10" :min="0" />
</a-form-item>
<a-form-item label="每笔最多解锁档数" help="防止一笔充值解锁多档">
<a-input-number v-model="formData.ext_config.max_unlock_per_recharge" placeholder="如 1" :min="1" :precision="0" />
</a-form-item>
<a-form-item label="Banner 图片" help="Free Play to Go 底部弹窗 Banner">
<sa-upload-image v-model="formData.ext_config.banner_image" :limit="1" :multiple="false" />
</a-form-item>
</a-col>
</template>
```
#### 2.2 让 type==11 跳过 goods 区块
- [edit.vue line 60](backend/slot_admin_vue/src/views/game/activity/edit.vue) `onlyGift` computed 追加 `|| formData.type == 11`
```js
let onlyGift = computed(() => {
return formData.type == 7 || formData.type == 8 || formData.type == 9 || formData.type == 11;
})
```
- [edit.vue line 125](backend/slot_admin_vue/src/views/game/activity/edit.vue) 把「添加赠送」按钮与下方 `<a-row v-else>` 的 goods 卡片包成一组条件,排除 type==10 和 type==11
```vue
<template v-if="formData.type !== 10 && formData.type !== 11">
<a-button type="primary" ... @click="add()">添加赠送</a-button>
</template>
<a-row :gutter="20" v-if="formData.type !== 9 && formData.type !== 10 && formData.type !== 11">
...goods 卡片...
</a-row>
```
(保留原 type==9 的 exchange 表格 v-if 不变。)
#### 2.3 `submit()` 中追加 type==11 的金额 ×1000 转换
[edit.vue line 370](backend/slot_admin_vue/src/views/game/activity/edit.vue) 类似 type==10 的处理,把 6 个美元字段写到 `_qf` 键,整数字段原样保留:
```js
if (formData.type === 11) {
const e = data.ext_config || {};
const toQf = (v) => v === '' || v == null ? undefined : Math.round(Number(v) * 1000);
data.ext_config = {
win_threshold_qf: toQf(e.win_threshold),
recharge_unlock_amount_qf: toQf(e.recharge_unlock_amount),
first_cash_amount_qf: toQf(e.first_cash_amount),
package_amount_qf: toQf(e.package_amount),
subsequent_min_recharge_qf: toQf(e.subsequent_min_recharge),
max_unlock_per_recharge: e.max_unlock_per_recharge == null ? undefined : Math.round(Number(e.max_unlock_per_recharge)),
banner_image: e.banner_image || '',
};
data.goods = [];
}
```
#### 2.4 `setFormData()` 中追加 type==11 的回填还原
[edit.vue line 344-351](backend/slot_admin_vue/src/views/game/activity/edit.vue) 仿照 type==10
```js
if (data.type === 11 && data.ext_config) {
const e = data.ext_config;
formData.ext_config = {
win_threshold: e.win_threshold_qf != null ? e.win_threshold_qf / 1000 : e.win_threshold,
recharge_unlock_amount: e.recharge_unlock_amount_qf != null ? e.recharge_unlock_amount_qf / 1000 : e.recharge_unlock_amount,
first_cash_amount: e.first_cash_amount_qf != null ? e.first_cash_amount_qf / 1000 : e.first_cash_amount,
package_amount: e.package_amount_qf != null ? e.package_amount_qf / 1000 : e.package_amount,
subsequent_min_recharge: e.subsequent_min_recharge_qf != null ? e.subsequent_min_recharge_qf / 1000 : e.subsequent_min_recharge,
max_unlock_per_recharge: e.max_unlock_per_recharge ?? 1,
banner_image: e.banner_image || '',
};
}
```
### 3. 后端校验:[backend/slot_admin/app/game/validate/ActivityValidate.php](backend/slot_admin/app/game/validate/ActivityValidate.php)
ThinkValidate 对嵌套 JSON 支持有限,采用「在 Controller 按 `type` 触发额外校验」+「Validate 提供专用方法」的模式,避免污染既有 scene。
#### 3.1 ActivityValidate 增加一个公开方法
```php
/**
* Free Creditstype=11专用 ext_config 校验。
*
* @param array $extConfig 前端提交的 ext_config金额已 ×1000 写入 _qf 键
* @throws \think\exception\ValidateException
*/
public function checkFreeCreditsExt(array $extConfig): void
{
$required = [
'win_threshold_qf' => '首笔赢取门槛',
'recharge_unlock_amount_qf' => '充值解锁门槛',
'first_cash_amount_qf' => '免打码提现额',
'package_amount_qf' => '解锁拆分金额',
'subsequent_min_recharge_qf' => '后续解锁最小充值',
'max_unlock_per_recharge' => '每笔最多解锁档数',
];
foreach ($required as $key => $label) {
if (!isset($extConfig[$key]) || $extConfig[$key] === '' || (int)$extConfig[$key] < 0) {
throw new \think\exception\ValidateException("{$label}必须填写且为非负数");
}
}
if ((int)$extConfig['max_unlock_per_recharge'] < 1) {
throw new \think\exception\ValidateException('每笔最多解锁档数必须 ≥ 1');
}
// 业务约束:免打码提现额 ≤ 累计充值解锁门槛
if ((int)$extConfig['first_cash_amount_qf'] > (int)$extConfig['recharge_unlock_amount_qf']) {
throw new \think\exception\ValidateException('免打码提现额不能超过充值解锁门槛');
}
}
```
#### 3.2 ActivityController save / update 触发
[ActivityController::save / update](backend/slot_admin/app/game/controller/ActivityController.php) 在 `checkData()` 之后追加:
```php
if ((int)input('type') === 11) {
$this->validate->checkFreeCreditsExt((array)input('ext_config', []));
}
```
`update` 里同样判断,且要兼容仅改状态的请求(无 `updateData` 时不校验 ext_config
```php
if (!empty(input('updateData')) && (int)input('type') === 11) {
$this->validate->checkFreeCreditsExt((array)input('ext_config', []));
}
```
### 4. 不需要改的部分
- `slot_console``ActivityConfigEntity::updateConfig` 已原样写入 `ext_config`,无变更。
- `Consts::ACTIVITY_TYPE_*` 不必新增 11C 端已通过 `RechargeGiftConfigModel::TYPE_FREE_CREDITS` 引用。
- 不新增独立菜单页,复用通用「活动管理」编辑弹窗。
## 数据流
```mermaid
flowchart LR
edit[edit.vue type=11 form] -->|"美元×1000 → _qf"| save["POST /game/activity/save|update"]
save --> ctrl[ActivityController]
ctrl -->|"checkFreeCreditsExt"| validate[ActivityValidate]
ctrl --> svc[ActivityService HTTP]
svc --> console["slot_console innerapi/activity/update"]
console --> entity[ActivityConfigEntity::updateConfig]
entity --> db["s_recharge_gift_config.ext_config"]
db --> fc["FreeCreditsLogic::configAmount key_qf 优先"]
```
## 验收
- 新建 type=11 活动:填写 6 字段 + Banner保存成功DB `ext_config``*_qf` 整数 + `max_unlock_per_recharge` + `banner_image`
- 编辑回填:再次打开同一条活动,美元字段显示为原值(如 50 / 20 / 10整数字段为 1Banner 显示已上传图。
- 必填校验:清空任一必填项保存,返回明确错误信息(如「首笔赢取门槛必须填写且为非负数」)。
- 业务约束:免打码提现额填 60、充值解锁门槛填 50保存被拒。
- 类型切换type 选 11→1再切回 11goods 区块不出现formData 不污染;保存 type==1 时 `_qf` 字段不会被带到 ext_config。
- C 端:调 `FreeCreditsLogic::status($uid)`,门槛 / 第一档金额 / 解锁拆分金额能读到刚配的值。
- type 字典管理后台编辑弹窗活动类型下拉出现「Free Credits首充前免费余额」选项。

View File

@@ -0,0 +1,182 @@
---
name: free credits release
overview: 为“首充前免费余额定格与分档释放”准备开发方案,按钱包账务、充值提现事件、客户端 API、后台配置统计分阶段落地。重点遵循现有后端分层规则避免把业务编排错误地下沉到 Service。
todos:
- id: confirm-wallet-fields
content: 按已确认口径使用 deposit_balance + withdraw_balance 作为免费余额
status: completed
- id: design-schema
content: 设计独立 Free Credits 活动主表、档位明细表、流水关联和旧活动 ID 关联
status: completed
- id: implement-console-core
content: 在 slot_console 实现活动领域模型、定格、解锁、Claim 和状态查询 Logic
status: pending
- id: wire-recharge
content: 接入 slot_pay/slot_console 充值成功链路并保证首充定格幂等
status: completed
- id: wire-cashout
content: 实现第一档独立提现订单和回调状态同步
status: completed
- id: add-console-apis
content: 补充大厅状态、活动入口、后台配置统计相关 API
status: completed
- id: test-acceptance
content: 按需求文档核心规则和账务验收补测试/联调用例
status: completed
isProject: false
---
# 首充前免费余额定格与分档释放开发准备
## 目标范围
本次需求核心实现应以后端账务和状态机为主,前端展示依赖新增/扩展 API。主要涉及
- [`/Users/ray/Documents/project/www/slot/slot_console`](slot_console)Free Credits 活动领域模型、Pool、档位、状态机、定格/释放/Claim 编排、大厅/提现页接口、活动入口、配置读取、后台统计入口。
- [`/Users/ray/Documents/project/www/slot/slot_wallet`](slot_wallet):只提供钱包原子能力,例如余额扣减/入账、钱包流水、Deposit Balance 入账、Y1 流水任务创建;不放活动领域模型和活动状态机。
- [`/Users/ray/Documents/project/www/slot/slot_pay`](slot_pay):充值成功异步通知、第一档独立提现订单。
- [`/Users/ray/Documents/project/www/slot/backend/slot_admin`](backend/slot_admin):运营后台配置和统计页,如该项目负责管理端。
- 客户端 UI 由前端人员在其它仓库实现,本仓库只提供后端接口和状态数据。
已有统一活动管理表可作为关联来源:
- `s_common.s_recharge_gift_config`:现有充值赠送活动主表。
- `recharge_gift_player`:现有充值赠送活动参与玩家表。
本需求不直接复用这两张表承载 Free Credits 账务状态,采用独立 Free Credits 表设计,并保留与旧活动 ID 的关联,避免把“首充前免费余额定格”与已有充值赠送活动规则混在同一张参与表里。
## 数据表 DDL 草案
金额字段建议沿用现有活动表中的 `_qf` 口径,按千分位整数保存,避免小数精度问题。
```sql
CREATE TABLE `free_credits_player` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`activity_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '关联 s_recharge_gift_config.id',
`uid` bigint unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`source` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道',
`model_id` int unsigned NOT NULL DEFAULT '0' COMMENT '游戏模型ID',
`home_withdraw_unlocked` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '首页Withdraw是否已解锁 0否 1是',
`frozen_amount_qf` bigint unsigned NOT NULL DEFAULT '0' COMMENT '首充时定格金额,千分位',
`first_cash_amount_qf` bigint unsigned NOT NULL DEFAULT '0' COMMENT '第一档免打码提现金额,千分位',
`first_recharge_order_id` varchar(64) NOT NULL DEFAULT '' COMMENT '触发定格的首笔充值订单号',
`first_recharge_time` datetime DEFAULT NULL COMMENT '首笔充值成功时间',
`first_cashout_order_id` varchar(64) NOT NULL DEFAULT '' COMMENT '第一档独立提现订单号',
`status` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '主状态 0未开始 1首页已解锁 2已定格 3待充值解锁 4第一档可提现 5第一档提现中 6第一档已提现 7后续释放中 10全部完成',
`completed_time` datetime DEFAULT NULL COMMENT '全部完成时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_activity_uid` (`activity_id`,`uid`),
KEY `idx_uid` (`uid`),
KEY `idx_activity_status` (`activity_id`,`status`),
KEY `idx_first_recharge_order` (`first_recharge_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Free Credits用户活动主表';
```
```sql
CREATE TABLE `free_credits_package` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`player_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'free_credits_player.id',
`activity_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '关联 s_recharge_gift_config.id',
`uid` bigint unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`package_no` int unsigned NOT NULL DEFAULT '0' COMMENT '档位序号从1开始',
`package_type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '档位类型 1第一档免打码提现 2后续释放档',
`amount_qf` bigint unsigned NOT NULL DEFAULT '0' COMMENT '档位金额,千分位',
`status` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '档位状态 0锁定 1可操作 2处理中 3已完成 4失败 5风控拒绝',
`withdraw_order_id` varchar(64) NOT NULL DEFAULT '' COMMENT '第一档提现订单号',
`claim_biz_id` varchar(64) NOT NULL DEFAULT '' COMMENT '后续档Claim入账幂等业务号',
`unlocked_time` datetime DEFAULT NULL COMMENT '解锁时间',
`claimed_time` datetime DEFAULT NULL COMMENT '领取时间',
`completed_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_player_package` (`player_id`,`package_no`),
UNIQUE KEY `uniq_claim_biz` (`claim_biz_id`),
KEY `idx_uid_status` (`uid`,`status`),
KEY `idx_activity_status` (`activity_id`,`status`),
KEY `idx_withdraw_order` (`withdraw_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Free Credits档位明细表';
```
## 建议数据流
```mermaid
flowchart TD
preDepositUser["未首充用户"] --> winThreshold["免费余额曾达到门槛"]
winThreshold --> homeUnlocked["首页 Withdraw 解锁"]
preDepositUser --> firstRecharge["任意入口首笔真实充值成功"]
firstRecharge --> freezeFreeCredits["定格当前免费余额"]
freezeFreeCredits --> freeCreditsPool["slot_console 保存 Free Credits Pool 与档位"]
firstRecharge --> walletTotalRecharge["查询钱包累计充值总额"]
walletTotalRecharge --> firstReady["累计充值满门槛释放第一档"]
firstReady --> firstCashout["第一档独立提现"]
firstCashout --> laterPackages["后续档位按充值解锁"]
laterPackages --> claimToWallet["Claim 入 Deposit Balance"]
claimToWallet --> y1Task["创建 Y1 流水任务"]
```
## 后端落地方案
1.`slot_console` 新增独立 Free Credits 活动领域模型。
- 新增用户池主表,保存 `activity_id``uid`、定格金额、首充订单、主状态、首页解锁标记、完成时间等;主表不保存累计充值。
- 新增档位明细表,一档一行,保存序号、类型、金额、状态、关联提现单/Claim 流水、解锁/完成时间。
- `activity_id` 关联现有 `s_common.s_recharge_gift_config` 或后台活动配置 ID但 Free Credits 的进度、档位和账务状态不写入 `recharge_gift_player`
- 表归属按 `slot_console` 现有业务库和活动模块规范处理;`slot_wallet` 不新增 Free Credits 活动表。
2.`slot_console` 增加 Logic 编排定格、解锁、Claim。
- Controller 只做请求接收、Validate、DTO、统一响应。
- Validate 处理参数必填/类型/枚举。
- DTO 只承载已校验字段。
- Logic 负责首充定格、查询钱包累计充值总额、档位状态流转、事务和幂等。
- Model 负责查询/写入和状态条件更新。
- Service 仅用于已有公共能力,如调用钱包原子 API、配置读取、外部系统封装不新增单纯转发 Service。
- 调用 `slot_wallet` 时只请求余额变更、钱包流水、入 Deposit Balance、创建 Y1 任务等钱包能力,不把活动状态写入钱包服务。
3. 接入充值成功链路。
- 现有充值成功链路在 [`/Users/ray/Documents/project/www/slot/slot_pay/app/command/EventRecharge.php`](slot_pay/app/command/EventRecharge.php) 调用钱包充值入账。
- 充值成功后由 `slot_pay` 或事件消费者通知 `slot_console`,由 `slot_console` 判断是否首笔真实充值并触发定格。
- 定格需要调用 `slot_wallet` 原子能力扣减当前免费余额并写钱包流水,再由 `slot_console` 落 Free Credits Pool 和档位。
- 第一档门槛通过调用钱包查询用户累计充值总额判断;`slot_console` 主表不保存累计充值,也不新增充值事件明细表。
4. 实现第一档独立提现。
- 第一档不进入普通钱包余额,创建独立提现订单类型 `free_credit_first_cashout`
- 需要在 `slot_pay` 的提现订单模型/实体中支持新订单类型,提现处理中防重复,成功/失败/拒绝回写档位状态。
- 第一档提现成功后关闭首页状态条,但活动入口保留到所有档位完成。
5. 实现后续档位解锁和 Claim。
- 后续每笔符合条件真实充值最多解锁下一档,按配置控制最小充值金额和每笔最多解锁档数。
- Claim 由 `slot_console` 校验档位状态和顺序后,调用 `slot_wallet` 将档位金额入 `Deposit Balance`,创建 Deposit Lot 和 Y1 Wager Task。
- `slot_console` 负责 Claim 幂等和档位状态流转;`slot_wallet` 负责钱包入账幂等和流水一致性。失败时档位保持 `ready`,重复点击不能重复入账。
6. 增加客户端查询与操作 API。
- 查询用户活动状态首页状态条、活动入口、Free Play to Go 弹窗需要同一份状态数据。
- 操作 API第一档提现、后续 Claim、后续 Unlock 跳充值。
- `slot_console` 可在 [`/Users/ray/Documents/project/www/slot/slot_console/app/napi/controller/LobbyController.php`](slot_console/app/napi/controller/LobbyController.php) 或独立 API 暴露大厅所需数据。
7. 增加后台配置与统计。
- 配置项优先绑定到现有统一活动管理的活动 IDFree Credits 专属配置包括活动开关、首笔赢取门槛、充值解锁门槛、免打码提现额、拆分金额、后续最小充值、每笔最多解锁档数、Y1 倍数、Banner。
- 统计项:定格人数、完成提现人数、全部完成人数、定格总金额、已提现金额、已领取金额、待释放金额。
- 筛选排序按文档要求实现。
## 已确认口径
- 免费余额 = `deposit_balance + withdraw_balance`,即钱包余额。
- 第一档不需要流水,完全绕开普通可提现余额计算。
- 第一档解锁通过充值成功异步通知驱动,并调用钱包查询累计充值总额判断是否达到门槛。
- Free Credits 定格逻辑以独立活动配置为准。
- 客户端 UI 由前端人员处理,本仓库不包含对应页面代码。
## 关键风险与需确认点
- 文档中的 `Deposite Balance / Deposite Lot` 建议统一确认是否为历史命名还是拼写问题。
## 建议开发顺序
1. 先实现 `slot_console` 活动数据模型、配置读取、状态机和只读查询接口。
2. 梳理并补齐 `slot_wallet` 需要暴露的钱包原子能力,包括查询累计充值总额、冻结/扣减免费余额、入 Deposit Balance、创建 Y1 任务和幂等流水。
3. 接入充值成功链路,完成 `slot_pay``slot_console` 的事件通知、首充定格和调用钱包累计充值总额解锁第一档。
4. 实现第一档独立提现链路和提现回调到 `slot_console` 的状态同步。
5. 实现后续档位解锁、Claim 调用钱包入账和 Y1 流水任务创建。
6. 补 `slot_console` 大厅/活动入口接口、后台统计、客户端 UI、文案、埋点和验收用例。

View File

@@ -0,0 +1,317 @@
---
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 新建 FreeCreditsServicebasePath=innerapi/free-credits含 list + statistics
status: completed
- id: slot-admin-controller
content: slot_admin 新建 game/controller/FreeCreditsStatsController.phpindex 一次调两个 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.vuesa-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: <statistics> }` 给 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.status0/1/2/3/4/5/6/7/10
- `first_cash_status` 数组package.status0/1/2/3/4/5限定 `package_no=1`
- `is_completed` 0/1status=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`(千分位整数)传到 VueVue 用 `/ 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'(活动只面向美国 RMGcurrency 字段后续如多币种再扩展)
- 一次性聚合查询,避免 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()` actionV1 不做导出)
---
### 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' })`
- `<sa-table>` `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
<template #tableAfterButtons>
<a-space>
<a-typography-text>定格人数{{ stats.frozen_user_count }}</a-typography-text>
<a-typography-text>完成提现人数{{ stats.first_cashout_user_count }}</a-typography-text>
<a-typography-text>全部完成人数{{ stats.completed_user_count }}</a-typography-text>
<a-typography-text>定格总金额${{ qfToDollar(stats.frozen_amount_qf) }}</a-typography-text>
<a-typography-text>已提现金额${{ qfToDollar(stats.first_cashout_amount_qf) }}</a-typography-text>
<a-typography-text>已领取金额${{ qfToDollar(stats.claimed_amount_qf) }}</a-typography-text>
<a-typography-text>待释放金额${{ qfToDollar(stats.pending_amount_qf) }}</a-typography-text>
</a-space>
</template>
```
- `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 (<game_parent_id>, 'L', 'Free Credits 统计',
'game/freeCreditsStats/index', '/game/freeCreditsStats',
'game/freeCreditsStats/index', 50);
INSERT INTO sm_system_menu (parent_id, type, title, code)
VALUES (<leaf_id>, '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 校验路径回归

View File

@@ -0,0 +1,141 @@
---
name: Free Credits 老用户隐藏评审
overview: EventBus 写库确定参加、status 只读展示;在此基础上用环境变量 FREE_CREDITS_ENROLL_REG_AFTER 做注册时间门槛——regTime 晚于该时间的用户才有资格参加,否则一律 status=-1 且 EventBus 跳过写池。
todos:
- id: env-reg-cutoff
content: 新增 FREE_CREDITS_ENROLL_REG_AFTER 环境变量与 FreeCreditsLogic::isEligibleByRegTime(uid) 统一门禁
status: completed
- id: apply-gate-all-paths
content: 在 status、syncHomeWithdrawUnlocked、handleFreeCreditInit、advanceAfterRecharge、claim、firstCashout 入口应用注册时间门禁
status: completed
- id: fix-docs-lobby
content: 修正 FreeCreditsController PHPDoc含 status=-1 含老用户/未达注册门槛LobbyController 清理无用 import
status: completed
- id: guard-claim-cashout
content: 无资格或无 player 时 claim/firstCashout 返回明确业务错误
status: completed
- id: verify-reg-cutoff
content: 联调regTime 早于门槛 status=-1 且 EventBus 不建池;晚于门槛走 win/首充完整流程
status: completed
isProject: false
---
# Free Credits 老用户隔离:注册时间门槛(计划)
## 目标
C 端全量更新后,**注册时间早于活动上线节点的用户视为老用户,不参加、不展示****注册时间晚于该节点的新用户**在现有「EventBus 写库 = 参加」模型下正常走活动。
「默认参加」含义:**有资格参加**EventBus 允许建池、status 可读),**不是**无需 EventBus 自动插入 `free_credits_player`
---
## 分层架构(保持不变)
```mermaid
flowchart TB
gate{regTime > FREE_CREDITS_ENROLL_REG_AFTER?}
gate -->|否| block[不参加: status=-1 EventBus return]
gate -->|是| eligible[有资格]
eligible --> win[EventBus syncHomeWithdrawUnlocked]
eligible --> init[EventBus free_credit_init]
eligible --> api[status 只读 player 行]
win --> pool[(free_credits_player)]
init --> pool
api --> pool
```
| 路径 | 行为 |
| --- | --- |
| **读** `status()` | 未达注册门槛 → `status=-1`;达门槛且无 player 行 → `-1`;有行 → `buildStatus` |
| **写** EventBus | 未达注册门槛 → 各 Logic 方法开头直接 return不建池、不定格、不推进 |
---
## 实现要点
### 1. 环境变量
在 [`.env`](slot_console/.env) / 部署说明中增加(示例):
```env
# 用户注册时间Unix 或 Y-m-d H:i:s建议与 TIMEZONE 一致)晚于此值才可参加 Free Credits
FREE_CREDITS_ENROLL_REG_AFTER=2026-05-20 00:00:00
```
- 在 [`config/app.php`](slot_console/config/app.php) 或新建 `config/free_credits.php` 读取:`getenv('FREE_CREDITS_ENROLL_REG_AFTER')``strtotime` 解析为 `enroll_reg_after_ts`(启动时或首次调用缓存)。
- **未配置时的默认策略(已确认)**`FREE_CREDITS_ENROLL_REG_AFTER` 为空或未配置 → **全员不参加**`isEligibleByRegTime` 恒 false`status=-1`EventBus 不写池)。
### 2. 统一门禁方法(建议放在 [`FreeCreditsLogic`](slot_console/app/api/logic/FreeCreditsLogic.php)
```php
/**
* 是否具备 Free Credits 参与资格(注册时间晚于环境变量门槛)。
*/
protected function isEligibleByRegTime(int $uid): bool
{
$cutoff = self::enrollRegAfterTimestamp(); // 0 表示未配置
if ($cutoff <= 0) {
return false;
}
$userInfo = \app\service\user\UserService::getUserInfoEntity($uid);
if ($userInfo === null || $userInfo->create_at === '') {
return false;
}
$regTime = strtotime($userInfo->create_at);
return $regTime !== false && $regTime > $cutoff; // 严格「晚于」
}
```
- **数据来源**[`app\service\user\UserService::getUserInfoEntity`](slot_console/app/service/user/UserService.php)(调 user 服 `/innerapi/user/info`),与 [`EventBus::registerEvent`](slot_console/app/command/EventBus.php) 等同用法。
- **注册时间字段**[`app\entity\UserInfoEntity::$create_at`](slot_console/app/entity/UserInfoEntity.php)`Y-m-d H:i:s`),比较前 `strtotime` 为 Unix 秒。
- **不用** `UserTagService::tagInfo->regTime`(标签缓存,与账号创建时间可能不一致)。
- 备选:若 Logic 内已大量使用 slotLib可用 `\slotLib\services\UserService::getInstance()->setUid($uid)->getUserInfo()?->regTime`(构造函数内由 `create_at` 解析),但 console 侧优先统一 `app\service\user\UserService`
### 3. 调用点(读写对称)
| 方法 | 未达门槛时 |
| --- | --- |
| `status()` | 直接 `return ['status' => -1]` |
| `syncHomeWithdrawUnlocked()` | `return null` |
| `handleFreeCreditInit()` | `return` |
| `advanceAfterRecharge()` | `return` |
| `claim()` / `firstCashout()` | `throw BusinessException('...')` 或统一文案 |
**说明**:已达门槛、但库中无 player 行 → 仍为 `status=-1`(尚未被 EventBus 纳入);达门槛且 win 后 → EventBus 建池 → status 非 `-1`
### 4. 与「已首充」的关系
- 注册门槛解决:**老账号 / 老注册** 不进入活动。
- 钱包侧 [`maybeSendFreeCreditInit`](slot_wallet/app/api/logic/WalletLogic.php) 仍仅 **首充** 发定格,二者叠加,互不替代。
### 5. 撤销项
- 不再单独做 `total_deposit == 0` 的 EventBus 门禁(由注册时间门槛覆盖「老用户」定义)。
- 不再要求 `frozen_amount > 0` 才展示(保留 HOME_UNLOCKED 阶段)。
### 6. 文档与测试
- 更新 [`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php)`status=-1` = 未参加 / 活动关 / **注册时间早于门槛**
- 单测mock `regTime` 与 env cutoff覆盖 eligible / ineligible 的 status 与 freeze 是否被调用。
- 联调矩阵见下。
---
## 联调矩阵
| regTime vs 门槛 | win 达门槛 | 首充定格 | status | EventBus 建池 |
| --- | --- | --- | --- | --- |
| 早于 | - | - | `-1` | 否 |
| 晚于 | 否 | 否 | `-1` | 否 |
| 晚于 | 是 | 否 | `1` | sync 建池 |
| 晚于 | - | 是 | `3/4` + packages | freeze |
---
## 新 C 端约定(不变)
- **`status === -1`**:隐藏(含老用户、未达注册门槛、未参加)。
- **`status !== -1`**:已参加,按 `FreeCreditsPlayerModel` 状态渲染。

View File

@@ -0,0 +1,162 @@
---
name: Free Credits 验收核查
overview: 「首充前免费余额定格与分档释放」的后端核心链路(定格、分档、首档提现、后续 Claim、充值事件、钱包账务已在 slot_console / slot_wallet / slot_pay / slot_lib 落地约 7080%,但尚未达到需求文档 V1.0 全量验收标准;运营后台、客户端 UI、广播、异常风控、部分 API 字段与自动化测试仍缺失或未对齐。
todos:
- id: p0-wallet-atomicity
content: 补齐 Claim+Y1 原子性/回滚;确认是否需要 Deposit Lot
status: pending
- id: p0-status-api-fields
content: status/buildStatus 增加 total_recharge、show_home_status_bar、show_activity_entry
status: pending
- id: p0-peak-balance
content: 首页解锁「曾达到门槛」:增加峰值记录或可靠触发点
status: pending
- id: p1-admin-config-stats
content: slot_admin 增加 type=11 配置页与统计页(对接 innerapi
status: pending
- id: p1-refund-risk
content: 实现充值退款/拒付暂停未释放档位(文档 20.1
status: pending
- id: p2-frontend-broadcast
content: 前端仓实现 UI/广播;本仓可增加 broadcast API
status: pending
- id: p2-tests
content: 按文档 22.1/22.2 补充自动化或验收用例
status: pending
isProject: false
---
# 首充前免费余额定格与分档释放 — 实现完成度核查
**结论:未实现完。** 后端主流程可联调,但对照 [需求文档](file:///Users/ray/Documents/project/www/slot/docs/requirements/首充前免费余额定格与分档释放需求文档.md) 第 22 节验收标准,仍有若干 **P0 账务/规则缺口** 与大量 **前端/运营/统计** 范围未覆盖。
---
## 实现分布(已落地)
```mermaid
flowchart TB
subgraph console [slot_console]
RechargeEvent --> FreeCreditsLogic
EventBus --> FreeCreditsLogic
FreeCreditsLogic --> DB[(free_credits_player/package)]
ApiCtrl[FreeCreditsController] --> FreeCreditsLogic
Lobby[LobbyController.frontData] --> FreeCreditsLogic
Inner[innerapi FreeCreditsController] --> DB
end
subgraph wallet [slot_wallet]
FreeCreditsLogic --> WalletLogic
WalletLogic --> freeze[freeCreditsFreeze]
WalletLogic --> claim[freeCreditsClaim]
claim --> Y1[createTask SOURCE_TYPE_FREE]
end
subgraph pay [slot_pay]
firstCashout[firstCashout API] --> WithdrawalOrder
WithdrawalOrder --> MQ[FreeCreditsFirstCashout*]
MQ --> EventBus
end
```
| 模块 | 关键文件 | 覆盖的需求章节 |
|------|----------|----------------|
| 领域与状态机 | [FreeCreditsLogic.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php) | 5.35.9 核心规则 |
| 数据表 | [install.sql](file:///Users/ray/Documents/project/www/slot/slot_console/db/install.sql) L3686 | 活动池 + 档位 |
| 活动配置 | [RechargeGiftConfigModel.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/model/common/RechargeGiftConfigModel.php) `TYPE_FREE_CREDITS=11` | 17 配置(读库) |
| 客户端 API | [FreeCreditsController.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/controller/FreeCreditsController.php) | status / claim / firstCashout |
| 大厅聚合 | [LobbyController.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/napi/controller/LobbyController.php) `free_credits` | 7 状态条数据源(部分) |
| 充值事件 | [RechargeEvent.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/event/RechargeEvent.php) | 5.3 首充定格、5.5 累计充值解锁 |
| 首页解锁 | [EventBus.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/EventBus.php) win/bonus 等 → `syncHomeWithdrawUnlocked` | 5.2 |
| 钱包原子能力 | [WalletLogic.php](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) | 15.215.4 |
| 首档提现 | [WithdrawalOrderEntity.php](file:///Users/ray/Documents/project/www/slot/slot_pay/app/entity/WithdrawalOrderEntity.php) + [EventWithdrawal.php](file:///Users/ray/Documents/project/www/slot/slot_pay/app/command/EventWithdrawal.php) | 5.6、15.3、20.2 |
| 运营统计 API | [innerapi/FreeCreditsController.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/innerapi/controller/FreeCreditsController.php) | 18.118.2(简化版) |
---
## 按需求章节对照22.1 核心规则)
| 验收项(文档 22.1 | 实现情况 | 说明 |
|---------------------|----------|------|
| 未达 $50 点 Withdraw 提示 | **前端** | 后端 `status` 返回 `win_threshold` / `home_withdraw_unlocked`,无专用提示 API |
| 曾达 $50 后输回仍解锁 | **部分** | 已解锁用户靠 `home_withdraw_unlocked=1` 保持;**未持久化历史峰值**,仅在 MQ 事件时若 `balance >= threshold` 才写库,存在漏解锁边界(例如达峰后无 win/bonus 类事件即输掉) |
| 任意入口首充定格 | **是** | `RechargeEvent` + `handleRecharge``frozenAmount = balance_after - wallet_amount` |
| 首充失败不定格 | **是** | 仅充值成功 MQ 触发 |
| 定格后余额扣除、活动展示 | **是** | `freeCreditsFreeze` + player/package 记录 |
| 累计未满 $50 第一档不可提现 | **是** | 首包 `STATUS_LOCKED` 直至 `wallet.r >= recharge_unlock_amount` |
| 累计满 $50 第一档可提现 | **是** | `advanceByRecharge` 解锁 package_no=1 |
| 定格 &lt; $20 时第一档=定格额 | **是** | `min(frozen, first_cash_amount)` |
| 第一档提现成功后关首页状态条 | **后端未显式字段** | 有 `STATUS_FIRST_CASH_DONE`,但 `buildStatus` **未返回** `show_home_status_bar` 等 UI 开关,需前端自行推断 |
| 后续充值只解锁下一档 | **是** | `max_unlock_per_recharge` + `nextLockedReleasePackage` |
| Claim 入 Deposit + Y1 | **部分** | 入 deposit_balance + `createTask(SOURCE_TYPE_FREE)`**无 Deposit Lot**(文档 15.4、22.2 |
| 全部完成关活动入口 | **部分** | `STATUS_COMPLETED=10`API 未返回 `show_activity_entry` 布尔字段 |
---
## 按需求章节对照22.2 账务)
| 验收项 | 实现情况 | 风险 |
|--------|----------|------|
| 定格有钱包流水 | **是** | `BIZ_TYPE_FREE_CREDITS_FREEZE` |
| 定格前后余额正确 | **是** | `addLog` 记录 |
| 第一档不进普通钱包 | **是** | 独立 `free_credit_first_cashout`,跳过 `withdrawFrozen` |
| Claim 创建 Deposit Lot | **否** | 全仓库无 `DepositLot`/`deposit_lot` 实现 |
| Claim 创建 Y1 | **是** | 但 `createTask` 在钱包事务 **commit 之后**;失败仅打日志,**不满足** 文档 20.3「流水任务失败整体回滚」 |
| 重复回调不重复定格 | **是** | `frozen_amount_qf > 0` 跳过 |
| 重复 Claim 不重复入账 | **部分** | package 状态 + `claim_biz_id` 唯一;钱包层 **未见 biz_id 幂等查重** |
---
## 明确未实现 / 未对齐项
### P0影响验收或资金一致性
1. **Deposit Lot**:文档 15.4 / 22.2 要求 Claim 后创建;当前仅 `inc` deposit + Y1 task。
2. **Claim 与 Y1 原子性**`freeCreditsClaim` 先 commit 入账,再 `createTask`;与 20.3 冲突。
3. **充值退款/拒付暂停档位**(文档 20.1`FreeCreditsLogic` 无 refund/chargeback 处理。
4. **status API 缺累计充值进度**:定格后 UI 需要 `$9.99 / $50`(文档 6.3、7.3`buildStatus` 未返回 `total_recharge`(内部用 `wallet.r`,未透出)。
5. **「曾经达到 $50」**:无 `peak_balance` 字段;依赖事件驱动 `syncHomeWithdrawUnlocked`,与 5.2 字面规则不完全一致。
### P1运营与配置
6. **后台配置模块**(文档 17`backend/slot_admin` / `slot_admin_vue` **无** `TYPE_FREE_CREDITS=11` 的 CRUD 页面;仅能手工维护 `s_recharge_gift_config.ext_config`
7. **后台统计页**(文档 18仅有 [innerapi](file:///Users/ray/Documents/project/www/slot/slot_console/app/innerapi/controller/FreeCreditsController.php) 简化接口;列表缺 **渠道、时间范围、第一档状态、排序项、累计充值、完成进度** 等字段/筛选项。
8. **可配置 Y1 倍数**(文档 17 `后续档打码倍数`):代码写死 `createTask(0, fee, SOURCE_TYPE_FREE)`,未读 `ext_config`
9. **状态机 `STATUS_FROZEN=2`**:模型定义了但 Logic **从未赋值**(定格后直接 `DEPOSIT_PENDING` / `FIRST_CASH_READY`)。
### P2产品体验 / 文档其他章节)
10. **客户端 UI**(文档 614`slot_pwa` 等仓库 **零引用** `free_credits`首页状态条、Free Play to Go、独立提现页、广播模块均未实现原开发计划也标明前端另仓
11. **广播模块**(文档 13无最近 10 条提现/领取记录 API。
12. **注册赠送改 $30**(文档 19与本活动解耦**未在本需求代码变更中验证**是否已改配置/落地页。
13. **自动化测试**:无 FreeCredits 相关单测/集成测试。
---
## 与现有开发计划的一致性
[free_credits_release 计划](file:///Users/ray/.cursor/plans/free_credits_release_902566c9.plan.md) 中 `implement-console-core` 仍为 **pending**与代码现状Logic 已较完整)不一致;`test-acceptance` 标 completed 但仓库内 **无测试文件**,建议以文档 22 节手工/自动化用例重新验收。
---
## 建议验收顺序(若需补齐)
1. **手工走通 P0 账务用例**:首充定格幂等、满 $50 解锁、首档提现回调、顺序 Claim、重复 Claim/回调。
2. **补齐 status 字段**`total_recharge``show_home_status_bar``show_activity_entry``current_balance`
3. **对齐钱包**Deposit Lot若钱包规范要求、Claim+Y1 同事务或补偿回滚。
4. **运营**slot_admin 增加 type=11 配置页 + 统计页对接 innerapi。
5. **前端仓**:按文档 614 接 API本 monorepo 外)。
6. **补测试**:覆盖 22.1 / 22.2 表格各行。
---
## 总览评分(仅供沟通)
| 范围 | 完成度(估) |
|------|-------------|
| 后端核心状态机 + 充值/提现/Claim 链路 | ~7585% |
| 钱包账务与文档完全一致 | ~60% |
| 客户端 API 字段/UI 支撑 | ~50% |
| 运营后台配置与统计 | ~25% |
| 前端 UI / 文案 / 广播 | ~0%(本仓库) |
| 自动化测试 | ~0% |
**综合:需求文档 V1.0 不能判定为「已全部实现」;可判定为「后端 MVP 已具备,待补齐账务细节、运营与前端后全量验收」。**

View File

@@ -0,0 +1,78 @@
---
name: is_end派奖结算改造
overview: 基于现有 BetFunding/WinService 实现,新增 is_end 分支:中间派奖仅累计不入账,结束派奖统一按 Round Final Settlement 入账;并对中间派奖增加 biz_id 严格幂等。
todos:
- id: dto-validator-is-end
content: 扩展 DTO 与校验器,支持 is_end 并强制 win 场景 round_id 校验
status: pending
- id: redis-win-keys
content: 新增 pending/dedupe Redis key 生成方法与 TTL 约定
status: pending
- id: logic-win-branching
content: 在 WalletLogic::win 中实现 is_end=0 累计与 is_end=1 最终结算分支
status: pending
- id: idempotency-regression
content: 补充关键回归场景说明并验证与现有幂等不冲突
status: pending
isProject: false
---
# is_end 驱动的派奖结算改造计划
## 目标
`win` 接口支持第三方 `is_end` 语义:
- `is_end=0`:仅记录/累计本局派奖,不做钱包入账、不做 Lot 终态收敛
- `is_end=1`:将本次派奖 + 已累计派奖合并后,执行一次 Final Settlement现有 `WinService::execute`
## 现状结论(基于代码)
- 入口路由在 [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php) 的 `type=win -> win()`
- Final Settlement 主流程在 [app/service/wallet/WinService.php](app/service/wallet/WinService.php)。
- 当前 `win` 默认即 Final Settlement不区分中间派奖事件。
- 下注资金事实表在 [app/model/multi/WalletBetFundingModel.php](app/model/multi/WalletBetFundingModel.php)。
## 实现方案(按你选择)
### 1) 入参扩展与校验
- 在 [app/api/dto/request/wallet/WalletUpdateRequestDTO.php](app/api/dto/request/wallet/WalletUpdateRequestDTO.php) 增加字段:`is_end`(默认 `1`,仅允许 `0|1`)。
- 在 [app/validator/Wallet2Validator.php](app/validator/Wallet2Validator.php)
- 增加 `is_end` 校验规则(整数且取值 `0/1`)。
-`win` 也纳入 `round_id` 必传校验(当前仅 bet 必传)。
### 2) Redis Key 设计
- 在 [app/service/RedisKeyManagerService.php](app/service/RedisKeyManagerService.php) 新增两个 key 生成器:
- `wallet:win:pending:{uid}:{currency}:{round_id}`:累计未结算派奖金额
- `wallet:win:dedupe:{uid}:{currency}:{round_id}:{biz_id}`:中间派奖幂等标记
- 过期策略建议:`pending` 48h`dedupe` 72h与重放窗口对齐
### 3) win 主流程分支
- 修改 [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php) 的 `win()`
- `is_end=0`
- 命中 dedupe key 则直接返回当前钱包(幂等)
- 未命中则将 `fee` 累加到 pending key写 dedupe key返回当前钱包不写 `BIZ_TYPE_WIN` 流水)
- `is_end=1`
- 读取 pending 累计并与本次 `fee` 合并为 `finalWinAmount`
-`finalWinAmount` 调用现有 `WinService::execute`
- 事务提交后清理 pending key失败不清
### 4) 幂等与一致性
- `is_end=1` 继续沿用现有 `wallet_log(uid,biz_id,biz_type=win)` 幂等。
- `is_end=0` 使用 Redis dedupe key 防重,避免重复累计。
-`is_end=1` 无待分配 funding保持当前行为报错便于暴露上游时序问题。
### 5) 兼容与回归
- 默认 `is_end=1`,兼容未传该字段的旧调用。
- 回归场景:
- 单次结算(只发 `is_end=1`
- 多次派奖(多条 `is_end=0` + 一条 `is_end=1`
- `is_end=0` 重放同 biz_id 不重复累计
- `is_end=1` 重放同 biz_id 不重复入账
- `is_end=1` 失败后 pending 不丢失
## 关键改动文件
- [app/api/dto/request/wallet/WalletUpdateRequestDTO.php](app/api/dto/request/wallet/WalletUpdateRequestDTO.php)
- [app/validator/Wallet2Validator.php](app/validator/Wallet2Validator.php)
- [app/service/RedisKeyManagerService.php](app/service/RedisKeyManagerService.php)
- [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php)
## 风险与边界
- 该方案不新增 `wallet_game_round` / `wallet_game_round_event` 持久化表;中间派奖仅 Redis 暂存,审计粒度弱于 DB 事件流。
- 若某局长期无 `is_end=1`pending 依赖 TTL 过期清理。后续可升级为 DB 事件表方案。

View File

@@ -0,0 +1,57 @@
---
name: is_end结算回退方案
overview: 取消“中间派奖实时余额可见化”调整,回退到严格 Final Settlement 方案is_end=0 仅累计is_end=1 才入账与状态收敛。
todos:
- id: dto-validator-is-end
content: 增加 is_end 字段及 win round_id 强校验
status: completed
- id: pending-dedupe-keys
content: 新增 pending 与 dedupe Redis key 并接入 is_end=0 累计
status: completed
- id: win-final-merge
content: is_end=1 合并 pending 后调用现有 WinService 结算并清理缓存
status: completed
- id: regression-check
content: 回归验证多次派奖累计、最终结算与幂等行为
status: completed
isProject: false
---
# is_end 结算回退方案(不做中间余额更新)
## 变更决策
取消这部分:
- 多次派奖期间(`is_end=0`)对第三方返回余额进行实时更新
-`wallet` 查询叠加 pending 金额
保留并执行:
- `is_end=0`:仅做中间派奖累计,不改钱包余额、不做 Lot 收敛
- `is_end=1`:合并累计派奖后一次 Final Settlement调用现有 win 主流程)
## 实现边界
- 严格遵循 [doc/win.md](doc/win.md) 的“Final Settlement 才做真实入账”原则
- 不引入展示层余额叠加逻辑,避免展示余额与真实可下注余额不一致
## 具体改造
1. DTO / 校验
- [app/api/dto/request/wallet/WalletUpdateRequestDTO.php](app/api/dto/request/wallet/WalletUpdateRequestDTO.php) 增加 `is_end`(默认 `1`
- [app/validator/Wallet2Validator.php](app/validator/Wallet2Validator.php)
- 增加 `is_end` 仅允许 `0/1`
- `win` 场景强制 `round_id` 必传
2. Redis 累计与幂等(仅中间派奖)
- [app/service/RedisKeyManagerService.php](app/service/RedisKeyManagerService.php) 增加 key
- `wallet:win:pending:{uid}:{currency}:{round_id}`
- `wallet:win:dedupe:{uid}:{currency}:{round_id}:{biz_id}`
- `is_end=0` 命中 dedupe 则忽略,未命中则累计 pending
3. win 结算分支
- [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php)
- `is_end=0`:只累计并返回当前真实钱包余额
- `is_end=1`:读取 pending 并与本次 `fee` 合并后走现有 `WinService::execute`,成功后清理 pending
## 验收
- 多条 `is_end=0` 不触发真实入账
- 一条 `is_end=1` 触发一次最终结算,金额=中间累计+末笔
- 中间派奖重复 `biz_id` 不重复累计
- Final 失败时 pending 不丢失,可重试

View File

@@ -0,0 +1,141 @@
---
name: PHPDoc Cursor 规则
overview: 在 Cursor 全局规则中新增 PHP PHPDoc 严格规范(类、方法、常量全覆盖),与现有 backend-layering 规则并列,并明确适用范围与模板,避免与「不写显而易见注释」的原则冲突。
todos:
- id: create-php-doc-mdc
content: "新建 /Users/ray/.cursor/rules/php-doc.mdcglobs: **/*.php严格 PHPDoc 正文 + 示例)"
status: completed
- id: verify-rule-active
content: 在 Cursor 中打开任意 .php 文件,确认规则被注入;用 WalletLogic 缺注释方法做一次试写验证
status: completed
- id: optional-readme-sync
content: (可选)将 PHPDoc §3.4 同步到 slot_wallet 等 README与 Cursor 规则保持一致
status: completed
isProject: false
---
# 新增 PHPDoc 严格 Cursor 规则
## 结论:要加,但不要写成「所有符号一刀切」的空话
你选择了 **严格全覆盖**。建议在 Cursor 里加一条 **独立规则文件**,与现有的 [`backend-layering.mdc`](/Users/ray/.cursor/rules/backend-layering.mdc) 并列,而不是塞进 layering 里(职责不同:一个管分层,一个管文档)。
**不建议**写成模糊的「所有都要 PHPDoc」——应写清 **哪些符号、最少哪些 tag、何时可写短句**。否则 Agent 会在 trivial 代码上堆 `@param int $uid uid` 这类无意义注释,与 [`slot_agent/README.md`](/Users/ray/Documents/project/www/slot/slot_agent/README.md) 第 9.3 节「不解释显而易见语句」打架。
## 现状
| 来源 | PHPDoc 要求 |
|------|-------------|
| [`.cursor/rules/`](/Users/ray/.cursor/rules/) | 仅有 layering + dev-environment**无 PHPDoc** |
| [`slot_agent/README.md`](/Users/ray/Documents/project/www/slot/slot_agent/README.md) §6.2 | 已要求:业务类说明、公开方法说明、参数/返回值说明 |
| 实际代码(如 [`WalletLogic.php`](/Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) | **不一致**`run`/`register` 有块注释,`initWalletWithoutMoney``getBalance` 无 |
终端里曾出现给 `slot_wallet/README.md` 增加 §3.4 PHPDoc 的 diff但当前 README **尚未落地**该节——规范应优先进 **Cursor rule**Agent 每次都会读README 可作为人类文档二次同步(可选)。
## 推荐规则文件
**路径**[`/Users/ray/.cursor/rules/php-doc.mdc`](/Users/ray/.cursor/rules/php-doc.mdc)
**Frontmatter 建议**
```yaml
---
description: PHP PHPDoc requirements for all classes, methods, and constants
globs: "**/*.php"
alwaysApply: false
---
```
-`globs: **/*.php`:编辑 PHP 时自动注入,不污染 Vue/TS 会话
- `alwaysApply: false`:与 layering 的 `true` 区分,减少非 PHP 任务 token
## 规则正文(严格版,建议写入 mdc
### 1. 适用范围
- **新增或修改**的 PHP 文件中的:**class / interface / trait / enum**、**所有方法**`public` / `protected` / `private`)、**所有类常量**`const`
- 适用目录:`slot_*``backend/**` 下 PHP 业务代码
- **不追溯**改历史未动代码;但 **本次 diff 触及的符号** 若缺 PHPDoc须一并补齐
### 2. 最低 PHPDoc 内容
| 符号 | 必须包含 |
|------|----------|
| 类 / 接口 / Trait | 一行职责说明;复杂类可加 `@package`(可选) |
| 方法 | 职责说明 + 每个参数的 `@param` + `@return`;有 `throw` 的须 `@throws` |
| 类常量 | 一行说明业务含义(单位、枚举语义、与配置/表字段对应关系) |
| 属性(若新增) | `@var` 或 typed property + 一行说明(仅当类型/语义不直观时) |
已有 **PHP 8+ 标量/对象类型声明** 时,`@param`/`@return` 仍要保留(与你选的 strict 一致),但 **描述句可短**,禁止空块或只复制类型名。
### 3. 禁止项(与 slot_agent 注释原则对齐)
- 禁止无 `@param` / `@return` 的空 `/** */`
- 禁止 `@param int $id id` 式同义反复;语义、单位、边界写进描述
- 禁止用 PHPDoc 替代 Validate / Logic 里的业务校验说明
### 4. 分层补充(与 layering 一致)
对以下层 **额外** 要求写清业务语义(不仅是类型):
- **Controller**:接口用途、幂等/鉴权前提(若有)
- **Logic**:用例步骤、事务边界、失败时行为
- **Service**:复用场景、调用方约束
- **Model**:查询条件、分表键、金额字段单位
- **DTO / Validate**:字段含义、与上游参数映射
### 5. 示例模板(写入规则供 Agent 照抄)
```php
/**
* 首充前冻结免费余额。
*
* @return WalletEntity|null 成功返回钱包实体;无需冻结时返回 null
* @throws WalletException 余额不足或钱包不存在
*/
public function freeCreditsFreeze(): ?WalletEntity
```
```php
/** 释放档位:单位分,对应配置 free_credits.release_tiers */
public const RELEASE_TIER_MIN = 100;
```
## 与工具链的关系(可选,本期可不做的)
Cursor rule **不能**在 CI 里自动 fail。若以后要机器 enforce再单独加
- PHPStan + `phpstan/phpdoc-parser`
- PHPCS `Squiz.Commenting` / 自定义 sniff
本期仅 Cursor 规则即可满足「Agent 写码时遵守」。
## 实施步骤
1. 新建 [`php-doc.mdc`](/Users/ray/.cursor/rules/php-doc.mdc),按上文写入 frontmatter + 正文
2. 在 Cursor Settings → Rules 确认该规则对 PHP 文件生效(`globs` 匹配)
3. (可选)把相同 §3.4 同步进 [`slot_wallet/README.md`](/Users/ray/Documents/project/www/slot/slot_wallet/README.md) 与其它服务 README供人工 review 对照
4. 用一次小改动验证:例如在 `WalletLogic` 给无注释的 `getBalance` 补 PHPDoc看 Agent 是否自动遵循
## 风险与预期
- **Diff 变大**strict 下每个新方法多 515 行注释,属预期成本
- **历史债**:全库补 doc 工作量巨大;规则应写明 **仅 touch 到的符号**,避免 Agent 一次性重构整文件
- **类型重复**strict 仍保留 `@param`/`@return` 类型,利于 IDE/静态分析;描述聚焦「为什么/单位/边界」
```mermaid
flowchart LR
subgraph rules [Cursor Rules]
layering[backend-layering.mdc]
phpdoc[php-doc.mdc]
devenv[dev-environment.mdc]
end
subgraph code [PHP 改动]
edit[编辑 PHP 文件]
agent[Agent 生成/修改代码]
end
edit --> phpdoc
edit --> layering
agent --> phpdoc
agent --> layering
```

View File

@@ -0,0 +1,84 @@
---
name: PHPUnit 钱包链路测试
overview: 补齐本仓库的 PHPUnit 基建,并新增 register/bet/win 集成测试用例,默认在本地 php82 容器执行、连接真实依赖,以 HTTP 结果与余额变化作为核心断言。
todos:
- id: setup-phpunit
content: 补齐 PHPUnit 基建composer require-dev、phpunit.xml、tests/bootstrap.php
status: completed
- id: implement-feature-test
content: 实现 register/bet/win 集成测试与幂等/异常断言
status: completed
- id: run-in-container
content: 在 php82 容器执行测试并根据结果修正
status: completed
- id: document-runbook
content: 补充测试执行说明与可选增强项
status: completed
isProject: false
---
# PHPUnit Register/Bet/Win 测试落地计划
## 默认口径(基于当前信息)
- 测试框架PHPUnit当前仓库尚无 `phpunit.xml``tests/`)。
- 执行环境:本地 `php82` 容器。
- 依赖模式:默认连本地真实 MySQL/Redis贴近联调
- 断言范围:默认先做 API 返回与余额变化断言DB 深断言作为第二阶段可选扩展。
## 现状结论
- 缺少 PHPUnit 基建:未发现 `phpunit*.xml``tests/` 目录。
- `register/bet/win` 入口与逻辑已具备:
- 接口入口:[app/api/controller/WalletController.php](app/api/controller/WalletController.php)
- 业务编排:[app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php)
- 注册服务:[app/service/wallet/RegisterService.php](app/service/wallet/RegisterService.php)
## 实施步骤
1. **补测试基建**
-`require-dev` 增加 `phpunit/phpunit`
- 新增 `phpunit.xml`(定义 `tests/` 目录、bootstrap、环境变量覆盖
- 新增 `tests/bootstrap.php`(加载自动加载与测试环境初始化)。
2. **新增钱包链路集成测试**
- 新建测试文件:[tests/Feature/WalletRegisterBetWinTest.php](tests/Feature/WalletRegisterBetWinTest.php)
- 用例覆盖:
- `register` 成功(通过 `wallet/update` + `type=register`
- `bet` 成功并校验扣款后余额变化
- `win is_end=0` 中间派奖仅影响待结算展示
- `win is_end=1` 最终结算落账
- `bet` 重复 `biz_id` 幂等
- `win` 重复 `biz_id` 幂等
- `bet/win``round_id` 参数错误
- `win` 非法 `is_end` 参数错误
3. **测试数据与可重复执行设计**
- 每个测试生成独立 `uid/round_id/biz_id/trace_id`(时间戳+随机后缀),避免脏数据冲突。
- 用例内封装统一请求方法POST JSON与响应断言助手降低重复代码。
- 优先按顺序单线程执行此特性测试,避免并发串扰。
4. **执行与验证**
- 在容器内执行:`docker compose exec -T php82 php vendor/bin/phpunit --filter WalletRegisterBetWinTest`
- 验证输出:
- 所有用例通过
- 幂等场景无重复记账(通过返回余额不重复变化来断言)
5. **第二阶段可选增强(不在首版强制)**
- 增加 DB 断言(`wallet_log``wallet_fund_lot``wallet_bet_funding`)与 Redis pending key 清理断言。
- 将该测试纳入 CI job需环境可用性与测试隔离策略先达成一致
## 目标结构图
```mermaid
flowchart TD
testBootstrap[phpunitBootstrap] --> featureTest[WalletRegisterBetWinTest]
featureTest --> registerStep[register_update]
registerStep --> betStep[bet]
betStep --> winMidStep[win_is_end_0]
winMidStep --> winFinalStep[win_is_end_1]
winFinalStep --> idemCheck[idempotencyChecks]
idemCheck --> negativeCheck[invalidParamsChecks]
```
## 受影响文件(计划新增/修改)
- [composer.json](composer.json)
- [phpunit.xml](phpunit.xml)
- [tests/bootstrap.php](tests/bootstrap.php)
- [tests/Feature/WalletRegisterBetWinTest.php](tests/Feature/WalletRegisterBetWinTest.php)

View File

@@ -0,0 +1,11 @@
---
name: PHPUnit 集成测试落地
overview: 补齐本仓库 PHPUnit 基建,并新增 register/bet/win 集成测试(连接真实 MySQL/Redis与关键 DB 断言,支持在本地 php82 容器执行。
todos: []
isProject: false
---
# Register/Bet/Win PHPUnit 实施计划
## 目标
- 新增可

View File

@@ -0,0 +1,227 @@
---
name: PHP用例写法规则
overview: 在用户级 Cursor 新增 `php-use-case-style.mdc`,约束 slot 后端所有 PHP 业务代码Controller/Logic/Service/Model/Command 等)的表达力与可维护性;并扩展 verify-slot-backend.sh 对 diff 中所有 touched 的 app/**/*.php 做轻量检查。仅约束本次 diff不强制全盘重构历史代码。
todos:
- id: create-mdc
content: 创建 ~/.cursor/rules/php-use-case-style.mdc全层 MUST/禁止、BAD/GOOD、按层对照表
status: completed
- id: update-gate-rule
content: 在 agent-completion-gate.mdc 增加对 php-use-case-style 的引用(任意 PHP 改动)
status: completed
- id: extend-verify
content: 扩展 verify-slot-backend.sh所有 app/**/*.php diff 检查 + @use-case-exempt
status: completed
- id: smoke-verify
content: 分别在 Logic/Controller/Service diff 触发自测 PASS/FAIL/豁免
status: completed
- id: merge-php-code
content: 用例写法合并进 php-code.mdc修 frontmatter/强制要求/命名
status: completed
- id: fix-gate-refs
content: agent-completion-gate 引用改为 php-code
status: completed
- id: smoke-rules
content: 确认规则生效 + verify PASS
status: completed
isProject: false
---
# PHP 代码写法规则 + Verify 门禁(全层)
## 目标
让 AI 在改 **任意 slot 后端 PHP 业务代码** 时默认产出:**编排清晰、职责单一、业务异常语义正确、状态变更有条件更新与幂等键**,并对齐仓库内参照实现。
适用范围(规则 + verify 一致):
- `app/api/controller/**`
- `app/api/logic/**``app/innerapi/logic/**``app/napi/logic/**`
- `app/service/**`
- `app/model/**`
- `app/command/**`
- 其它 `app/**` 下业务 PHP`validate``entity` 等按条适用)
**不覆盖**`vendor/``tests/`(除非 diff 触及且单独约定)、纯配置/SQL 文件。
## 默认范围
- **规则文件**:用户级 [`~/.cursor/rules/php-use-case-style.mdc`](~/.cursor/rules/php-use-case-style.mdc)
- **生效方式**`alwaysApply: true`(任意对话均注入;与 `backend-layering` 同级,避免只开 Logic 文件时漏规则)
- **门禁脚本**[`~/.cursor/hooks/verify-slot-backend.sh`](~/.cursor/hooks/verify-slot-backend.sh)(仅 `git diff` + 路径 `app/`
- **不在本阶段**:强制重构历史代码(如 `DailyRebateLogic::claim`);可另开样板 PR
## 1. 新增规则 `php-use-case-style.mdc`
**Frontmatter**
```yaml
---
description: Slot PHP 写法——表达力、可维护、全层通用Controller/Logic/Service/Model/Command
alwaysApply: true
---
```
### 1.1 全层通用 MUST
| 条目 | 要求 |
|------|------|
| 单一职责 | 一个 public 方法只做一件事;编排方法目标 ≤25 行,超出拆 private |
| if 墙 | 禁止同一方法内连续 ≥4 个 `if (...) throw/return error`;合并为 `assert*` / `ensure*` |
| 业务异常 | 可预期业务失败用 `support\exception\BusinessException`;禁止用 `RuntimeException` 表示业务态(未开启、不可领、已过期等) |
| 魔法值 | 状态/类型用 Model 常量或 enum禁止裸 `1/2/3` 散落diff 新增代码) |
| 命名 | 方法名表达意图(`assertClaimable``markClaimedIfClaimable`),禁止 `doClaim``handle` 等空泛名(新增代码) |
| 参照 | 领取/入账编排:[`FreeCreditsLogic::claim`](slot_console/app/api/logic/FreeCreditsLogic.php)Controller[`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php) |
### 1.2 按层补充(与 backend-layering 互补,不重复分层表)
| 分层 | 写法 MUST | 禁止 |
|------|-----------|------|
| **Controller** | 仅Validate → 调 Logic/Service → `success/errorCode``catch BusinessException` 映射业务码 | 业务 if 墙、直接 `Db::`、直接 `gift()` |
| **Logic** | 用例编排:`buildContext``assert*``perform*``format*`;事务边界在此 | 纯转发 Model 无编排、跨服务裸 curl |
| **Service** | 可复用能力封装外部系统wallet/sdk隔离稳定 `biz_id` | Logic 型 Service单用例整条流水线 |
| **Model** | 查询/写入 + `scope`/`mark*If*` 条件更新;金额字段注释单位(厘) | 业务编排、调 Wallet |
| **Command** | 薄入口:参数解析 → 调 Logic日志 phase 结构化 | 复制 Logic 大段 if 墙 |
| **Validate** | 格式/必填/枚举 | 业务规则(「是否可领」应在 Logic assert |
### 1.3 领取 / 入账 / 状态变更(凡涉及处均适用)
- MUST条件更新`where status = expected``update`),鼓励 Model 方法 `mark*If*`
- MUST钱包/外部入账带稳定 `biz_id`(如 `daily_rebate:{uid}:{statDate}`
- Controller禁止 `catch (\RuntimeException)` 后一律 `PARAMS_ERROR`
### 1.4 BAD / GOOD规则内各一段≤8 行)
- BADController `catch RuntimeException` + Logic 5 连 throw + Service 仅 `return Model::find()`
- GOODController 捕 `BusinessException`Logic `assert*` + 条件更新Service 封装 wallet + biz_id
### 1.5 与现有规则关系
- 分层职责:仍服从 [`backend-layering.mdc`](~/.cursor/rules/backend-layering.mdc)
- 文档:仍服从 [`php-doc.mdc`](~/.cursor/rules/php-doc.mdc)
- [`agent-completion-gate.mdc`](~/.cursor/rules/agent-completion-gate.mdc) 增加:**任意改动 `app/**/*.php` 须遵守 `php-use-case-style`**
## 2. 扩展 `verify-slot-backend.sh`
**`CHANGED_PHP` 且路径匹配 `*/app/*`** 的文件检查(不再限定 `/logic/`
```bash
# 1) diff 新增行含 throw new \RuntimeException业务态误用
# 豁免:文件含 @use-case-exempt
# 2) Controller*/app/**/controller/*diff 新增行:
# catch (\RuntimeException 且同文件/邻近行 PARAMS_ERROR → fail
# 3) 已有BaseController、banlist、deleted class — 保持不变
```
**原则**
- 只 FAIL **diff 新增行**`git diff -U0` / `git diff --cached -U0`),不扫历史行
- `@use-case-exempt` 在文件前 5 行内则跳过该文件全部 use-case 检查
- 首期不 FAIL方法行数、biz_id 参数名(写在规则 MUSTverify 二期)
## 3. 数据流
```mermaid
flowchart TB
subgraph agent [Agent 改任意 app PHP]
rule[php-use-case-style alwaysApply]
layers[Controller Logic Service Model Command]
end
subgraph gate [完成前]
verify[verify-slot-backend app/** diff]
hook[stop hook followup]
end
rule --> layers
layers --> verify
verify -->|FAIL| hook
verify -->|PASS| done[可声称完成]
```
## 4. 验收
1. 打开任意 `app/service/*.php``DailyRebateController.php`规则均应生效alwaysApply
2. 在 Controller diff 新增 `catch (\RuntimeException` + `PARAMS_ERROR` → verify FAIL
3. 在 Service diff 新增 `throw new \RuntimeException('活动未开')` → verify FAIL
4.`@use-case-exempt` → 该文件跳过
5. `~/.cursor/hooks/verify-slot-backend.sh` 输出 PASS
## 5. 后续可选(本计划不含)
- 样板 PR重构 `DailyRebateLogic::claim` + `DailyRebateController::claim`
- verify 二期:`gift(` / wallet 调用邻近 biz_id 检测public 方法行数 WARN
---
## 6. 阶段二:合并进 `php-code.mdc`(待执行)
用户已将 PHPDoc 并入 [`php-code.mdc`](~/.cursor/rules/php-code.mdc)`php-use-case-style.mdc` 已不存在。verify 已具备 use-case 检查,但规则与 gate 引用断裂。本阶段只做规则对齐,不改业务代码。
### 6.1 修改 `php-code.mdc`
**Frontmatter**(删空 `globs:`
```yaml
---
description: PHP 全局工程规范PHPDoc + 用例写法 + AI 纪律)
alwaysApply: true
---
```
**在「代码原则」后插入新章节「用例写法app/**)」**(约 35 行):
- MUST业务失败 `support\exception\BusinessException`;禁止 `RuntimeException` 表业务态
- MUST编排 public 方法 ≤25 行;同一方法禁止连续 ≥4 个 `if (...) throw` → 抽 `assert*`
- MUST状态变更条件更新钱包入账稳定 `biz_id``daily_rebate:{uid}:{date}`
- 按层表(与 backend-layering 互补Controller 禁止业务 if 墙 / 直接 giftLogic 编排结构;禁止 Logic 型 Service
- 参照:`slot_console/app/api/logic/FreeCreditsLogic.php` :: `claim()``FreeCreditsController.php`
- BAD/GOOD 各一段RuntimeException vs BusinessExceptionController catch
- 豁免:文件前 5 行 `@use-case-exempt`(与 verify 一致)
**改写「强制要求」**L8492为 diff 范围:
```markdown
## 强制要求(本次 diff 新增/修改须符合)
- PHP 8+;新增/修改的方法须有 typed parameter 与 return
- 新增类属性须 typed property
- 新建文件或本次 diff 触及的文件顶部可加 `declare(strict_types=1);`,禁止为达标改无关历史文件
```
**微调「命名规范」**
- 禁止 `$tmp``$a``$b`、无业务含义的 `$data`/`$list`
- **删除** blanket 禁止 `$info`(与 `UserInfoEntity` 等冲突);改为禁止「无上下文的 `$info` 临时变量」
**文末增加「与 verify 对齐」**
- diff 新增 `throw new RuntimeException`(业务态)→ verify FAIL
- Controller diff 新增 `catch RuntimeException` + `PARAMS_ERROR` → verify FAIL
### 6.2 修改 `agent-completion-gate.mdc`
```diff
- 3. 修改 PHP 后,最终回复含 `PHPDoc: checked`(见 php-doc 规则)。
- 4. 改动 `app/**/*.php` 须遵守 `php-use-case-style`表达力、BusinessException、按层写法
+ 3. 修改 PHP 后,最终回复含 `PHPDoc: checked`(见 php-code 规则 PHPDoc 章节)。
+ 4. 改动 `app/**/*.php` 须遵守 `php-code` 用例写法章节BusinessException、按层写法
```
### 6.3 不改动
- `verify-slot-backend.sh`(已含 use-case 检查)
- `backend-layering.mdc``cross-service-sdk.mdc`
### 6.4 验收
1. `~/.cursor/rules/` 仅一份 PHP 总规范 `php-code.mdc`,无悬空 `php-doc` / `php-use-case-style` 引用
2. 新开 Agent 对话,改 `app/**` PHP 时应看到用例写法 + PHPDoc
3. `~/.cursor/hooks/verify-slot-backend.sh` → PASS
### 6.5 执行 todos
| id | 内容 |
|----|------|
| merge-php-code | 按 6.1 更新 php-code.mdc |
| fix-gate-refs | 按 6.2 更新 agent-completion-gate.mdc |
| smoke-rules | 打开 DailyRebateLogic + 跑 verify |

View File

@@ -0,0 +1,11 @@
---
name: pwa每日返水流水展示
overview: 在 slot_pwa 的交易流水服务中补充每日返水(65)展示映射,确保钱包已入账的每日返水可在前端流水里看到。
todos: []
isProject: false
---
# PWA 每日返水流水展示计划
## 目标
-

View File

@@ -0,0 +1,49 @@
---
name: recharge-config规则说明字段
overview: 为 `s_common.s_recharge_gift_config` 新增独立 `rule_desc` 字段,并在 `slot_admin``slot_console` 全链路打通,保持 `help` 兼容不破坏现有接口。
todos:
- id: add-rule-desc-ddl
content: 在 slot_console/db 与 slot_admin/db 增加 s_recharge_gift_config.rule_desc 的 DDL
status: completed
- id: wire-admin-rule-desc
content: 打通 slot_admin 的 validate/logic/model 对 rule_desc 的读写
status: completed
- id: wire-console-rule-desc
content: 在 slot_console 模型与 DailyRebate info 返回新增 rule_desc 并保留 help
status: completed
- id: verify-multi-repo
content: 分别校验变更仓库并运行 verify-slot-backend 脚本
status: completed
isProject: false
---
# s_recharge_gift_config 增加 rule_desc 计划
## 目标
-`s_common.s_recharge_gift_config` 增加独立规则说明字段 `rule_desc`
- 后台管理(`slot_admin`)支持新增/编辑/列表读写该字段。
- C 端(`slot_console`)返回新增 `rule_desc`,并继续保留现有 `help` 字段兼容。
## 变更范围
- 数据库 SQL双落点
- [`/Users/ray/Documents/project/www/slot/slot_console/db/install.sql`](/Users/ray/Documents/project/www/slot/slot_console/db/install.sql)
- [`/Users/ray/Documents/project/www/slot/backend/slot_admin/db`](/Users/ray/Documents/project/www/slot/backend/slot_admin/db)
- 增加 `ALTER TABLE s_common.s_recharge_gift_config ADD COLUMN rule_desc ...`(含注释、默认值、可空策略)。
- `slot_admin`(配置录入链路)
- [`/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/validate/RechargeGiftConfigValidate.php`](/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/validate/RechargeGiftConfigValidate.php)
- [`/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/logic/RechargeGiftConfigLogic.php`](/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/logic/RechargeGiftConfigLogic.php)
- [`/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/model/RechargeGiftConfigModel.php`](/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/model/RechargeGiftConfigModel.php)
- 确保 `saveRedPacket/updateRedPacket` 等写入路径可接收并持久化 `rule_desc`,列表查询可读出该字段。
- `slot_console`(对外返回链路)
- [`/Users/ray/Documents/project/www/slot/slot_console/app/model/common/RechargeGiftConfigModel.php`](/Users/ray/Documents/project/www/slot/slot_console/app/model/common/RechargeGiftConfigModel.php)
- [`/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php`](/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php)
- 在活动元数据中新增 `rule_desc`,并保留现有 `help` 字段。
- 返回策略:`rule_desc` 优先读新字段;`help` 维持原语义不变。
## 验证
- SQL 可执行性:检查字段存在与默认值。
- 后台接口:新增/编辑后回读包含 `rule_desc`
- C 端接口:`DailyRebate info` 同时返回 `help``rule_desc`
- 执行门禁:在变更仓库分别运行 `~/.cursor/hooks/verify-slot-backend.sh`

View File

@@ -0,0 +1,81 @@
---
name: SDK taskProgress 封装
overview: 将 slot_sdk或你们的 SDK 仓库)加入工作区后,可按现有 Wallet Client 模式直接实现 `player-task/task-progress` 的调用封装wallet 侧接口文档已就绪,可作为契约来源。
todos:
- id: add-sdk-workspace
content: 用户将 slot_sdk 加入 Cursor 工作区并告知仓库路径
status: completed
- id: explore-sdk-patterns
content: 阅读 SDK 现有 Wallet Client / HTTP 封装与错误处理约定
status: completed
- id: implement-client
content: 新增 taskProgress 方法、路径常量、请求/响应类型(对齐 player-task-progress-api.md
status: completed
- id: add-tests-or-example
content: 按 SDK 惯例补单测或调用示例(若项目有测试目录)
status: completed
isProject: false
---
# SDK 工作区加入后的直接开发方案
## 结论
**可以。** 当前工作区只有 [slot-wallet](file:///Users/ray/Documents/project/www/ray/slot-wallet),已具备接口契约文档 [doc/player-task-progress-api.md](doc/player-task-progress-api.md) 与实现对照(`PlayerTaskController``PlayerTaskQueryService` 等)。把 **SDK 仓库** 作为第二个根目录(或 monorepo 子目录)加入工作区后,我可以:
1. 阅读 SDK 里现有 Wallet/HTTP Client 的命名、基类、错误处理、DTO 约定;
2. 新增 `taskProgress`(或团队统一命名)方法、路径常量、请求/响应类型;
3. 若有单测/示例,补一条调用示例或 Feature 测试;
4. 保证字段 **snake_case** 与 HTTP JSON 一致,成功判定 `code === 0`
```mermaid
flowchart LR
subgraph workspace [Cursor Workspace]
Wallet[slot-wallet]
SDK[slot_sdk]
end
Doc[player-task-progress-api.md]
Wallet --> Doc
SDK -->|reads patterns| SDK
Doc -->|contract| SDK
SDK -->|POST player-task/task-progress| Wallet
```
## 你需要做的准备
| 项 | 说明 |
|----|------|
| 加入工作区 | Cursor**File → Add Folder to Workspace**,选中 SDK 仓库根目录 |
| 告知路径 | 例如 `company/ray/slots/slot_sdk`(与你们实际目录一致即可) |
| 语言确认 | 若是 PHP `slot_sdk`、Go、TS 等,我会跟现有 Client 语言一致,不另起一套风格 |
无需改 wallet 代码即可开始 SDK 开发wallet 接口已实现完毕。
## 我会按什么写(预期产出)
以 SDK 现有 Wallet Client 为模板(具体类名需打开 SDK 后确认),典型改动:
- **路径常量**`player-task/task-progress`
- **请求**`uid`, `currency`(可选统一附带 `trace_id` 若其他接口都有)
- **响应模型**`summary` + `bonus_tasks[]` + `deposit_tasks[]`,字段与 [doc/player-task-progress-api.md](doc/player-task-progress-api.md) §5 一致
- **错误**:复用 SDK 已有 `WalletApiError` / `code !== 0` 处理
## 跨仓库发布(团队规范)
按 [`.cursor/rules/slot-wallet-layers-and-delivery.mdc`](file:///Users/ray/Documents/project/www/ray/slot-wallet/.cursor/rules/slot-wallet-layers-and-delivery.mdc) §89
1. **先在 SDK 仓库** `commit``push`
2. 消费方(如 slot-wallet 若通过 composer 依赖 SDK`composer update` 并锁 `composer.lock`
当前 [composer.json](file:///Users/ray/Documents/project/www/ray/slot-wallet/composer.json) **尚未**声明 `slot_sdk` 依赖,说明 SDK 可能独立发布或由其他服务引用——这不影响我在 SDK 仓库内直接编码。
## 建议的确认项(加入工作区后第一条消息说明即可)
1. SDK 仓库在本机的**绝对路径**或文件夹名;
2. 方法命名偏好:`taskProgress` / `getPlayerTaskProgress` / 与现有 `wallet()` 等方法对齐;
3. 是否需要 **foundation 常量**(如 `source_type`)进 SDK还是仅透传 int。
## 不在本次默认范围
- 修改 wallet 服务端实现(已满足 PRD §26.1
- 自动 `composer update` 到其他服务(除非你明确要求并给出目标仓库)。

View File

@@ -0,0 +1,16 @@
---
name: slot workspace
overview: 在 `/Users/ray/Documents/project/www/slot` 创建一个 Cursor/VS Code workspace 文件,并把该目录下一级项目文件夹加入 workspace。默认排除隐藏目录 `.vscode`,不递归加入嵌套子目录。
todos: []
isProject: false
---
# 创建 Slot Workspace
将创建 `[slot.code-workspace](/Users/ray/Documents/project/www/slot/slot.code-workspace)`,内容使用标准 VS Code/Cursor workspace JSON
```json
{
"folders": [
{ "path": "backend" },
{ "path": "monitor" }

View File

@@ -0,0 +1,176 @@
---
name: Slot 硬规则落地
overview: 在用户级 `~/.cursor` 强化分层/PHPDoc 规则为可执行 checklist并新增 Cursor Hooks拦截宿主机 php/composer、在 Agent 结束前自动跑仓库校验脚本,未通过则注入 follow-up 要求继续修复。
todos:
- id: hooks-scripts
content: 新建 ~/.cursor/hooks.json、block-host-php.sh、verify-slot-backend.sh 并 chmod +x
status: completed
- id: rules-mdc
content: 扩展 backend-layering.mdcphp-doc alwaysApply新增 agent-completion-gate.mdc
status: completed
- id: plan-dod
content: 每日返水 plan 追加 Definition of Done与脚本检查项一致
status: completed
- id: verify-hooks
content: 手动验证:宿主机 php 拦截、docker php 放行、verify 脚本 PASS/FAIL
status: in_progress
isProject: false
---
# Slot 后端硬规则落地
## 目标
把当前「软约束」升级为三层:
| 层级 | 手段 | 效果 |
|------|------|------|
| 上下文 | 规则 `alwaysApply` + 完成前 MUST | 每条对话强制可见 |
| 行为 | Agent 必须跑校验脚本并贴输出 | 可审计、可复查 |
| 机器 | Cursor Hooks `failClosed` | 违反则阻断或自动续跑修复 |
不依赖各 slot 仓库已有 CI目前无 `.github/workflows`、无 pre-commit**先落在用户级 `~/.cursor`**,对你当前多根工作区(`slot_console``backend/slot_admin` 等)全部生效。
## 1. 强化 Cursor Rules`~/.cursor/rules/`
### 1.1 扩展 [backend-layering.mdc](file:///Users/ray/.cursor/rules/backend-layering.mdc)
在文末新增 **「Agent 完成前 MUST」**(短、可验证):
- **重命名/删除类**:对本次 diff 中删除的 `*.php`,提取类名,在 `~/Documents/project/www/slot``rg` 引用数为 **0**
- **新建/大改 `app/api/controller/*`**:必须 `extends \slotLib\basic\BaseController`,构造注入对应 `*Logic` + `*Validator`;参照 [FreeCreditsController.php](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/controller/FreeCreditsController.php)。
- **禁止 Logic 型 Service**:不得新建以单业务用例编排为主、仅转发 Model/Logic 的 `app/service/*Service``DailyRebateCalcService` 这类**公共计算**除外)。
- **声称完成前**:必须执行 `~/.cursor/hooks/verify-slot-backend.sh`,在最终回复粘贴脚本输出(`PASS``FAIL` 明细)。
### 1.2 调整 [php-doc.mdc](file:///Users/ray/.cursor/rules/php-doc.mdc)
- `alwaysApply: false`**`alwaysApply: true`**
- 在「Agent 执行要求」增加:**最终回复必须含一行 `PHPDoc: checked` 或列出例外文件**
与 layering 规则叠加后PHPDoc 不再因 glob 未命中而漏掉。
### 1.3 新增 [agent-completion-gate.mdc](file:///Users/ray/.cursor/rules/agent-completion-gate.mdc)
`alwaysApply: true`,仅 1520 行,避免与 layering 重复:
- 引用校验脚本路径与 Hooks 行为
- 禁止在未跑脚本前写「已完成 / 可以合并」
- 多仓库任务:脚本会扫描 `slot_*``backend` 下有 git 的目录
## 2. Cursor Hooks用户级真正「硬」
新建:
- [~/.cursor/hooks.json](file:///Users/ray/.cursor/hooks.json)
- [~/.cursor/hooks/block-host-php.sh](file:///Users/ray/.cursor/hooks/block-host-php.sh)
- [~/.cursor/hooks/verify-slot-backend.sh](file:///Users/ray/.cursor/hooks/verify-slot-backend.sh)
```mermaid
flowchart TD
shell[Agent_Shell_command]
blockHook[block-host-php.sh]
edit[PHP_file_edits]
stop[Agent_stop]
verify[verify-slot-backend.sh]
shell --> blockHook
blockHook -->|deny_naked_php_composer| block[permission_deny]
blockHook -->|docker_exec| allow[permission_allow]
stop --> verify
verify -->|FAIL| followup[followup_message_continue_fix]
verify -->|PASS| done[session_ends]
```
### 2.1 `beforeShellExecution` — 落实 [dev-environment.mdc](file:///Users/ray/.cursor/rules/dev-environment.mdc)
**`block-host-php.sh`**`failClosed: true`
- 若命令以 `php` / `composer` 开头且**不包含** `docker exec``permission: deny`
- 允许:`docker exec ... php``docker exec ... composer`
- 其它命令 → `allow`
这样宿主机误跑 PHP 会被 Hook **直接拦住**,不依赖模型记忆。
### 2.2 `stop` — 分层与调用方收尾
**`verify-slot-backend.sh`**`failClosed: false`,失败时返回 `followup_message` 让 Agent 继续改):
扫描根目录:`/Users/ray/Documents/project/www/slot` 下各子仓库(存在 `.git``slot_*``backend`
| 检查 | 逻辑 |
|------|------|
| 删除类无残留引用 | `git diff --diff-filter=D --name-only` 的 PHP → 解析 `class X``rg '\bX\b'` 全 slot 根,非 0 则 FAIL |
| API Controller 结构 | `git diff --name-only` 命中 `app/api/controller/*.php` → 文件须含 `extends \slotLib\basic\BaseController`(无则 FAIL除非文件头有 `@layering-exempt` |
| 禁止符号回归 | 内置 banlist首项 `DailyRebateService``rg` 命中即 FAIL |
| 可选轻量 PHPDoc | 对 diff 中新增的 `public function`,上一行非 `/**` 则 WARN不阻断避免历史债 |
输出格式固定:
```
=== verify-slot-backend ===
PASS
```
`FAIL` + 逐条原因;`stop` hook 解析到 `FAIL` 时返回 `followup_message`:「校验未通过,按明细修复后重跑脚本」。
脚本依赖:`bash``git``rg`(你本机已有);不依赖 `jq`(用 grep/sed 解析 stdin降低 hook 环境差异)。
### 2.3 `hooks.json` 草案
```json
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": "./hooks/block-host-php.sh",
"matcher": "^\\s*(php|composer)\\b",
"failClosed": true
}
],
"stop": [
{
"command": "./hooks/verify-slot-backend.sh",
"failClosed": false
}
]
}
}
```
路径相对 **`~/.cursor/`**(用户 hook 约定)。创建后 `chmod +x` 两个脚本。
**验证步骤**(实施后手动测一次):
1. Agent 尝试 `php -v` → 应被拦截
2. `docker exec ... php -v` → 应放行
3. 故意留 `DailyRebateService` 引用 → `stop` 应触发 follow-up
## 3. 每日返水 Plan 补 DoD可选与硬规则对齐
在 [每日返水活动化改造_dd1575a1.plan.md](file:///Users/ray/.cursor/plans/每日返水活动化改造_dd1575a1.plan.md) 末尾加 **Definition of Done** 小节(与脚本检查项一致),便于人工对照:
- `rg DailyRebateService` → 0
- `DailyRebateController` 对齐 `FreeCreditsController`
- 删除 orphan `dailyRebateTierConfig/*`
- `verify-slot-backend.sh` → PASS
(仅更新 plan 文档,不执行业务代码。)
## 4. 不纳入首期的项(避免过度工程)
- 各仓库 CI / pre-commit可后续把 `verify-slot-backend.sh` 拷到 `scripts/` 进 pipeline
- `DailyRebateTierValidateService` 迁到 Validate 层:属代码重构,单独 PR
- `afterFileEdit` 每次保存都跑校验:太吵,先用 `stop` + 规则要求手动跑脚本
## 5. 实施顺序
1. 写两个 hook 脚本 + `hooks.json`,本地 chmod + 试跑脚本
2. 改/增三个 `.mdc` 规则文件
3. 更新每日返水 plan 的 DoD若你同意
4. 重启 Cursor 或确认 Hooks 面板已加载
## 风险与说明
- **Hooks 是 Cursor 产品能力**:需 Cursor 版本支持 `hooks.json`;若 `stop` 的 stdin 字段与文档不一致,首版脚本以「扫描 git diff」为主不依赖复杂 JSON 字段。
- **硬 ≠ 100%**:模型仍可能不写 follow-up 前的脚本输出;`stop` hook 是兜底。二者叠加后明显严于仅 Rules。
- **多根工作区**:脚本按 `~/Documents/project/www/slot/*/git` 遍历,不依赖当前打开哪个 folder。

View File

@@ -0,0 +1,165 @@
---
name: status 接口字段精简
overview: 对照需求文档精简 C 端 status/claim 响应字段并将所有金额由千分位qf转为大单位与全站 getNumberFormat 一致innerapi 仍返回 qf 全量。
todos:
- id: package-list-for-client
content: FreeCreditsPackageModel::listForClient() 返回精简字段amount 经 getNumberFormat 转大单位
status: completed
- id: slim-build-status
content: buildStatus 去掉 activity_id顶层金额与 packages 均转大单位
status: completed
- id: format-first-cashout-amount
content: firstCashout 响应 data.amount 同步转大单位Pay 入参仍用 qf
status: completed
- id: update-controller-phpdoc
content: 更新 FreeCreditsController PHPDoc金额为展示单位 float非千分位
status: completed
- id: client-status-test
content: 单测断言字段白名单及 78500 qf → 78.5 等大单位转换
status: completed
isProject: false
---
# Free Credits status 接口字段精简
## 结论
**可以且应该精简。** 当前 [`buildStatus()`](slot_console/app/api/logic/FreeCreditsLogic.php) 直接 `toArray()` 透出库表列,超出需求文档中 C 端 UI 所需信息;[`activity_id`](slot_console/app/api/logic/FreeCreditsLogic.php) 仅用于后台/内部关联C 端无展示或交互用途。
你已确认:
- **只做删减**,不新增 `total_deposit`(累计充值进度由 C 端从钱包侧获取)。
- **金额转大单位**C 端接口不再返回千分位整数,统一转为展示金额(与 [`AgentController::formatAmountToFloat`](slot_console/app/api/controller/AgentController.php)、[`CommonFn::getNumberFormat`](slot_lib/src/common/CommonFn.php) 一致,默认 `moneyFormat=1000``moneyDot=2`,如 qf `78500``78.5`)。
---
## 需求文档 vs 当前响应
需求文档([首充前免费余额定格与分档释放需求文档.md](docs/requirements/首充前免费余额定格与分档释放需求文档.md))描述的是 **UI 行为**,未定义 JSON 契约,但可反推 C 端必需数据:
| UI 场景(文档章节) | C 端需要的数据 |
| --- | --- |
| 是否展示活动(`status === -1` 隐藏) | `status` |
| 首页 Withdraw 是否曾达赢取门槛§5.2、§7 | `home_withdraw_unlocked` |
| 池总额 / 入口副标题「$78.50 pending」§6.36.6、§9.3、§14.2 | `frozen_amount` |
| 问号弹窗 / 第一档金额§8、§10 | `first_cash_amount``recharge_unlock_amount` |
| 档位列表 Withdraw / Unlock / Claim / Claimed§9.3、§1012 | `packages[]``id``package_type``amount``status` |
| 发起 claim / 第一档提现(现有 API | `packages[].id` → POST `package_id` |
**当前多出的字段:**
- 顶层:`activity_id``recharge_gift_config.id`,仅 innerapi/编排用)
- `packages[]` 全表列:`player_id``activity_id``uid``withdraw_order_id``claim_biz_id``unlocked_time``claimed_time``completed_time``create_time``update_time`
- `package_no` 可保留(便于调试与稳定排序展示),也可仅靠数组顺序;建议 **保留**(成本低、与 DB 序号一致)
**命名与单位:** packages 字段由 `amount_qf` 改为 **`amount`**;顶层与各档 **`amount` 均为大单位 float**(非 qfC 端可直接用于 `$78.50` 类文案,无需再 `/1000`
---
## 目标响应契约C 端)
```json
{
"status": 4,
"home_withdraw_unlocked": 1,
"frozen_amount": 78.5,
"first_cash_amount": 20,
"recharge_unlock_amount": 50,
"packages": [
{
"id": 101,
"package_no": 1,
"package_type": 1,
"amount": 20,
"status": 1
}
]
}
```
`status === -1` 时仍仅 `{ "status": -1 }`(与现逻辑一致)。
**转换规则(实现):**
```php
// 与全站 C 端金额一致qf 为库表 / 内部逻辑整数
private function formatClientAmount(int $amountQf): float
{
return (float) CommonFn::getNumberFormat($amountQf);
}
```
- 应用于:`frozen_amount``first_cash_amount``recharge_unlock_amount``packages[].amount`
- **`firstCashout` 成功响应** `data.amount` 同样转大单位;调用 Pay / 钱包时仍传 qf仅 JSON 对外转换。
- **innerapi / 单测写库** 仍使用 `_qf` 整数,不在 DB 层改单位。
---
## 实现方案
```mermaid
flowchart LR
statusApi[status_claim_firstCashout]
buildStatus[buildStatus]
clientDto[toClientStatusDto]
modelFull[listByPlayerId_toArray]
innerapi[innerapi_list]
statusApi --> buildStatus --> clientDto
buildStatus --> modelFull
innerapi --> modelFull
```
1. **Logic 层统一转换**[`FreeCreditsLogic`](slot_console/app/api/logic/FreeCreditsLogic.php)
- 新增 `formatClientAmount(int $amountQf): float`(封装 `CommonFn::getNumberFormat`)。
-`buildStatus``listForClient`(或 Model 回调)、`firstCashout` 响应共用。
2. **Model 层 C 端 packages**[`FreeCreditsPackageModel`](slot_console/app/model/common/FreeCreditsPackageModel.php)
- 新增 `listForClient(int $playerId, callable $formatAmount): array` 或 Logic 内 `array_map`:只输出 `id``package_no``package_type``amount`(大单位)、`status`
- 保留 `listByPlayerId()` 供 innerapi、dev、集成测试。
3. **收口 DTO**`buildStatus`
- 去掉 `activity_id`
- 顶层三金额字段经 `formatClientAmount``packages``listForClient`
- `status()``claim()``buildStatus` 返回,结构一致。
4. **firstCashout 响应**
- `return ['order_id' => ..., 'amount' => $this->formatClientAmount($package->amount_qf)]`
5. **更新文档**[`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php) PHPDoc
- 删除「千分位整数、展示时除以 1000」描述改为「金额为大单位 float精度见 `moneyDot`」。
6. **单测**
- 新增 `FreeCreditsClientStatusTest`:字段白名单 + `78500` qf → `78.5` 转换断言。
7. **不改动**
- [`innerapi/controller/FreeCreditsController`](slot_console/app/innerapi/controller/FreeCreditsController.php) 仍返回全量 `toArray()`
- `FreeCreditsFreezeDev` 等内部工具继续用 `listByPlayerId`
---
## 字段对照表(精简前后)
| 字段 | 精简前 | 精简后 | 说明 |
| --- | --- | --- | --- |
| `activity_id` | 有 | **删** | C 端不需要 |
| `status` | 有 | 有 | 主状态机 |
| `home_withdraw_unlocked` | 有 | 有 | §5.2 |
| `frozen_amount` | qf 整数 | **float 大单位** | 如 78.5 |
| `first_cash_amount` | qf 整数 | **float 大单位** | 如 20 |
| `recharge_unlock_amount` | qf 整数 | **float 大单位** | 如 50 |
| `packages[].id` | 有 | 有 | claim/cashout |
| `packages[].package_no` | 有 | 有 | 序号展示 |
| `packages[].package_type` | 有 | 有 | 1=提现档 2=领取档 |
| `packages[].amount` | `amount_qf`qf | `amount`**float 大单位** | 重命名 + 转换 |
| `firstCashout.data.amount` | qf | **float 大单位** | 与 status 一致 |
| `packages[].status` | 有 | 有 | UI 状态 |
| `packages[].player_id/uid/...` | 有 | **删** | 内部字段 |
| `packages[].withdraw_order_id` 等 | 有 | **删** | 轮询靠主 `status` + package `status` |
---
## 风险与协调
- **Breaking change**C 端需改为直接使用大单位金额(不再 `/1000`);删除 `activity_id``amount_qf` 及 packages 审计字段。
- **精度**:与 `ShareConfigService::moneyDot` / `moneyFormat` 绑定,勿手写 `/1000`,避免与 VIP/钱包接口不一致。
- **累计充值进度**不纳入本次接口C 端继续从钱包统计接口取(钱包侧金额亦通常为大单位)。

View File

@@ -0,0 +1,70 @@
---
name: status 返回 help
overview: 在 Free Credits C 端 status 接口(及 claim 同源 buildStatus中增加 help 字段,读取活动配置表 recharge_gift_config.help供问号说明弹窗等展示status=-1 仍不返回 help。
todos:
- id: build-status-help
content: FreeCreditsLogic::buildStatus 增加 help 字段(读 config->help
status: completed
- id: update-phpdoc
content: FreeCreditsController::status PHPDoc 补充 help 说明
status: completed
- id: unit-test-help
content: FreeCreditsClientStatusTest 白名单与透传断言
status: completed
isProject: false
---
# status 接口增加 help 返回
## 目标
`GET /api/free-credits/status`(以及 `claim` 成功后的同源响应)在 `status !== -1` 时增加 **`help`string**,内容来自运营在后台活动编辑里填写的 **「帮助说明」**[`recharge_gift_config.help`](slot_console/app/model/common/RechargeGiftConfigModel.php))。
`status === -1` 时保持仅 `{ "status": -1 }`,不附带 help。
## 改动点
### 1. [`FreeCreditsLogic::buildStatus()`](slot_console/app/api/logic/FreeCreditsLogic.php)
在现有返回数组中增加:
```php
'help' => is_null($config) ? '' : strval($config->help ?? ''),
```
- `buildStatus` 已接收 `$config``activeConfig` 查出的 type=11 活动行),无需额外查库。
- 不做 §8 模板占位符替换轻量方案C 端可用已有 `frozen_amount` / `recharge_unlock_amount` / `first_cash_amount` 自行替换,或原样展示运营配置的富文本/多行文案。
### 2. [`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php) PHPDoc
`status` 方法成功字段列表中补充:
- `help` (string) 活动规则说明,来自后台「帮助说明」
### 3. 单测 [`FreeCreditsClientStatusTest`](slot_console/tests/Unit/FreeCreditsClientStatusTest.php)
- `PLAYER_TOP_KEYS` 增加 `help`
- 用例:`FreeCreditsConfigStub` 设置 `help` 属性,`assertSame` 透传
## 数据流
```mermaid
flowchart LR
admin[slot_admin 活动编辑 help 文本框]
db[(recharge_gift_config.help)]
logic[buildStatus]
api["/api/free-credits/status"]
admin --> db --> logic --> api
```
## 验收
- 后台 type=11 活动填写「帮助说明」并保存后,已参加用户 `status` 响应含相同 `help` 字符串
- `status=-1` 响应无 `help` 字段
- 单元测试通过;`claim` 返回结构同步含 `help`(复用 `buildStatus`
## 不在本次范围
- `ext_config` 独立说明模板、动态金额替换服务端拼装
- `banner_image` 透出(如需可另开)
- 前端问号弹窗 UI

View File

@@ -0,0 +1,129 @@
---
name: status 透出后续最小充值
overview: 在 slot_console 的 Free Credits `GET /api/free-credits/status`(及 claim 共用 `buildStatus`)中,将后台 ext_config 的 `subsequent_min_recharge_qf` 以 C 端约定字段 `subsequent_min_recharge`float 大单位)透出,与 `recharge_unlock_amount` 一致。
todos:
- id: logic-expose-field
content: FreeCreditsLogicbuildConfigClientFields / buildStatus / buildPreEnrollmentStatus 透出 subsequent_min_recharge
status: completed
- id: controller-phpdoc
content: FreeCreditsController::status PHPDoc 补充 subsequent_min_recharge 说明
status: completed
- id: unit-tests
content: 更新 FreeCreditsClientStatusTest、FreeCreditsEligibilityTest 白名单与断言
status: completed
- id: run-phpunit
content: php82 容器跑相关单测验证
status: completed
isProject: false
---
# status 透出 subsequent_min_recharge
## 背景
后台 type=11 活动 [`ext_config`](backend/slot_admin_vue/src/views/game/activity/edit.vue) 已保存 **`subsequent_min_recharge_qf`**(后续解锁最小单笔充值,千分位)。
[`FreeCreditsLogic::advanceByRecharge()`](slot_console/app/api/logic/FreeCreditsLogic.php) 充值推进逻辑**已读取**该配置:
```627:628:slot_console/app/api/logic/FreeCreditsLogic.php
$minRecharge = $this->configAmount($config, 'subsequent_min_recharge', self::DEFAULT_SUBSEQUENT_MIN_RECHARGE);
if ($rechargeAmount < $minRecharge || !is_null(FreeCreditsPackageModel::nextReadyReleasePackage($player->id))) {
```
[`configAmount()`](slot_console/app/api/logic/FreeCreditsLogic.php) 会优先读 `key_qf`(即 `subsequent_min_recharge_qf`),再 fallback 旧键 `subsequent_min_recharge`。
但 C 端 [`buildConfigClientFields()`](slot_console/app/api/logic/FreeCreditsLogic.php) / [`buildStatus()`](slot_console/app/api/logic/FreeCreditsLogic.php) 目前只透出 `win_threshold`、`recharge_unlock_amount`、`help`、`banner_image`**缺少**后续档解锁门槛,前端无法展示「单笔充值满 $X 解锁下一档」类文案。
```mermaid
flowchart LR
extConfig["ext_config.subsequent_min_recharge_qf"]
configAmount["configAmount(subsequent_min_recharge)"]
buildFields["buildConfigClientFields"]
statusAPI["status / claim data"]
advanceByRecharge["advanceByRecharge 已有"]
extConfig --> configAmount
configAmount --> advanceByRecharge
configAmount --> buildFields --> statusAPI
```
## 目标契约
| 项 | 约定 |
| --- | --- |
| 接口 | `GET /api/free-credits/status``POST /api/free-credits/claim` 成功后的 `data` 结构相同 |
| 字段名 | **`subsequent_min_recharge`**(与 `recharge_unlock_amount` 命名一致,**不**在 C 端响应中带 `_qf` |
| 类型 | `float`,展示大单位(`formatClientAmount` + `CommonFn::getNumberFormat` |
| 配置来源 | `ext_config.subsequent_min_recharge_qf`(经现有 `configAmount` |
| 默认值 | `DEFAULT_SUBSEQUENT_MIN_RECHARGE = 10`(千分位 → 展示 `10.0` |
| `status=-1` | 仍仅 `{ status: -1 }`,不含本字段 |
| `status=0` 与有 player | 均返回(与 `recharge_unlock_amount` 同级) |
响应片段示例(`status≥0`
```json
{
"status": 2,
"win_threshold": 50.0,
"recharge_unlock_amount": 50.0,
"subsequent_min_recharge": 10.0,
"help": "...",
"packages": []
}
```
## 实现步骤(仅 slot_console
### 1. Logic — [`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php)
在 **`buildConfigClientFields()`** 增加:
```php
'subsequent_min_recharge' => $this->formatClientAmount(
$this->configAmount($config, 'subsequent_min_recharge', self::DEFAULT_SUBSEQUENT_MIN_RECHARGE)
),
```
- `config === null` 时占位 `0.0`(与 `recharge_unlock_amount` 一致)
- 更新方法 `@return` 数组 shape
在 **`buildStatus()`**、**`buildPreEnrollmentStatus()`** 顶层增加:
```php
'subsequent_min_recharge' => $configFields['subsequent_min_recharge'],
```
无需改 Model / Service不新增中转 Service。
### 2. PHPDoc — [`FreeCreditsController.php`](slot_console/app/api/controller/FreeCreditsController.php)
在 `status()` 的 `data` 字段说明中,于 `recharge_unlock_amount` 后补充:
- `subsequent_min_recharge (float)` 解锁下一后续释放档所需**单笔**充值下限(来自 `ext_config.subsequent_min_recharge_qf`
### 3. 单测
| 文件 | 改动 |
| --- | --- |
| [`FreeCreditsClientStatusTest.php`](slot_console/tests/Unit/FreeCreditsClientStatusTest.php) | `PLAYER_TOP_KEYS` 增加 `subsequent_min_recharge`stub 配置加 `subsequent_min_recharge_qf`(如 `10000` → 展示 `10.0`)并断言 |
| [`FreeCreditsEligibilityTest.php`](slot_console/tests/Unit/FreeCreditsEligibilityTest.php) | `testStatusReturnsPreEnrollmentConfigWhenNoPlayer`stub 加 `subsequent_min_recharge_qf`,断言 `subsequent_min_recharge` |
### 4. 验证
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit \
tests/Unit/FreeCreditsClientStatusTest.php \
tests/Unit/FreeCreditsEligibilityTest.php
```
## 不在本次范围
- C 端 / slot_pwa 消费字段与 UI 文案(仅后端补字段)
- 透出 `first_cash_amount`、`package_amount`、`max_unlock_per_recharge` 等其它 ext 配置
- 修改 `advanceByRecharge` 业务规则(已正确读配置)
## 验收
1. 有资格且 `status≥0``data` 含 `subsequent_min_recharge`,值与后台配置的 `subsequent_min_recharge_qf` 一致(大单位)
2. `status=-1`:无该键
3. `claim` 成功返回的 `data` 同样含该字段
4. 上述单测通过

View File

@@ -0,0 +1,106 @@
---
name: status 配置与未充值
overview: 修复 slot_console FreeCreditsLogic::status无玩家池时返回 status=0 及活动配置C 端新增 win_threshold不含 initial_amount、home_withdraw_unlocked。Logic 已按需求变更实现,待对齐单测与 PHPDoc。
todos:
- id: logic-pre-enrollment
content: FreeCreditsLogicbuildPreEnrollmentStatusstatus() 无 player 时返回配置态(已完成)
status: completed
- id: logic-build-status-fields
content: buildStatus / buildConfigClientFields 仅透出 win_threshold不含 initial_amount、home_withdraw_unlocked已完成
status: completed
- id: align-phpdoc
content: FreeCreditsController PHPDoc 与 buildConfigClientFields 注释:删除 initial_amount、home_withdraw_unlocked移除无用 DEFAULT_INITIAL_AMOUNT
status: completed
- id: align-unit-tests
content: FreeCreditsClientStatusTest / FreeCreditsEligibilityTest 白名单与断言与现实现一致
status: completed
isProject: false
---
# Free Credits status 接口修复计划(需求变更版)
## 需求确认2026-05-21
**C 端 `GET /api/free-credits/status` 约定:**
- **包含**`win_threshold`(来自 `ext_config.win_threshold_qf`
- **不包含**`initial_amount``home_withdraw_unlocked`
- 注册赠送金额:仍由 `game_base.user_register_reward` 等其它接口提供
- 首页 Withdraw 解锁C 端用 `status`(如 `STATUS_HOME_UNLOCKED=1`)或钱包余额 + `win_threshold` 自行判断;库表 `home_withdraw_unlocked` 仍由 `syncHomeWithdrawUnlocked` 维护,仅不下发 API
后台 type=11 的 `initial_amount_qf` 配置保留(运营后台用),**不**经 status 透出。
## 问题根因
[`FreeCreditsLogic::status()`](slot_console/app/api/logic/FreeCreditsLogic.php) 原逻辑在无 `free_credits_player` 时返回 `{ status: -1 }`,导致首充前 C 端无法展示活动(`status !== -1` 为展示开关)。
## 目标行为
| 场景 | status | 返回顶层字段 |
| --- | --- | --- |
| 无资格 / 无 type=11 配置 | `-1` | 仅 `status` |
| 有资格 + 有配置 + **无 player** | `0` | `status`, `frozen_amount`, `first_cash_amount`, `win_threshold`, `recharge_unlock_amount`, `help`, `packages`(空数组) |
| 有资格 + 有配置 + **有 player** | 玩家真实值 | 同上 + 玩家 `frozen_amount` / `first_cash_amount` / `packages` |
```mermaid
flowchart TD
statusReq[GET status] --> eligible{资格+活动配置?}
eligible -->|否| notVisible["status=-1"]
eligible -->|是| player{有 player 行?}
player -->|无| preConfig["status=0 + win_threshold 等"]
player -->|有| fullStatus["buildStatus 玩家态"]
```
## 已实现 Logic当前代码无需再改字段集
[`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php) 已与上表一致:
- `status()`:无 player → `buildPreEnrollmentStatus($config)`
- `buildConfigClientFields()`:仅 `win_threshold``recharge_unlock_amount``help`
- `buildStatus()` / `buildPreEnrollmentStatus()`**不**返回 `initial_amount``home_withdraw_unlocked`
## 待办:对齐文档与单测
### 1. PHPDoc — [`FreeCreditsController.php`](slot_console/app/api/controller/FreeCreditsController.php)
- 删除 `initial_amount``home_withdraw_unlocked` 字段说明
- 明确 `status=0` 时仍返回 `win_threshold``recharge_unlock_amount``help`
### 2. 清理 Logic 注释/死代码 — [`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php)
- 删除未使用的 `DEFAULT_INITIAL_AMOUNT` 常量(若仍保留)
- `buildConfigClientFields``@return` 改为仅含 `win_threshold``recharge_unlock_amount``help`
### 3. 单测
| 文件 | 改动 |
| --- | --- |
| [`FreeCreditsClientStatusTest.php`](slot_console/tests/Unit/FreeCreditsClientStatusTest.php) | `PLAYER_TOP_KEYS` 改为:`status`, `frozen_amount`, `first_cash_amount`, `win_threshold`, `recharge_unlock_amount`, `help`, `packages`;移除 `initial_amount``home_withdraw_unlocked` 断言 |
| [`FreeCreditsEligibilityTest.php`](slot_console/tests/Unit/FreeCreditsEligibilityTest.php) | `testStatusReturnsPreEnrollmentConfigWhenNoPlayer`:断言 `win_threshold=50`**不**断言 `initial_amount` / `home_withdraw_unlocked` |
运行:
```bash
docker exec -w /app/www/slot/slot_console php82 vendor/bin/phpunit tests/Unit/FreeCreditsClientStatusTest.php tests/Unit/FreeCreditsEligibilityTest.php
```
## C 端约定(供联调)
- **展示开关**`data.status !== -1`;首充前为 `status === 0`
- **进度条上限**`win_threshold`
- **注册赠送****不要**从 status 取;走注册/大厅配置接口
- **Withdraw 解锁****不要**依赖 `home_withdraw_unlocked` 字段;用 `status` 或余额逻辑
## 不在本次范围
- status 返回 `initial_amount` / `home_withdraw_unlocked`
- 注册发奖改读 `ext_config.initial_amount_qf`
- slot_pwa / gateway 前端改动
- backend 后台 `initial_amount_qf` 编辑能力(已存在)
## 验收清单
1. 无 player`status=0`,含 `win_threshold``recharge_unlock_amount``help``packages=[]`**无** `initial_amount``home_withdraw_unlocked`
2. 有 player含玩家态金额 + 同上配置字段
3. 无资格/无配置:`status=-1`
4. 单测全部通过

View File

@@ -0,0 +1,131 @@
---
name: type11 活动初始金额
overview: 在活动类型 11Free Credits后台编辑表单中新增「活动初始金额」字段以美元小数录入、落库为 ext_config.initial_amount_qf同步补齐 slot_admin 校验与列表展示。数据经现有 ActivityService 透传至 slot_console本任务不改 slot_console 业务逻辑。
todos:
- id: edit-vue-initial-amount
content: edit.vuetype=11 表单项 + setFormData/submit 的 initial_amount ↔ initial_amount_qf 转换
status: completed
- id: index-vue-display
content: index.vuetype=11 列表 ext_config 展示活动初始金额
status: completed
- id: validate-initial-amount
content: ActivityValidate::checkFreeCreditsExt 增加 initial_amount_qf 必填与非负校验
status: completed
- id: manual-verify
content: 本地验证新建/编辑/回填/必填/列表展示
status: completed
isProject: false
---
# 活动类型 11 增加「活动初始金额」后台配置
## 背景与范围
- 需求来源:[首充前免费余额定格与分档释放需求文档.md](docs/requirements/首充前免费余额定格与分档释放需求文档.md) 第 19 节将注册赠送调整为 `$30`;运营需在 **活动类型 11** 的配置里维护「用户注册时的初始金额」。
- **已确认键名**`initial_amount`(表单) / `initial_amount_qf`(落库,千分位整数)。
- **范围**:仅 [backend/slot_admin](backend/slot_admin) 与 [backend/slot_admin_vue](backend/slot_admin_vue)`ActivityController` 已透传 `ext_config``slot_console`,无需改 `slot_console` / `slot_lib`(新字段会原样写入 `s_recharge_gift_config.ext_config` JSON
## 现状(可复用)
type=11 专属编辑已在 [edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue) 实现 6 个金额字段 + Banner模式为
- 编辑:`setFormData``*_qf ÷ 1000` 还原为美元小数
- 提交:`submit` 将美元小数 `× 1000` 写入 `*_qf`
- 校验:[ActivityValidate::checkFreeCreditsExt](backend/slot_admin/app/game/validate/ActivityValidate.php) 在 [ActivityController](backend/slot_admin/app/game/controller/ActivityController.php) `save` / `update(updateData)` 时触发
```mermaid
flowchart LR
editVue["edit.vue submit"] --> adminApi["slot_admin ActivityController"]
adminApi --> activitySvc["slotLib ActivityService"]
activitySvc --> consoleInner["slot_console innerapi/activity"]
consoleInner --> extConfig["ext_config JSON"]
```
## 字段定义
| UI 标签 | 表单字段 | 落库字段 | 说明 |
| --- | --- | --- | --- |
| 活动初始金额($) | `ext_config.initial_amount` | `initial_amount_qf` | 用户注册时赠送的免费余额金额placeholder 建议 `30`(对齐需求文档 $30 |
金额处理与现有 6 项一致:`Math.round(Number(v) * 1000)`,回填优先读 `_qf`
## 改动清单
### 1. 编辑表单 — [edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue)
`v-if="formData.type == 11"` 区块 **最上方** 增加表单项(置于「首笔赢取门槛」之前):
```vue
<a-form-item
label="活动初始金额($)"
help="用户注册时赠送的免费余额金额"
:rules="[{ required: true, message: '必填' }]">
<a-input-number v-model="formData.ext_config.initial_amount" placeholder="如 30" :min="0"/>
</a-form-item>
```
**`setFormData`type===11 分支)** 增加映射:
```js
initial_amount: e.initial_amount_qf != null ? e.initial_amount_qf / 1000 : e.initial_amount,
```
**`submit`type===11 分支)** 在 `data.ext_config` 中增加:
```js
initial_amount_qf: toQf(e.initial_amount),
```
### 2. 列表展示(可选但建议)— [index.vue](backend/slot_admin_vue/src/views/game/activity/index.vue)
在 type===11 的 `ext_config` 模板中增加一行,复用已有 `formatExtAmount`
```vue
<div>活动初始金额: ${{ formatExtAmount(record.ext_config.initial_amount_qf, record.ext_config.initial_amount) }}</div>
```
### 3. 后端校验 — [ActivityValidate.php](backend/slot_admin/app/game/validate/ActivityValidate.php)
`checkFreeCreditsExt()``$required` 数组追加:
```php
'initial_amount_qf' => '活动初始金额',
```
校验规则与现有金额字段一致:必填、非负整数(千分位)。若业务要求注册赠送必须大于 0可将条件改为 `(int)$extConfig[$key] <= 0` 时报错(与运营确认;默认可先仅要求 `>= 0`,与「首笔赢取门槛」等一致)。
更新方法 PHPDoc由「6 个金额字段」改为「7 个金额相关字段」。
### 4. 无需改动的文件
- [ActivityController.php](backend/slot_admin/app/game/controller/ActivityController.php):已按 type===11 调用 `checkFreeCreditsExt`,无需新增分支。
- `slot_console` / `slot_lib`:本任务只落库配置;注册发奖仍走 `game_base.user_register_reward`,后续若要从 type=11 的 `initial_amount_qf` 读配置,属独立 console 改造。
## 数据流示意
```mermaid
sequenceDiagram
participant Op as 运营后台
participant Vue as edit.vue
participant Val as ActivityValidate
participant DB as ext_config JSON
Op->>Vue: 填写 initial_amount=30
Vue->>Val: submit initial_amount_qf=30000
Val->>DB: 校验通过并保存
Note over DB: 后续 console 可用 configAmount(config,'initial_amount',default)
```
## 验证步骤
1. 新建/编辑 type=11 活动:可见「活动初始金额」,默认 placeholder 30。
2. 保存后 DB/API 返回的 `ext_config``initial_amount_qf`(如 30 → 30000
3. 再次打开编辑:回填为 30美元
4. 必填校验:留空提交应被 `checkFreeCreditsExt` 拦截。
5. 列表页 type=11 行展示「活动初始金额」。
6. 仅改状态(`updateData` 为空)的 update 请求仍不触发 ext 校验(现有逻辑保持不变)。
## 风险与说明
- **与注册发奖未联动**:本任务只完成后台配置录入;`UserRegisterEventService` 仍读 `game_base.user_register_reward`,改活动配置不会自动改变注册到账,需后续 console 改造读取 `initial_amount_qf`
- **全量覆盖 ext_config**`edit.vue` submit 对 type=11 会重建整个 `ext_config` 对象;新增字段必须同时写入 submit 与 setFormData避免编辑时丢失其它键当前实现已是全量重建与现有一致

View File

@@ -0,0 +1,58 @@
---
name: update 返回值 intval
overview: 在 WalletUserStatModel::updateWithOptionalVersion 中对 ThinkORM update 返回值做 intval 显式转换,保证声明返回 int 与运行时一致,并统一所有经该方法的 apply*/incField 出口。
todos:
- id: apply-intval-updateWithOptionalVersion
content: updateWithOptionalVersion 使用 $result + return (int) intval 并补 PHPDoc
status: completed
isProject: false
---
# updateWithOptionalVersion 返回值显式转换
## 问题
[`app/model/WalletUserStatModel.php`](app/model/WalletUserStatModel.php) 中:
```php
private static function updateWithOptionalVersion(...): int {
// ...
return $query->update($data); // 当前仓库版本
}
```
- `incField` / `applyDeposit` / `applyWithdraw`**全部**`updateWithOptionalVersion` 返回「影响行数」。
- ThinkORM 链式 `static::where(...)->update($data)`**BaseQuery** 上文档为 `int`,但 IDE/静态分析常因 `Model::update()` 静态方法(返回 `Modelable`)产生 **联合类型**,与 `: int` 不匹配。
- 你本地已改为 `intval($result)`,这是正确做法:统一把 `false`、数字字符串等收敛为 `int`,避免类型告警与调用方误判。
## 修改(单点即可)
在 [`updateWithOptionalVersion`](app/model/WalletUserStatModel.php) 末尾改为:
```php
$result = $query->update($data);
return (int) $result;
```
`return intval($result);`(与你本地写法等价;项目内可统一用 `(int)``intval`,二选一即可)。
**无需** 在每个 `apply*` / `incField` 再包一层转换——它们已 `return static::updateWithOptionalVersion(...)`,改一处即全覆盖。
## PHPDoc可选一行补充
在方法 `@return` 说明中注明:「对 ORM `update` 结果做整型转换,无匹配行时为 0」。
## 验证
Docker 内复用既有冒烟(`incField` / `applyDeposit`),确认返回仍为 `1`/`0` 整数:
```bash
docker exec -w /app/www/ray/slot-wallet php82 php -r "
# bootstrap 后 WalletUserStatModel::incField(...)
"
```
## 不涉及
- 不改 MQ / WalletLogic
- 不恢复 `inc()->update()` 链式写法

View File

@@ -0,0 +1,53 @@
---
name: wallet-bet-win-api-split
overview: 评估并规划将 bet/win 从统一 update 入口中显式拆分为独立 API同时保留兼容性与幂等语义。
todos:
- id: add-bet-win-controller-endpoints
content: 新增 wallet/bet 与 wallet/win 控制器入口,保留 update 兼容
status: completed
- id: split-validator-dto
content: 拆分 bet/win DTO 与校验场景,减少 type 分支耦合
status: completed
- id: proxy-update-for-compat
content: 让 update 的 bet/win 分支复用新入口流程,确保行为完全一致
status: completed
- id: docs-and-migration
content: 补充 README/doc 迁移说明与灰度/下线节奏
status: completed
isProject: false
---
# Bet/Win API 拆分评估与迁移计划
## 结论
- 对资金域来说,**对外 API 语义上拆分 `bet` / `win` 会更好**:可读性、接入防错、风控审计与权限隔离都会更清晰。
- 但不建议直接废弃 `update`;建议采用“**新增独立接口 + `update` 兼容转发 + 渐进下线**”的迁移路线,避免影响现有上游与历史幂等键。
## 现状依据
- 当前只有一个入口 [`app/api/controller/WalletController.php`](app/api/controller/WalletController.php) 的 `update()`,通过 `type` 分发。
- 分发逻辑在 [`app/api/logic/WalletLogic.php`](app/api/logic/WalletLogic.php) 的 `ACTION_METHOD_MAP``bet/win` 已是独立业务方法。
- 入参模型 [`app/api/dto/request/wallet/WalletUpdateRequestDTO.php`](app/api/dto/request/wallet/WalletUpdateRequestDTO.php) 同时承载多类交易,`round_id``is_end` 仅对 bet/win 有意义。
- 校验器 [`app/validator/Wallet2Validator.php`](app/validator/Wallet2Validator.php) 也是“单场景 + 按 type 条件校验”,存在语义混杂。
## 目标形态
- 提供显式 API`wallet/bet``wallet/win`(自动路由下对应 Controller 方法)。
- `wallet/update` 保留为兼容入口:内部仅做 DTO 转换与分发,不承载新能力。
- 资金与幂等规则保持不变:仍以 `biz_id` + `type`(必要时叠加 `round_id`)确保可追溯与幂等。
## 实施步骤
1. 在 [`app/api/controller/WalletController.php`](app/api/controller/WalletController.php) 新增 `bet()``win()` 方法,复用统一返回封装。
2. 拆分请求 DTO 与校验场景:
- 新增 bet/win 专用 DTO`WalletUpdateRequestDTO` 中抽取必要字段)。
- 在 [`app/validator/Wallet2Validator.php`](app/validator/Wallet2Validator.php) 增加 `SCENE_BET``SCENE_WIN`,去掉“按 type 再二次判断”的耦合。
3. 在 Logic 层保持复用:
- Controller 仍调用 [`app/api/logic/WalletLogic.php`](app/api/logic/WalletLogic.php) 现有 `bet()` / `win()` 实现,避免资金路径重写。
- `update()` 对 bet/win 请求改为调用新入口共享流程(或内部代理),确保行为一致。
4. 文档与对接迁移:
- 在 [`README.md`](README.md) 与 [`doc/wallet.md`](doc/wallet.md) 补充“新接口 + 兼容期 + 下线节奏”。
- 给上游约定迁移窗口,监控 `update(type=bet|win)` 调用量后再决定是否下线。
## 验证要点
- 幂等:重复 `biz_id` 命中行为与现状一致。
- Round`win``is_end=0/1` 中间派奖与最终结算语义不变。
- 资金正确性:下注扣款顺序、派奖分配、流水字段不变。
- 可回滚:任一阶段可回退为仅使用 `update` 入口。

View File

@@ -0,0 +1,132 @@
---
name: WalletUserStat Model
overview: 为 rh_wallet.wallet_user_stat 新增继承 think\Model 的 WalletUserStatModel通过 mysql 连接 + 库名.表名 的 $table 绑定跨库表;提供 inc/version 及充提首末次等领域方法;不改 thinkorm 配置与 WalletLogic。
todos:
- id: wallet-user-stat-model
content: 新增 WalletUserStatModel extends think\Modelconnection/table、PHPDoc、inc/apply* 方法)
status: completed
- id: smoke-verify
content: Docker 内冒烟验证 find/applyDeposit 与 version 行为
status: completed
isProject: false
---
# rh_wallet.wallet_user_stat Model 实现计划
## 背景与约束
- 表:`rh_wallet.wallet_user_stat`**单库单表、非分片**;主键 `(uid, currency)`;金额为 **x1000 整数**;含 `version` 乐观锁。
- 与现有 [`WalletStatModel`](app/model/multi/WalletStatModel.php)`SelfBaseModel` + 分片)**并存**,本次不替换其调用链。
- **直接继承 `think\Model`**;库表绑定参考项目内惯例:`$connection` + **`$table = '库名.表名'`**(与你提供的 `s_statistics.gm_stat_summary` 写法一致)。
```mermaid
flowchart LR
Logic["Logic / Service"]
Model["WalletUserStatModel"]
Conn["connection mysql"]
Table["table rh_wallet.wallet_user_stat"]
Logic --> Model
Model --> Conn
Model --> Table
```
## 实现方案
### 单文件:[`app/model/WalletUserStatModel.php`](app/model/WalletUserStatModel.php)
```php
namespace app\model;
use think\Model;
use think\facade\Db;
/**
* 用户钱包累计统计rh_wallet.wallet_user_stat
*
* @property int $uid
* @property string $currency
* ...
*/
class WalletUserStatModel extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
* 跨库:库名.表名
*
* @var string
*/
protected $table = 'rh_wallet.wallet_user_stat';
/**
* 复合主键
*
* @var array<int, string>|string
*/
protected $pk = ['uid', 'currency'];
}
```
**说明:**
- **不改** [`config/thinkorm.php`](config/thinkorm.php):沿用现有 `mysql` 连接(`database` 可为空,与分片库同一实例),由 `$table` 全限定名定位 `rh_wallet`
- 主键使用 ThinkORM **数组形式** `['uid', 'currency']`,与表 `PRIMARY KEY (uid, currency)` 一致;`find` / `update` / `destroy` 等可传复合键数组。
### PHPDoc `@property`
与表字段一致:`total_deposit_*``first/last_deposit_*`、提现/促销/返利/退款/拒付字段、`version``created_at``updated_at`;金额单位 x1000。
### 查询与初始化
| 方法 | 说明 |
|------|------|
| `findByUidCurrency(int $uid, string $currency = 'USD'): ?static` | `static::find(['uid' => $uid, 'currency' => $currency])` 或等价 where |
| `lockByUidCurrency(int $uid, string $currency): ?static` | 复合 where + `lock(true)->find()` |
| `insertInitRow(int $uid, string $currency = 'USD'): static` | `static::create(['uid' => $uid, 'currency' => $currency])` |
### 统计更新(参考 [`WalletStatModel::inc`](app/model/multi/WalletStatModel.php)
**通用累加 `incField`**
- `$field` 白名单:仅可 `inc``*_amount` / `*_count`
- `static::where(['uid','currency'])->inc($field, $amount)`,可选 `version` 条件与 `version + 1`
**领域方法(静态,单次 `update`**
| 方法 | 行为 |
|------|------|
| `applyDeposit` | 累加充值金额/次数;更新 `last_deposit_*`;首充写 `first_deposit_*` |
| `applyWithdraw` | 提现对称 |
| `applyPromoBonus` / `applyCashback` / `applyRefund` / `applyChargeback` | 对应累计字段 |
- 金额参数 `int`,禁止 `float`
- 首充/首提用 `Db::raw('IF(...)')` 保证原子性
- 返回影响行数
### 暂不包含
- 不改 [`WalletLogic.php`](app/api/logic/WalletLogic.php)、[`RegisterService.php`](app/service/wallet/RegisterService.php)
## 验证(实现后)
```bash
docker exec -w /app/www/slot/slot_wallet php82 php -r "
// bootstrap 后
var_export(app\model\WalletUserStatModel::findByUidCurrency(1, 'USD'));
"
```
## 文件清单
| 操作 | 路径 |
|------|------|
| 新增 | `app/model/WalletUserStatModel.php` |
**无配置变更**(不新增 `rh_wallet` connection

View File

@@ -0,0 +1,45 @@
---
name: win返回余额叠加pending
overview: 仅在 win 派奖流程中,对返回给第三方的余额叠加本局 pending 派奖,解决连续派奖期间玩家看到余额不增长的问题;不改其他接口。
todos:
- id: win-response-overlay
content: 仅在 win is_end=0 返回值叠加 pendingRoundAmount 到 w/withdraw
status: completed
- id: idempotent-display-check
content: 确认 dedupe 命中时返回余额不重复增长
status: completed
- id: final-settlement-regression
content: 确认 is_end=1 结算与 pending 清理行为不受影响
status: completed
isProject: false
---
# win 派奖返回余额叠加 pending仅 win 场景)
## 目标
在不改变真实账务入账时机(仍以 `is_end=1` Final Settlement 为准)的前提下,让第三方在连续派奖时拿到“可展示余额”:
- `is_end=0`:返回余额包含本局 pending
- 其他场景:不改
## 当前问题定位
在 [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php) 的 `win()` 中:
- `is_end=0` 已做 pending 累计
- 但返回值仍是 DB 真实余额(未叠加 pending导致第三方展示不变
## 最小改动方案
仅修改 [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php) 的 `win()`
1.`is_end=0` 分支中,累计完成后读取 `pendingRoundAmount = getPendingWinByRound()`
2. 返回给第三方时,将 `withdraw`/`w` 做展示叠加:
- `w = wallet.withdraw_balance + pendingRoundAmount`
- `withdraw = wallet.withdraw_balance + pendingRoundAmount`
3. `deposit` / `bonus` 保持真实值(不动)。
4. `is_end=1` 维持现有逻辑:合并 pending 后走真实结算并清理 pending。
## 兼容与风险
- 仅影响 `type=win` 的返回体,不影响 DB、Lot、流水、下注规则。
- 若第三方把 `w` 当“可立即下注余额”,会产生语义差异(展示值 > 真实可用);但你当前业务前提是第三方只负责展示。
## 验收点
- 连续 `is_end=0`:返回 `w/withdraw` 逐次增长(包含 pending
- 重复 `biz_id``is_end=0`:返回值不重复增长
- `is_end=1`返回值与真实结算后余额一致pending 被清理

View File

@@ -0,0 +1,91 @@
---
name: 中间派奖余额可见化
overview: 在保持 Final Settlement 真账口径不变的前提下,让 is_end=0 中间派奖实时反映到三方读取的余额(接口返回和钱包查询)。
todos:
- id: pending-win-keys
content: 设计并接入 pending round/total 与 dedupe Redis key
status: pending
- id: win-is-end-branch
content: 实现 is_end=0 累计展示、is_end=1 合并结算与清理
status: pending
- id: wallet-query-overlay
content: 让 wallet 查询叠加 pending total 返回最新展示余额
status: pending
- id: dto-validator-update
content: 补齐 is_end DTO 与 win round_id 校验
status: pending
- id: regression-cases
content: 验证多次派奖余额可见、最终结算、幂等与失败重试场景
status: pending
isProject: false
---
# 中间派奖余额可见化is_end改造计划
## 目标
解决“多次派奖时三方读取余额不更新”的问题,同时保持 `win.md` 的核心原则:
- 真正入账、Lot 回流、转化/解锁只在 `is_end=1`Final Settlement执行
- `is_end=0` 仅做中间派奖累计与展示余额更新
## 核心思路
在 Redis 维护“待结算派奖池Pending Win并把它叠加到对外返回余额
- DB 里的 `withdraw/deposit/bonus` 仍保持真实账
- 对外给三方的余额 = 真实账 + pendingWin
这样能保证:
- 三方实时看到余额变化
- 不破坏现有 `WinService` 的 Round Final Settlement 账务逻辑
## 具体改造
### 1) 新增 Pending Win 缓存层
修改 [app/service/RedisKeyManagerService.php](app/service/RedisKeyManagerService.php):新增 key
- `wallet:win:pending:round:{uid}:{currency}:{round_id}`(每局中间派奖累计)
- `wallet:win:pending:total:{uid}:{currency}`(用户币种维度累计,供快速读余额)
- `wallet:win:dedupe:{uid}:{currency}:{round_id}:{biz_id}`(中间派奖严格幂等)
### 2) win 分支行为
修改 [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php) 的 `win()`
- `is_end=0`
- 命中 dedupe 直接返回
- 未命中则 `INCRBY pending:round``INCRBY pending:total`
- 返回余额时把 `pending:total` 叠加到 `withdraw`(仅对外展示)
- 不调用 `WinService::execute`、不写 `BIZ_TYPE_WIN`
- `is_end=1`
- 读取 `pending:round``finalWin = fee + pendingRound`
-`finalWin` 调用现有 [app/service/wallet/WinService.php](app/service/wallet/WinService.php)
- 成功后清理 `pending:round` 并从 `pending:total` 扣除对应值
- 返回余额按真实账(此时 pending 已回收)
### 3) 钱包查询返回也要可见化
修改 [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php) 的 `query()` 返回:
-`pending:total` 叠加到返回字段 `w/withdraw`
- 这样第三方无论读 `win` 返回还是调用 `wallet` 查询,都看到“最新展示余额”
### 4) DTO/校验补齐
- 在 [app/api/dto/request/wallet/WalletUpdateRequestDTO.php](app/api/dto/request/wallet/WalletUpdateRequestDTO.php) 增加 `is_end`(默认 1
- 在 [app/validator/Wallet2Validator.php](app/validator/Wallet2Validator.php) 增加:
- `is_end` 仅允许 `0/1`
- `win` 场景强制 `round_id` 必传
## 一致性与幂等
- `is_end=0`Redis dedupebiz_id 级)防重复累计
- `is_end=1`:延续现有 `wallet_log(uid,biz_id,biz_type=win)` 幂等
- 若 Final 失败,不清 pending便于重试恢复
## 风险说明(需明确)
- 该方案是“展示余额实时、真账延后”:若上游把展示余额当可立即可下注余额,可能出现下注时余额校验不一致。
- 若你需要“中间派奖立即可下注”,则要走另一套方案(中间账临时入库 + Final 对冲重分配),改动会显著更大。
## 改动文件
- [app/service/RedisKeyManagerService.php](app/service/RedisKeyManagerService.php)
- [app/api/dto/request/wallet/WalletUpdateRequestDTO.php](app/api/dto/request/wallet/WalletUpdateRequestDTO.php)
- [app/validator/Wallet2Validator.php](app/validator/Wallet2Validator.php)
- [app/api/logic/WalletLogic.php](app/api/logic/WalletLogic.php)
## 验收场景
- 多次 `is_end=0` 后,`win` 返回余额递增
- 多次 `is_end=0` 后,`wallet` 查询余额递增
- `is_end=1` 触发后final 入账金额 = 中间累计 + 最后一笔
- 重放同一 `is_end=0` `biz_id` 不重复累计
- 重放同一 `is_end=1` `biz_id` 不重复结算

View File

@@ -0,0 +1,228 @@
---
name: 任务进度筛选对接
overview: 新增独立分页接口 `player-task/task-list`(筛选 task_type + status + page/page_size供前端双下拉表格使用保留现有 `player-task/task-progress` 全量快照不变。同步 slot_sdk 与 API 文档。
todos:
- id: wallet-model-paginate
content: WalletFundLotModel 新增 paginatePlayerTaskLots按 lot_type/status 分页)
status: completed
- id: wallet-task-list-api
content: 新增 taskList 全链路Validator/DTO/Logic/Service/Controller
status: completed
- id: sdk-task-list
content: slot_sdk 新增 taskList 请求/响应实体与 WalletService 方法
status: completed
- id: api-doc-list
content: 新增 doc/player-task-list-api.md含前端双下拉 + 分页说明)
status: completed
- id: feature-tests-list
content: Feature 测试筛选、分页、参数校验task-progress 回归不变
status: completed
isProject: false
---
# 玩家任务进度:新增分页列表接口
## 背景与目标
- **现有** [`player-task/task-progress`](doc/player-task-progress-api.md):只读全量快照(`bonus_tasks` + `deposit_tasks` + `summary`**不改契约**,继续给大厅/SDK 轻量查询用。
- **新增** `player-task/task-list`:面向 **前端操作页**(双下拉 + 表格),支持 **类型筛选、状态筛选、分页**
前端操作区:
| 下拉框 | 选项 | 说明 |
|--------|------|------|
| 任务类型 | **Bonus** / **Deposit** | 必填,二选一 |
| 状态 | **全部** + Waiting / Active / PendingConversion / PlayedOut | `status=0` 或不传 = 全部可见态 |
```mermaid
flowchart TB
subgraph keep [保持不变]
TP["POST task-progress"]
TP --> Full["bonus_tasks + deposit_tasks 全量"]
end
subgraph newApi [新增]
TL["POST task-list"]
TL --> Filter["task_type + status"]
TL --> Page["page + page_size"]
Page --> List["list + 分页元数据"]
end
UI[前端表格页] --> TL
Lobby[大厅轻量展示] --> TP
```
---
## 1. 新接口契约
### 1.1 路由
| 项 | 值 |
|----|-----|
| Method | `POST` |
| Path | `player-task/task-list` |
| Controller | [`PlayerTaskController::taskList`](app/api/controller/PlayerTaskController.php)(新增方法) |
### 1.2 请求参数
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `uid` | int | 是 | 用户 ID |
| `currency` | string | 是 | 币种,如 `TGO` |
| `task_type` | string | **是** | `bonus` \| `deposit` |
| `status` | int | 否 | `0` 或省略 = 全部玩家可见态;`1`/`2`/`3`/`5` = 单态 |
| `page` | int | 否 | 页码,默认 `1`,最小 `1` |
| `page_size` | int | 否 | 每页条数,默认 `20`,建议上限 `100` |
校验([`PlayerTaskValidator`](app/validator/PlayerTaskValidator.php) 新 scene `list`
- `uid``require|integer`
- `currency``require`
- `task_type``require|in:bonus,deposit`
- `status``integer|in:0,1,2,3,5`(可空,默认 0
- `page``integer|egt:1`(可空)
- `page_size``integer|between:1,100`(可空)
**前端映射**
| UI | 请求 |
|----|------|
| Bonus | `"task_type": "bonus"` |
| Deposit | `"task_type": "deposit"` |
| 全部 | 不传 `status``"status": 0` |
| Waiting / Active / … | `status` = `1` / `2` / `3` / `5` |
| 翻页 | 修改 `page`(切换筛选时重置 `page=1` |
### 1.3 成功响应 `data`
与仓库 [`SearchShardService`](app/service/search/shard/SearchShardService.php) 分页习惯对齐(`data``list`
```json
{
"task_type": "bonus",
"list": [ /* PlayerTaskItem task-progress 任务项字段相同 */ ],
"total": 15,
"page": 1,
"page_size": 20,
"last_page": 1
}
```
- `list[]` 元素结构 **复用** [`PlayerTaskItemEntity::toApiArray()`](app/entity/wallet/PlayerTaskItemEntity.php)(与 `task-progress` 单条一致)。
- 排序:`consume_priority_at ASC`, `lot_id ASC`(与现 [`listPlayerTaskLots`](app/model/multi/WalletFundLotModel.php) 一致)。
- 状态范围:默认仍为 PRD §26.1 四态 `[1,2,3,5]`**不含** Completed/Cancelled/Reversed`task-progress` 一致)。
### 1.4 与 `task-progress` 的分工
| 接口 | 场景 | 返回 |
|------|------|------|
| `task-progress` | 一次拿全量两类任务 + 计数汇总 | `summary` + `bonus_tasks` + `deposit_tasks` |
| `task-list` | 表格页:选定类型 + 状态 + 翻页 | 单维 `list` + 分页字段 |
---
## 2. wallet 实现要点
### 2.1 Model
在 [`WalletFundLotModel`](app/model/multi/WalletFundLotModel.php) 新增:
```php
/**
* 分页查询玩家任务 Lot。
* @return array{list: array, total: int, page: int, page_size: int, last_page: int}
*/
public function paginatePlayerTaskLots(
string $currency,
int $lotType,
array $statuses,
int $page,
int $pageSize
): array
```
- `statuses` 为空时直接返回空分页(与 `listPlayerTaskLots` 一致)。
- 使用 ThinkORM `paginate($pageSize, false, ['page' => $page])`,再将 `data` 重命名为 `list`
### 2.2 Service
在 [`PlayerTaskQueryService`](app/service/wallet/PlayerTaskQueryService.php) 新增 `queryTaskList(...)`
- `task_type` 字符串 → `LOT_TYPE_BONUS` / `LOT_TYPE_DEPOSIT`
- `status``PLAYER_VISIBLE_STATUSES` 或单元素数组
- 调用 Model 分页后,`buildTaskItems()` 组装 `list`
### 2.3 分层文件(新增/扩展)
| 层级 | 文件 |
|------|------|
| DTO | `app/api/dto/request/PlayerTaskListRequestDTO.php`(新建) |
| Validator | `PlayerTaskValidator` 增加 `SCENE_LIST` |
| Logic | `PlayerTaskLogic::taskList()` |
| Controller | `PlayerTaskController::taskList()` |
**不修改** `PlayerTaskProgressRequestDTO` / `taskProgress` 逻辑。
---
## 3. slot_sdk
新增(与 wallet 字段 snake_case 一致):
| 类型 | 文件 |
|------|------|
| 请求 | `PlayerTaskListRequestEntity.php` |
| 响应 | `PlayerTaskListResponseEntity.php`(含 `list``total``page``page_size``last_page``task_type` |
[`WalletService`](slot_sdk/src/service/wallet/WalletService.php) 新增:
```php
public function taskList(PlayerTaskListRequestEntity $entity): ?PlayerTaskListResponseEntity
// POST api/player-task/task-list
```
`taskProgress` / `PlayerTaskProgressRequestEntity` **保持不变**
发布slot_sdk commit → push → slot-wallet `composer update`
---
## 4. 文档
- **新建** [`doc/player-task-list-api.md`](doc/player-task-list-api.md):路由、入参、响应、前端双下拉 + 翻页交互、与 `task-progress` 对比、curl 示例。
- [`doc/player-task-progress-api.md`](doc/player-task-progress-api.md) 顶部增加「列表分页请用 task-list」交叉引用。
- [`doc/wallet.md`](doc/wallet.md) §26.1 补充 `task-list` 一行说明。
---
## 5. 测试
新建 `tests/Feature/PlayerTaskListTest.php`(造数参考 [`WalletRegisterBetWinTest`](tests/Feature/WalletRegisterBetWinTest.php)
| 用例 | 断言 |
|------|------|
| `task_type=bonus` + 默认分页 | `list` 非空项字段完整;`total >= len(list)` |
| `status=2` | `list``status` 均为 2 |
| `page=2` + `page_size=1` | 第二页与总数一致 |
| 缺 `task_type` | `40003` |
| `page_size=101` | `40003` |
| `task-progress` 仍返回双数组 | 回归,不受新接口影响 |
容器内执行:`docker compose exec -T php82` + 项目 PHPUnit 命令。
---
## 6. 不在本次范围
- 修改 `task-progress` 入参或响应
- 任务类型「全部」合并在一个列表
- 运营后台终态Completed 等)纳入筛选
- 前端页面实现(本仓库无前端)
---
## 7. 关键文件一览
| 仓库 | 变更 |
|------|------|
| slot-wallet | `PlayerTaskController``PlayerTaskLogic``PlayerTaskQueryService``WalletFundLotModel``PlayerTaskValidator`、新 DTO、`doc/player-task-list-api.md`、Feature 测试 |
| slot_sdk | 新 Entity ×2、`WalletService::taskList``readme.md` |

View File

@@ -0,0 +1,115 @@
---
name: 修复第一档绑卡类型
overview: "`WithdrawService::apply` 在 Free Credits 第一档(`package_id > 0`)分支把 `checkBankInfo` 返回的收款配置数组传给了需要 `UserBankCardModel``applyFreeCreditsFirstCashout`,导致 PHP 8 类型错误且 Pay 载荷取不到 `btc/usdt/email` 等字段。"
todos:
- id: refactor-checkBankInfo-return
content: WithdrawService::checkBankInfo 返回 ['model' => UserBankCardModel, 'config' => itemConfig] 并补 PHPDoc
status: cancelled
- id: fix-apply-branch
content: WithdrawService::apply 解构返回值FC 传 model普通提现用 config
status: cancelled
- id: run-integration-test
content: php82 跑 FreeCreditsFirstCashoutApplyTest 验证无 TypeError
status: completed
isProject: false
---
# 修复第一档提现绑卡类型不匹配
## 问题
[`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) 中:
```171:175:slot_console/app/service/WithdrawService.php
$bankInfo = $this->checkBankInfo($applyDTO);
if ($applyDTO->package_id > 0) {
return $this->applyFreeCreditsFirstCashout($applyDTO, $bankInfo);
}
```
[`checkBankInfo`](slot_console/app/service/WithdrawService.php) 在持久化 `UserBankCardModel` 后 **返回的是按支付类型整理的 `$itemConfig` 数组**(约 353 行 `return $itemConfig`),供普通提现填 `WithdrawalInfo`
```216:219:slot_console/app/service/WithdrawService.php
$withdrawalInfo->account = $bankInfo['account'];
$withdrawalInfo->address = $bankInfo['account'];
$withdrawalInfo->userName = $bankInfo['user_name'] ?? $userTag->uid;
$withdrawalInfo->card_number = $bankInfo['card_number'] ?? '';
```
而 [`applyFreeCreditsFirstCashout`](slot_console/app/service/WithdrawService.php) / [`FreeCreditsLogic::buildFirstCashoutWithdrawalInfo`](slot_console/app/api/logic/FreeCreditsLogic.php) 签名要求 **`UserBankCardModel`**,并读取模型字段:
```490:507:slot_console/app/api/logic/FreeCreditsLogic.php
'userName' => $post['user_name'] ?? $bank->user_name ?? strval($uid),
'account' => empty($bank->card_number) ? strval($uid) : $bank->card_number,
...
if ($payType === 2) {
$withdrawalInfo->address = $bank->btc ?? '';
} elseif ($payType === 3) {
$withdrawalInfo->address = $bank->usdt ?? '';
} elseif ($payType === 6) {
$withdrawalInfo->userName = trim($bank->paypal_first_name . ' ' . $bank->paypal_last_name);
$withdrawalInfo->account = $bank->email ?? '';
```
因此第一档路径在 PHP 8 下会触发 `TypeError`;即便未开严格类型,用数组当对象也会导致 Pay 下单字段错误。
```mermaid
flowchart LR
apply[WithdrawService.apply]
checkBank[checkBankInfo]
saveModel[save UserBankCardModel]
returnConfig["return itemConfig array"]
fcBranch[package_id greater than 0]
normalBranch[normal withdraw]
fcMethod[applyFreeCreditsFirstCashout]
logic[buildFirstCashoutWithdrawalInfo]
apply --> checkBank
checkBank --> saveModel --> returnConfig
returnConfig --> fcBranch
returnConfig --> normalBranch
fcBranch -->|"wrong: array"| fcMethod
fcMethod --> logic
logic -->|"expects UserBankCardModel"| modelFields[bank.card_number btc usdt]
```
## 推荐改法(单文件、无二次查询)
在 [`WithdrawService.php`](slot_console/app/service/WithdrawService.php) 调整 `checkBankInfo` 返回值,使 `apply` 能同时满足两条路径:
1. **`checkBankInfo` 返回结构体**(关联数组即可,无需新 DTO
- `model` → 已 `save()` 的 `UserBankCardModel`
- `config` → 现有 `$itemConfig`(普通提现继续用)
2. **`apply` 解构**
- `package_id > 0` → `applyFreeCreditsFirstCashout($applyDTO, $bank['model'])`
- 否则 → `$bankConfig = $bank['config']`,后续 `account` / `user_name` / `card_number` 逻辑不变
3. **补 PHPDoc**(符合 [`php-doc.mdc`](/Users/ray/.cursor/rules/php-doc.mdc)
- `checkBankInfo`: `@return array{model: UserBankCardModel, config: array<string, mixed>}`
- `applyFreeCreditsFirstCashout`: 保持现有 `@param UserBankCardModel`
**不改动** `FreeCreditsLogic`:其契约(绑卡已由 `WithdrawService` 写入后传入模型)是正确的。
### 备选(更小 diff多一次查询
若不想改 `checkBankInfo` 签名,可在 `package_id > 0` 分支先调用 `checkBankInfo` 落库,再 `find($uid)` 取模型。可行但冗余,不推荐。
## 测试
- 现有集成测 [`FreeCreditsFirstCashoutApplyTest`](slot_console/tests/Integration/FreeCreditsFirstCashoutApplyTest.php) 已走 `WithdrawService::apply` + `package_id`;修复后应能越过 `TypeError`,继续表现为 Pay 不可用时的 `Throwable` / 余额校验未触发。
- 修复后在 `php82` 容器执行:
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Integration/FreeCreditsFirstCashoutApplyTest.php
```
(需 `RUN_DB_TESTS=1` 时按项目惯例配置环境变量。)
可选:在 `tests/Unit` 增加轻量测试,用反射调用 `checkBankInfo` 断言返回含 `model` 与 `config` 键——非必须,集成测已覆盖主路径。
## 验收
1. 第一档 `POST /api/withdraw/apply` + `package_id` 不再出现 `UserBankCardModel` 类型错误。
2. 普通提现(无 `package_id`)行为与字段映射不变。
3. Pay 下单能正确带上 `card_number` / `btc` / `usdt` / PayPal 等模型字段(与 `checkBankInfo` 写入一致)。

View File

@@ -0,0 +1,202 @@
---
name: 充值用户 Redis 集合
overview: 在 center 注册共享 Redis key `userRecharged`wallet 侧一次性脚本从分片 `wallet_stat` 回填历史充值用户,并在 `recharge` / `rechargeSign` 成功后实时 SADD 到共享 Redis 集合。
todos:
- id: center-key
content: slot_center sharerediskey.php 增加 userRecharged => user:recharged
status: completed
- id: wallet-service
content: walletRedisKeyManagerService + RechargedUidSetServiceshare Redis SADD
status: completed
- id: wallet-hook
content: WalletLogic::recharge / rechargeSign 成功后调用 RechargedUidSetService::add
status: completed
- id: backfill-cmd
content: 新建 backfillRechargedUidSet 命令:扫 wallet_stat 分片、--dry-run/--rebuild/--batch-size
status: completed
- id: tests
content: 补充 Service/集成测试或 dry-run 验证说明
status: completed
isProject: false
---
# Wallet 充值用户共享 Redis 集合
## 目标
维护一个**全局、持久**的共享 Redis Set成员为「曾成功充值」的用户 uid口径`wallet_stat.total_deposit > 0`,与 [每日返水需求](file:///Users/ray/.cursor/plans/每日返水需求文档_20a03147.plan.md) 中「充值用户」一致)。供后续活动(如每日返水结算)用 `SISMEMBER` 快速判断,避免逐用户查库。
你已确认:
- Key`userRecharged` → Redis `user:recharged`
- `recharge``rechargeSign` 均写入集合
---
## 架构与数据流
```mermaid
flowchart LR
subgraph center [slot_center]
Sharerediskey["config/sharerediskey.php"]
ShareConfigAPI["/innerapi/share-config/all"]
end
subgraph wallet [slot_wallet]
BackfillCmd["BackfillRechargedUidSet command"]
WalletLogic["WalletLogic::recharge / rechargeSign"]
RechargedSvc["RechargedUidSetService"]
StatDB["wallet_XX.wallet_stat_YY"]
end
ShareRedis["Redis connection share"]
Sharerediskey --> ShareConfigAPI
ShareConfigAPI --> RechargedSvc
BackfillCmd --> StatDB
BackfillCmd --> RechargedSvc
WalletLogic --> RechargedSvc
RechargedSvc --> ShareRedis
```
---
## 1. Center注册共享 Key
**文件**[`slot_center/config/sharerediskey.php`](slot_center/config/sharerediskey.php)
在数组末尾增加(含注释):
```php
'userRecharged' => 'user:recharged', // set曾充值用户 uidwallet 维护)
```
该文件已通过 [`ShareConfigController::all()`](slot_center/app/innerapi/controller/ShareConfigController.php) 下发为 `sharerediskey.*`,各服务经 `ShareConfigService::get('sharerediskey.userRecharged')` 读取。
**部署顺序**:先发布 center再发布 wallet否则 wallet 会落到 fallback 默认值key 不一致)。
---
## 2. WalletKey 解析与写入封装
### 2.1 `RedisKeyManagerService`
**文件**[`slot_wallet/app/service/RedisKeyManagerService.php`](slot_wallet/app/service/RedisKeyManagerService.php)
新增方法(与现有 `sourceRealDataKey` 一致,从 center 取 key
```php
public static function getUserRechargedSetKey(): string
{
return ShareConfigService::get('sharerediskey.userRecharged', 'user:recharged');
}
```
### 2.2 新建 `RechargedUidSetService`
**路径**`slot_wallet/app/service/wallet/RechargedUidSetService.php`
职责(公共能力,避免 Logic 直接拼 Redis
- `add(int $uid): void``Redis::connection('share')->sAdd($key, (string)$uid)`
- key 来自 `RedisKeyManagerService::getUserRechargedSetKey()`
- 失败只 `LoggerService::error`,不抛异常(与 `WalletLogic::recharge()``sendConsoleBus` / `createTask` 等非阻断副作用一致)
- **不设 expire**(成员为永久「曾充值」标记;`SADD` 幂等)
参考:[`slot_risk`](slot_risk/app/service/api/BlackUserService.php) 对 `connection('share')->sAdd` 的用法;[`config/redis.php`](slot_wallet/config/redis.php) 已配置 `share` 连接。
---
## 3. 充值链路:实时写入
**文件**[`slot_wallet/app/api/logic/WalletLogic.php`](slot_wallet/app/api/logic/WalletLogic.php)
在以下两处、`total_deposit` 递增且 `RechargeExchangeService::recharge()` 之后,调用 `RechargedUidSetService::add($uid)`
| 方法 | 约行号 | 说明 |
|------|--------|------|
| `recharge()` | ~782 后 | 正常支付充值 |
| `rechargeSign()` | ~820 后 | 签到购,累加统计 |
放在现有 `try/catch` 块内即可,无需单独 try。
**不扩展范围**:全库仅这两处递增 `total_deposit`(已 grep 确认),无需 hook `inc()` 或其它 type。
---
## 4. 一次性回填脚本
### 4.1 Webman Command
**路径**`slot_wallet/app/command/BackfillRechargedUidSet.php`
- `$defaultName = 'backfillRechargedUidSet'`
- 结构参考 [`CleanupWalletLog.php`](slot_wallet/app/command/CleanupWalletLog.php)`LoggerService::initGenerateTraceId`、stdout 汇总、异常返回 `FAILURE`
**选项**
| Option | 作用 |
|--------|------|
| `--dry-run` | 只统计 uid 数量,不写 Redis |
| `--rebuild` | 执行前 `DEL` 目标 set全量重建默认增量 SADD可重复跑 |
| `--batch-size` | 每批 `SADD` 成员数,默认 500 |
### 4.2 扫描逻辑
按 [`InitDB::initWalletStat`](slot_wallet/app/command/InitDB.php) 的分片循环:
```php
for ($i = 1; $i <= $shardInfo['database_num']; $i++) {
$db = sprintf('wallet_%02d', $i);
for ($j = 1; $j <= $shardInfo['table_num']; $j++) {
$table = "{$db}.wallet_stat_" . sprintf('%02d', $j);
// SELECT DISTINCT uid FROM {$table} WHERE total_deposit > 0
}
}
```
- 使用 `DISTINCT uid`(表主键 `(uid, currency)`,多币种各一行,同一 uid 只入 set 一次)
- 分批 `sAdd($key, ...$uids)` 写入 `connection('share')`
- 输出:每表行数、总 uid 数、最终 `SCARD`
### 4.3 执行命令Docker
```bash
docker exec -w /app/www/slot/slot_wallet php82 php webman backfillRechargedUidSet --rebuild
# 预检
docker exec -w /app/www/slot/slot_wallet php82 php webman backfillRechargedUidSet --dry-run
```
**建议上线步骤**
1. 发布 center`userRecharged` 配置)
2. 发布 walletService + Logic + Command
3. 低峰执行 `backfillRechargedUidSet --rebuild`(或先 `--dry-run` 核对数量)
4. 之后新充值由 Logic 自动 `SADD`
---
## 5. 测试wallet 仓库)
| 项 | 方式 |
|----|------|
| `RechargedUidSetService` | 单元测试 mock `Redis::connection('share')`,或集成测试用真实 share Redis若 CI 有) |
| `WalletLogic` | 在现有 [`WalletControllerUpdateTest`](slot_wallet/tests/Integration/WalletControllerUpdateTest.php) / recharge 相关用例后断言 set 含 uid可选依赖 share Redis |
| Command | 本地 `--dry-run` + 小环境 `--rebuild``SCARD` 抽样 `SISMEMBER` |
至少保证Service 在 key 存在时调用 `sAdd` 且不抛异常Command SQL 与分片配置可读。
---
## 6. 不在本次范围(供后续)
- **消费方**(如 `slot_console` 每日返水 cron改为 `SISMEMBER`:需在对应服务增加 `ShareConfigService::get('sharerediskey.userRecharged')` + `connection('share')`,本次仅 wallet 写入。
- center 本地 [`ShareRedisKeyManagerService`](slot_center/app/service/ShareRedisKeyManagerService.php) 可不增方法wallet 直接用 `RedisKeyManagerService` + `ShareConfigService`,与现有 `sourceReal` 一致)。
---
## 涉及文件一览
| 仓库 | 文件 | 变更 |
|------|------|------|
| slot_center | `config/sharerediskey.php` | +1 key |
| slot_wallet | `app/service/RedisKeyManagerService.php` | +`getUserRechargedSetKey()` |
| slot_wallet | `app/service/wallet/RechargedUidSetService.php` | 新建 |
| slot_wallet | `app/api/logic/WalletLogic.php` | recharge / rechargeSign 各 +1 行调用 |
| slot_wallet | `app/command/BackfillRechargedUidSet.php` | 新建一次性脚本 |

View File

@@ -0,0 +1,123 @@
---
name: 定格冻结用户流水
overview: 在 slot_wallet 的 `freeCreditsFreeze` 成功扣款后,补充 `addTransactionRecord` 写入 C 端可见的 `user_transaction_log`;并在 slot_pwa 注册新 `transaction_type` 与列表筛选映射。
todos:
- id: wallet-add-transaction
content: WalletLogic::freeCreditsFreeze 增加 addTransactionRecordtype=65负金额
status: pending
- id: pwa-type-map
content: slot_pwa UserTransactionLogService 注册 type 65 与展示名
status: pending
- id: wallet-unit-test
content: wallet 单测断言 freeCreditsFreeze 写入用户流水
status: pending
- id: run-phpunit
content: php82 跑 wallet 相关单测验证
status: pending
isProject: false
---
# freeCreditsFreeze 补充用户可见流水
## 问题
[`WalletLogic::freeCreditsFreeze()`](slot_wallet/app/api/logic/WalletLogic.php) 当前仅:
1. `minus()` 扣减 deposit/withdraw
2. [`addLog()`](slot_wallet/app/api/logic/WalletLogic.php) 写入分表 **wallet_log**`biz_type=freeCreditsFreeze`
**未**调用 [`addTransactionRecord()`](slot_wallet/app/api/logic/WalletLogic.php),因此 MQ 不会投递到 PWA用户在 **Transaction / 流水页** 看不到定格扣款记录。
对比:普通提现冻结在同文件内已有:
```402:402:slot_wallet/app/api/logic/WalletLogic.php
$this->addTransactionRecord('withdraw frozen', $this->requestDTO->fee * -1, $balance, 7);
```
需求文档 §15.2 要求「必须写钱包流水」——`wallet_log` 已满足后台审计;本需求补齐 **C 端展示流水**。
```mermaid
flowchart LR
freeze[freeCreditsFreeze]
addLog[addLog wallet_log]
addTx[addTransactionRecord]
mq[PWA MQ]
ui[用户流水列表]
freeze --> addLog
freeze --> addTx --> mq --> ui
```
说明:`freeCreditsFreeze` 是**扣款**(金额为负),不是入账;用户列表里应显示为负向变动(与 type 7「Withdraw」冻结类似
## 实现方案slot_wallet + slot_pwa
### 1. slot_wallet — `freeCreditsFreeze` 增加用户流水
文件:[`WalletLogic.php`](slot_wallet/app/api/logic/WalletLogic.php)
在事务内、`addLog` 成功之后、`commit` 之前增加(与 `withdrawFrozen` 一致,失败仅记日志不回滚主事务):
```php
$this->addTransactionRecord(
'Free Credits Freeze',
$this->requestDTO->fee * -1,
$balance,
self::TRANSACTION_TYPE_FREE_CREDITS_FREEZE // 新常量,建议 65
);
```
在 `WalletLogic` 类内新增常量(或集中放在与 PWA 对齐的注释块):
```php
/** 用户流水类型:首充定格冻结扣款,对应 freeCreditsFreeze */
private const TRANSACTION_TYPE_FREE_CREDITS_FREEZE = 65;
```
字段约定(与现有 `addTransactionRecord` 一致):
| 字段 | 值 |
| --- | --- |
| `subject` | `Free Credits Freeze`C 端 `name` 映射用) |
| `amount` | `-fee`(千分位,负数为扣款) |
| `balance_after` | 扣款后 `deposit + withdraw` 总余额 |
| `transaction_id` | 已有规则:`substr(uid,0,3) + biz_id`(幂等键 `free_credits_freeze:{orderId}` |
| `transaction_type` | `65` |
**不**改 `addLog` / 余额扣减逻辑;**不**改 slot_console / slot_lib。
### 2. slot_pwa — 注册 transaction_type=65
文件:[`UserTransactionLogService.php`](slot_pwa/app/service/UserTransactionLogService.php)
- `$typeMaps[-1]`All增加 `65`
- `$typeMaps[1]`Withdraw 分类,已含 7/8增加 `65`(定格扣款与提现类支出归同一 tab可按产品再调
- `$typeToClient[65] = 'Free Credits Freeze'`(或简短文案 `Free Credits`
可选:在 [`UserTransactionLogShardModel.php`](slot_pwa/app/model/shard/UserTransactionLogShardModel.php) 增加 `TRANSACTION_TYPE_FREE_CREDITS_FREEZE = 65` 常量与注释wallet 侧用相同数字wallet 不依赖 pwa 类,注释对齐即可)。
### 3. 单测
| 仓库 | 文件 | 内容 |
| --- | --- | --- |
| slot_wallet | 扩展 [`WalletLogicHarness`](slot_wallet/tests/Support/WalletLogicHarness.php) 或新建用例 | 覆写/桩 `addTransactionRecord`,断言 `freeCreditsFreeze` 成功后调用一次,且 `amount < 0`、`type=65` |
| slot_wallet | 现有 freeze 相关测 | 回归通过 |
运行:
```bash
docker exec -w /app/www/slot/slot_wallet php82 ./vendor/bin/phpunit tests/Unit/WalletFreeCreditsFirstKeepTest.php tests/Unit/WalletFreeCreditInitBusTest.php
# 若有新增 FreezeTransactionTest 则一并跑
```
## 不在本次范围
- `freeCreditsClaim` / `freeCreditsFirstCashKeep` 的用户流水(若也要在流水页展示入账,可另开任务,同样走 `addTransactionRecord` + 新 type
- 修改 `wallet_log` 表结构
- C 端 UI 文案多语言(仅后端 `name` 英文字段)
## 验收
1. 首充定格成功后用户流水列表All / Withdraw出现一条 **负金额** 记录,`name` 为 Free Credits Freeze
2. `transaction_id` 与 `biz_id``free_credits_freeze:{orderId}`可追溯
3. 幂等重试 freezewallet_log 幂等不变 MQ 重复需依赖现有消费端去重 type 7 行为一致本次不单独改 MQ
4. wallet 单测通过

View File

@@ -0,0 +1,95 @@
---
name: 定格列表盈利阈值
overview: 在管理后台首充定格统计列表中,按当前页 UID 调用与 game/pop/index 相同的养客配置接口PopService → slot_pwa getDPByPlayers为每行补充 profitthreshold及可选 hasprofitthreshold字段前端新增对应列。
todos:
- id: pop-service-batch
content: PopService 新增 mapDevelopPlayerByUids按 uid 调用 getPageList 并容错
status: completed
- id: logic-enrich
content: FreeCreditsStatsLogic.list 合并 profitthreshold及可选 hasprofitthreshold
status: completed
- id: vue-columns
content: freeCreditsStats/index.vue 增加盈利阈值列与展示格式化
status: completed
- id: verify-pop-parity
content: 抽 12 个 UID 对比养客配置页与定格列表字段一致
status: completed
isProject: false
---
# 首充定格列表增加盈利阈值
## 背景
- 首充定格统计已迁入 [`slot_admin`](backend/slot_admin/app/game/logic/FreeCreditsStatsLogic.php),接口 `GET /game/freeCreditsStats/index` 返回列表 + 顶部汇总。
- 养客配置页 [`game/pop/index`](backend/slot_admin_vue/src/views/game/pop/config/index.vue) 通过 [`PopController::index`](backend/slot_admin/app/game/controller/PopController.php) → [`PopService::getPageList`](backend/slot_admin/app/service/game/PopService.php) → `slot_pwa` `pop/admin/getDPByPlayers` → 游戏侧 `/api/v2/dp/getDPByPlayers`,列表含 `profitthreshold``hasprofitthreshold` 等字段。
- 需求文档 §18.2 未写盈利阈值,属运营侧补充字段;数据源应与养客页一致,**不用** `UserTag.popBalance` 等本地 tag。
## 数据流
```mermaid
flowchart LR
vue[freeCreditsStats/index.vue] --> api["/game/freeCreditsStats/index"]
api --> logic[FreeCreditsStatsLogic.list]
logic --> db[(s_common free_credits_*)]
logic --> wallet[WalletService statistic]
logic --> pop[PopService getPageList]
pop --> pwa[slot_pwa getDPByPlayers]
pwa --> game["/api/v2/dp/getDPByPlayers"]
```
## 实现要点
### 1. PopService按 UID 批量取养客配置(公共封装)
在 [`PopService.php`](backend/slot_admin/app/service/game/PopService.php) 新增方法,例如 `mapDevelopPlayerByUids(array $uids): array<int, array>`
- 对每个 `uid` 调用现有 `getPageList(['userid' => $uid, 'page' => 1, 'limit' => 1])`(与 pop/index 单用户筛选一致;`starttime`/`endtime` 不传,与养客页默认「全量」查询一致)。
- 从返回 `data[0]` 取行;无记录或接口异常时该 uid 映射为 `null`Logic 展示 `-` / 空)。
- 单 uid 失败只 `logger()->warning`**不**拖垮整页列表(与 `fetchTotalRecharge` 容错一致)。
**说明**`getDPByPlayers` 仅支持单 `userid`,每页最多 100 条 ≈ 最多 100 次 HTTP经 pwa 到游戏 API。当前 `limit` 上限 100可接受若后续性能不足再与游戏侧协商批量接口不在本次范围。
### 2. FreeCreditsStatsLogic列表富化
在 [`FreeCreditsStatsLogic::list`](backend/slot_admin/app/game/logic/FreeCreditsStatsLogic.php) 中,在已有 `fetchTotalRecharge` 之后:
- 调用 `PopService` 批量映射;
- 为每行写入(字段名与 pop/index 保持一致,便于前端复用展示逻辑):
- `profitthreshold``int|null`,配置值(养客页原样展示,**不**除 10000
- 若产品确认需要第二列:`hasprofitthreshold`,游戏侧为千分位类整数,展示时与养客页 [`toNum`](backend/slot_admin_vue/src/views/game/pop/config/index.vue) 一致:`Number((val / 10000).toFixed(2))`
无养客配置的用户:`profitthreshold``null`,前端显示 `-`
**分层**:跨服务 HTTP 封装在 `PopService``FreeCreditsStatsLogic` 只编排,不直接拼 `pwaApiHost` URL。
### 3. 前端:新增列
修改 [`freeCreditsStats/index.vue`](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue)
- 在「累计充值金额」后(或渠道后)增加列:
- `盈利阈值``profitthreshold`
- (若选两列)`已达盈利阈值``hasprofitthreshold`slot 内 `/10000` 格式化
- 无自定义 slot 时直接显示;`null`/`undefined` 显示 `-`
路由、API 文件 [`freeCreditsStats.js`](backend/slot_admin_vue/src/api/game/freeCreditsStats.js) **不变**
### 4. 不改动的部分
- `statistics` 顶部 7 项汇总不加盈利阈值(无聚合口径)。
- Validate / DTO 无新筛选项。
- `slot_console` innerapi、`slot_sdk` 无需改动(统计已在 admin
## 验证
- 某 UID 在「养客配置」页能查到 `profitthreshold` 时,定格列表同 UID 显示相同值。
- 无养客配置 UID 显示 `-`,列表其余字段正常。
- pop/pwa 不可用时:列表仍可出数,盈利阈值为空;日志有 warning。
- 与迁前对比:除新增列外,筛选、排序、汇总、累计充值口径不变。
## 涉及文件
| 仓库 | 文件 |
|------|------|
| slot_admin | [`PopService.php`](backend/slot_admin/app/service/game/PopService.php)、[`FreeCreditsStatsLogic.php`](backend/slot_admin/app/game/logic/FreeCreditsStatsLogic.php) |
| slot_admin_vue | [`freeCreditsStats/index.vue`](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue) |

View File

@@ -0,0 +1,108 @@
---
name: 定格统计迁入 admin
overview: 将「首充剩余定格列表」的查询与汇总逻辑从 slot_console innerapi 迁入 slot_admin与用户列表等后台能力一致直连 s_common + WalletService前端路由不变。
todos:
- id: add-models
content: 在 slot_admin 新增 FreeCreditsPlayer/Package Models_common
status: completed
- id: migrate-logic
content: 迁入 FreeCreditsStatsLogic + Validate/DTOfetchTotalRecharge 改用 WalletService + UserModel
status: completed
- id: wire-controller
content: 改造 FreeCreditsStatsController移除 ConsoleClient 透传
status: completed
- id: cleanup-bff
content: 删除 console innerapi 与 slot_sdk ConsoleService 中 freeCredits* 方法
status: in_progress
- id: verify-api
content: 对比迁前后列表/汇总与用户列表充值金额口径
status: pending
isProject: false
---
# 首充定格统计迁入 slot_admin去掉 console BFF
## 结论:建议做
当前链路多一跳、职责错位:
```mermaid
flowchart LR
vue[slot_admin_vue] --> admin[slot_admin FreeCreditsStatsController]
admin --> sdk[slot_sdk ConsoleClient]
sdk --> console[slot_console innerapi]
console --> db[(s_common free_credits_*)]
console --> wallet[slot_wallet statistics]
```
[`FreeCreditsStatsController.php`](backend/slot_admin/app/game/controller/FreeCreditsStatsController.php) 注释已写明「仅透传 slot_console innerapi」[`FreeCreditsController`](slot_console/app/innerapi/controller/FreeCreditsController.php) 也标注「仅供 slot_admin 代理调用」——本质是 **admin 专用 BFF**,与 C 端 `FreeCreditsLogic` 无关。
同仓库内后台惯例是 **admin 自己查库 + 按需调钱包**,例如:
- [`UserLogic`](backend/slot_admin/app/game/logic/UserLogic.php)`WalletService::statistic` + `getNumberFormat`
- [`UserProfitDailyLogic`](backend/slot_admin/app/game/logic/UserProfitDailyLogic.php):同上
- [`RechargeGiftPlayerLogic`](backend/slot_admin/app/game/logic/RechargeGiftPlayerLogic.php):直连 `s_common` 活动表
`slot_admin` 已配置 [`s_common`](backend/slot_admin/config/thinkorm.php) 连接,具备迁回条件。
目标链路:
```mermaid
flowchart LR
vue[slot_admin_vue] --> admin[slot_admin Controller]
admin --> logic[FreeCreditsStatsLogic]
logic --> db[(s_common)]
logic --> wallet[WalletService statistic]
```
**不迁**C 端定格/提现/领取仍在 [`slot_console` `FreeCreditsLogic`](slot_console/app/api/logic/FreeCreditsLogic.php),避免动业务写路径。
---
## 实施范围
### 1. slot_admin 新增分层(对齐 backend-layering
| 层 | 文件 | 职责 |
|---|---|---|
| Validate | `app/game/validate/FreeCreditsStatsValidate.php` | `page/limit`、筛选字段类型与枚举 |
| DTO | `app/game/dto/FreeCreditsStatsQueryDTO.php` | 承接已校验参数 |
| Logic | `app/game/logic/FreeCreditsStatsLogic.php` | 从 console 迁入(以当前 [`FreeCreditsStatsLogic.php`](slot_console/app/innerapi/logic/FreeCreditsStatsLogic.php) 为基准,含近期前端改动:无 activity_id/status 筛选、`total_deposit` 按用户 currency + `getNumberFormat` |
| Model | `app/game/model/common/FreeCreditsPlayerModel.php``FreeCreditsPackageModel.php` | `connection = s_common`,表名与 console 一致,常量 `STATUS_*` / `TYPE_*` 复制 |
| Controller | 改造 [`FreeCreditsStatsController.php`](backend/slot_admin/app/game/controller/FreeCreditsStatsController.php) | Validate → DTO → Logic`index` 一次返回 `data/total/otherData`Logic 内 `statistics` + `list` |
**累计充值**:在 admin Logic 内用已有 [`WalletService::statistic`](backend/slot_admin/app/service/WalletService.php) + [`UserModel`](backend/slot_admin/app/model/UserModel.php) 按用户 `currency` 分组(与用户列表口径一致),**不再**经 console 调 wallet SDK。
**对外契约**:保持 `GET/POST /game/freeCreditsStats/index` 与字段名不变 → [`slot_admin_vue`](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue) **无需改路由**
### 2. 从 console / SDK 下线 admin 专用 BFF
迁移验证通过后删除或标记废弃:
- [`slot_console/app/innerapi/logic/FreeCreditsStatsLogic.php`](slot_console/app/innerapi/logic/FreeCreditsStatsLogic.php)
- [`slot_console/app/innerapi/controller/FreeCreditsController.php`](slot_console/app/innerapi/controller/FreeCreditsController.php)(若仅服务 stats
- [`slot_sdk` `ConsoleService::freeCreditsList/statistics`](slot_sdk/src/service/console/ConsoleService.php)
- admin Controller 中 `ConsoleClient` / `buildConsoleClient`
### 3. 不做的项
- 不把 C 端 `FreeCreditsLogic` 抽到 `slot_lib`(范围过大,与本次 admin 统计无关)
- 不为两张表单独建 Service 中转层Logic 内私有方法即可)
---
## 风险与注意点
1. **DB 权限**:确认 `slot_admin` 运行账号对 `s_common.free_credits_player/package` 有读权限(与 console 相同库)。
2. **双份 Model**console 仍保留 C 端用的 Modeladmin 侧独立 Model 类,表结构变更需两处同步常量(可接受,与现有 `RechargeGift*` 模式一致)。
3. **一次请求两次查询**`statistics` + `list` 仍在单次 `index` 内完成;若后续数据量大,再在 Logic 内优化共用 `filteredPlayerIds`,不必回退 BFF。
4. **UserProfitDaily 的 currency**:其用 `GameServerModel` 固定币种;本功能应继续用 **每用户 `UserModel.currency`**(与 `UserLogic` 一致),避免回退到固定 USD。
---
## 验证清单
- 列表筛选uid/source/定格时间/第一档状态/全部完成)与汇总 7 项与迁前一致
- 某 UID「累计充值」与后台用户列表 `wallet.r` / `total_deposit` 一致
- 去掉对 `consoleApiHost` 的依赖后,`/game/freeCreditsStats/index` 仍可正常访问
- console innerapi 无其它调用方后再删 SDK 方法

View File

@@ -0,0 +1,227 @@
---
name: 异步事件单元测试
overview: 结合 slot_console 现有 Free Credits 测试实践,说明异步 MQ 事件应分层测试:不直连 RabbitMQ优先测 Logic 编排,再补 Event 解析与 EventBus 路由;并给出可新增的示例用例结构。
todos:
- id: entity-test
content: 新增 FreeCreditInitEntityTest字段映射与 resolveWalletAmount 回退
status: completed
- id: bus-factory
content: 新增 tests/Support/ConsoleBusMessageFactory 统一构造 MQBusEntity 载荷
status: completed
- id: extend-logic-tests
content: 扩展 FreeCreditsHandleFreeCreditInitTest幂等、无配置、异常路径
status: completed
- id: optional-event-inject
content: 可选FreeCreditInitEvent 注入 Logic + Event 单测
status: completed
- id: optional-eventbus-nack
content: 可选EventBus deal 单测TYPE_FREE_CREDIT_INIT 成功 ack / 失败 nack
status: completed
isProject: false
---
# 异步事件单元测试写法slot_console
## 当前架构
```mermaid
sequenceDiagram
participant Wallet as slot_wallet
participant MQ as RabbitMQ_console_bus
participant Bus as EventBus_deal
participant Ev as FreeCreditInitEvent
participant Logic as FreeCreditsLogic
Wallet->>MQ: JSON uid/type/data
MQ->>Bus: AMQPMessage
Bus->>Ev: case TYPE_FREE_CREDIT_INIT
Ev->>Ev: FreeCreditInitEntity
Ev->>Logic: handleFreeCreditInit
Logic->>Logic: freezeFirstRecharge + wallet RPC
```
**结论**:异步只发生在 MQ 传输层;单测应测 **消息解析 → 路由 → 业务编排**,而不是启动真实 `event:bus` 或 RabbitMQ。
---
## 项目里已有的做法(推荐延续)
### 1. 单元测试:直接测 Logic主路径
现有 [`tests/Unit/FreeCreditsHandleFreeCreditInitTest.php`](/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsHandleFreeCreditInitTest.php) 已覆盖 `free_credit_init` 的**业务结果**,等价于事件消费后的效果:
- [`FreeCreditsLogicHarness`](/Users/ray/Documents/project/www/slot/slot_console/tests/Support/FreeCreditsLogicHarness.php):注入 `userContext` / `activeConfig` / `walletService`
- [`RecordingWalletService`](/Users/ray/Documents/project/www/slot/slot_console/tests/Support/RecordingWalletService.php):记录 `freeCreditsFreeze`,不发 HTTP
- [`FreeCreditsConfigStub`](/Users/ray/Documents/project/www/slot/slot_console/tests/Support/FreeCreditsConfigStub.php):活动配置桩
断言示例(已有):
- 定格金额 = `balance_before_qf`
- `biz_id` = `free_credits_freeze:{orderId}`
- `balance_before_qf <= 0` 时不调用 freeze
**运行**
```bash
cd slot_console && ./vendor/bin/phpunit --testsuite Unit
```
提现三类事件同理:[`tests/Integration/FreeCreditsLogicCashoutResultTest.php`](/Users/ray/Documents/project/www/slot/slot_console/tests/Integration/FreeCreditsLogicCashoutResultTest.php) 直接调 `handleFirstCashoutResult`,不经过 EventBus。
### 2. 集成测试:需要 DB 时
[`FreeCreditsDbTestCase`](/Users/ray/Documents/project/www/slot/slot_console/tests/Integration/FreeCreditsDbTestCase.php)`RUN_DB_TESTS=1` 才跑,默认事务回滚。
```bash
RUN_DB_TESTS=1 ./vendor/bin/phpunit --testsuite Integration --filter FreeCredits
```
---
## 建议的分层(按投入产出排序)
| 层级 | 测什么 | 是否必需 | 依赖 |
|------|--------|----------|------|
| A. Entity | `FreeCreditInitEntity` 字段映射、`resolveWalletAmount()` 回退 | 推荐 | 无 |
| B. Logic | `handleFreeCreditInit` / `handleFirstCashoutResult` 编排 | **已有,继续扩展** | Harness + RecordingWallet |
| C. Event | `FreeCreditInitEvent::handle``MQBusEntity` 转成 Logic 参数 | 可选 | 需可注入 Logic见下 |
| D. EventBus | `deal()``type` 分发、失败 nack | 少量即可 | Mock `AMQPMessage` |
| E. E2E | 真 MQ + wallet 发消息 | 手工/脚本,非单测 | 环境 |
**原则**B 覆盖 90% 风险A 防 payload 字段错C/D 防「路由写错 type」和「实体解析漏字段」。
---
## 可新增的测试示例
### A. Entity 单测(纯数组 → 实体)
新建 `tests/Unit/FreeCreditInitEntityTest.php`
```php
$entity = new FreeCreditInitEntity([
'balance_before_qf' => 15000,
'wallet_amount' => 0,
'amount' => 5000,
'biz_id' => 'order_1',
]);
$this->assertSame(15000, $entity->balance_before_qf);
$this->assertSame(5000, $entity->resolveWalletAmount()); // wallet_amount 为 0 时回退 amount
```
对应生产代码:[`app/entity/mq/FreeCreditInitEntity.php`](/Users/ray/Documents/project/www/slot/slot_console/app/entity/mq/FreeCreditInitEntity.php)。
### B. 构造 MQ 载荷辅助方法
`tests/Support/` 增加工厂,统一模拟 wallet 发出的 body
```php
public static function consoleBusMessage(int $uid, string $type, array $data): MQBusEntity
{
return new MQBusEntity(['uid' => $uid, 'type' => $type, 'data' => $data]);
}
// free_credit_init 示例
public static function freeCreditInitBus(int $uid, int $balanceBefore, int $walletAmount, string $bizId): MQBusEntity
{
return self::consoleBusMessage($uid, MQBusEntity::TYPE_FREE_CREDIT_INIT, [
'balance_before_qf' => $balanceBefore,
'wallet_amount' => $walletAmount,
'biz_id' => $bizId,
'source' => 'test',
'currency' => 'INR',
]);
}
```
与 [`WalletLogic::sendConsoleBus`](/Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) 字段保持一致。
### C. Event 层单测(当前结构的限制)
[`FreeCreditInitEvent`](/Users/ray/Documents/project/www/slot/slot_console/app/command/event/FreeCreditInitEvent.php) 内部写死 `new FreeCreditsLogic()`**无法在不改代码的情况下 mock Logic**。
两种做法(二选一):
1. **小重构(推荐)**Event 构造函数注入 `FreeCreditsLogic`,单测传入 `FreeCreditsLogicHarness`
2. **不测 Event**:认为 Event 只有 5 行胶水,由 B 层保证;仅加 A 层测 data 解析。
若采用 1示例
```php
$bus = ConsoleBusMessageFactory::freeCreditInitBus($uid, 15000, 5000, 'order_1');
$harness = (new FreeCreditsLogicHarness())->inject($context, $config, $wallet);
(new FreeCreditInitEvent($harness))->handle($bus);
$this->assertCount(1, $wallet->freezeCalls);
```
### D. EventBus 路由单测(少量)
Mock `PhpAmqpLib\Message\AMQPMessage`
```php
$message = $this->createMock(AMQPMessage::class);
$message->method('getBody')->willReturn(json_encode([
'uid' => 90088002,
'type' => MQBusEntity::TYPE_FREE_CREDIT_INIT,
'data' => ['balance_before_qf' => 15000, 'wallet_amount' => 5000, 'biz_id' => 'x'],
]));
$message->expects($this->once())->method('ack'); // 成功应 ack
$bus = new EventBus();
// 若 Logic 仍 new 在 Event 内,需配合 C 的注入或接受集成测
$bus->deal($message);
```
**nack 场景**[`EventBus.php` L164-167](/Users/ray/Documents/project/www/slot/slot_console/app/command/EventBus.php)Logic 抛异常时,`TYPE_FREE_CREDIT_INIT``nack(true)` 且不 `ack`。可让 Harness 的 `freeCreditsFreeze` 抛异常,断言 `$message->expects($this->once())->method('nack')->with(true)`
注意:`EventBus` 依赖多,路由测试宜 **只测 switch 分支 + ack/nack**,业务细节仍放 B。
---
## 四个 Free Credits 相关 type 怎么测
| MQ type常量 | 单测重点 | 现有覆盖 |
|-----------------|----------|----------|
| `TYPE_FREE_CREDIT_INIT` | 定格金额、biz_id、幂等跳过 | `FreeCreditsHandleFreeCreditInitTest` |
| `TYPE_FREE_CREDITS_FIRST_CASHOUT_SUCCESS` | 档位 completed、player 状态 | `FreeCreditsLogicCashoutResultTest` |
| `TYPE_FREE_CREDITS_FIRST_CASHOUT_FAIL` | 失败回滚逻辑 | 可补 case |
| `TYPE_FREE_CREDITS_FIRST_CASHOUT_REJECTED` | rejected → ready | 已有 reject case |
EventBus 里前三类目前 **直接调 Logic**(未走独立 Event 类),单测继续打 Logic 即可;常量定义在 [`MQBusEntity`](/Users/ray/Documents/project/www/slot/slot_console/app/entity/mq/MQBusEntity.php)。
---
## 不建议在单测里做的
- 启动 `php webman event:bus` 或连接真实 RabbitMQ
- 跨服务联调 wallet → console留给脚本如 `scripts/run_free_credits_first_freeze.php` 或手工验收)
- 在单测里依赖 `UserTagService::getByUid`(用 Harness 注入 context
---
## 推荐落地顺序(若你要补测试)
1.`FreeCreditInitEntityTest`A
2.`ConsoleBusMessageFactory`B 辅助)
3. 扩展 `FreeCreditsHandleFreeCreditInitTest`无活动配置、已定格幂等、Logic 抛错
4. (可选)`FreeCreditInitEvent` 注入 Logic + Event 单测C
5. (可选)`EventBusFreeCreditInitTest` 只测 ack/nackD
```mermaid
flowchart TD
subgraph unit [Unit 无 MQ]
E[EntityTest]
L[LogicHarnessTest]
end
subgraph optional [Optional]
Ev[EventTest]
Bus[EventBusRoutingTest]
end
subgraph integration [Integration RUN_DB_TESTS]
DB[DbTestCase]
end
E --> L
L --> Ev
Ev --> Bus
L --> DB
```

View File

@@ -0,0 +1,184 @@
---
name: 异步用户统计 MQ
overview: wallet_user_stat 通过 slot/foundation RabbitMQ 异步更新;废弃 WalletStatModel 分片统计表commit 后发 MQcommand 消费落库Redis biz_id 幂等。
todos:
- id: mq-entity-keys
content: 事件 Entity/常量、MQ 命名foundation RabbitMQConfig 工厂方法
status: completed
- id: mq-producer-consumer-service
content: WalletUserStatMQServiceProducer与 WalletUserStatConsumeService
status: completed
- id: command-update-wallet-user-stat
content: UpdateWalletUserStat command 使用 RabbitMQConsumer::consume
status: completed
- id: remove-wallet-stat-model
content: 移除 WalletStatModel 全部引用,读统计改 WalletUserStatModel
status: completed
- id: wire-wallet-logic
content: 注册/充值/提现完成 commit 后 publishRegisterService 去掉同步 stat
status: completed
- id: doc-and-verify
content: doc 留痕 + Docker 冒烟foundation MQ + 幂等)
status: completed
isProject: false
---
# wallet_user_stat 异步统计更新方案(修订)
## 变更要点(相对上一版)
1. **废弃** [`WalletStatModel`](app/model/multi/WalletStatModel.php)(分片 `wallet_stat_XX`):删除所有同步 `inc` / `insertData` / 读统计逻辑,**不再并行双写**。
2. **MQ 统一走** [`slot/foundation` `src/MQ`](vendor/slot/foundation/src/MQ/)**不再**为本需求扩展 [`RabbitMqService`](app/service/RabbitMqService.php)wager/console 等历史消费者可暂保留旧封装)。
## 为什么异步
| 维度 | 说明 |
|------|------|
| 定位 | `wallet_user_stat` 为运营/展示统计,非真账 |
| 性能 | 资金事务 commit 后异步写 `rh_wallet`,缩短主路径 |
| 规范 | 主账本 DB 先提交,再发 MQ消费幂等 |
```mermaid
sequenceDiagram
participant Logic as WalletLogic
participant ShardDB as wallet_shard
participant Prod as RabbitMQProducer
participant Cmd as updateWalletUserStat
participant Cons as RabbitMQConsumer
participant Stat as wallet_user_stat
Logic->>ShardDB: commit
Logic->>Prod: sendMessage event
Cmd->>Cons: consume autoAck
Cons->>Stat: WalletUserStatModel apply*
```
## Foundation MQ APIvendor 已具备)
| 类 | 用途 |
|----|------|
| [`RabbitMQConfig`](vendor/slot/foundation/src/MQ/RabbitMqConfig.php) | host/port/user/password/vhost |
| [`RabbitMQProducer`](vendor/slot/foundation/src/MQ/RabbitMqProducer.php) | `getInstance($config, $exchange, $queue, 'direct', $route)->sendMessage(array\|string)` |
| [`RabbitMQConsumer`](vendor/slot/foundation/src/MQ/RabbitMqConsumer.php) | `consume($callback, $autoAck=true)``decodeMessage($message)` |
配置从现有 `.env` 组装(与 [`RabbitMqService`](app/service/RabbitMqService.php) 相同变量:`MQ_HOST``MQ_PORT``MQ_USER``MQ_PASSWORD``MQ_VHOST`)。
本仓新增 **`WalletUserStatMqSupport`**(或写在 `WalletUserStatMQService` 内私有方法):
```php
use slot\foundation\MQ\RabbitMQConfig;
use slot\foundation\MQ\RabbitMQProducer;
use slot\foundation\MQ\RabbitMQConsumer;
private static function config(): RabbitMQConfig
{
return new RabbitMQConfig(
(string) getenv('MQ_HOST'),
(int) getenv('MQ_PORT'),
(string) getenv('MQ_USER'),
(string) getenv('MQ_PASSWORD'),
(string) getenv('MQ_VHOST'),
);
}
```
交换机/队列([`MQKeyManagerService`](app/service/MQKeyManagerService.php) 常量directroutingKey = queue 名):
- `EXCHANGE_WALLET_USER_STAT = 'wallet_user_stat'`
- `QUEUE_WALLET_USER_STAT = 'wallet_user_stat'`
## 消息体
[`app/entity/mq/WalletUserStatEventEntity.php`](app/entity/mq/WalletUserStatEventEntity.php) + [`app/constants/WalletUserStatEvent.php`](app/constants/WalletUserStatEvent.php)
| 字段 | 说明 |
|------|------|
| `event` | `init` / `deposit` / `withdraw` / `promo_bonus` / `cashback` / `refund` / `chargeback` |
| `uid`, `currency`, `amount`, `biz_id` | 金额 x1000`init` 时 amount=0 |
| `occurred_at` | 可选 |
消费端映射 [`WalletUserStatModel`](app/model/WalletUserStatModel.php) 已有 `apply*` / `insertInitRow`
## Service 分层
| 类 | 职责 |
|----|------|
| [`WalletUserStatMQService`](app/service/wallet/WalletUserStatMQService.php) | `publish(WalletUserStatEventEntity)``RabbitMQProducer::sendMessage($entity->activeData())` |
| [`WalletUserStatConsumeService`](app/service/wallet/WalletUserStatConsumeService.php) | 校验、Redis 幂等 `wallet:user_stat:dedupe:{event}:{biz_id}`、调用 Model |
**禁止**在 `WalletUserStatMQService` 内再包一层无意义的 `RabbitMqService` 转发。
## Command
[`app/command/UpdateWalletUserStat.php`](app/command/UpdateWalletUserStat.php)(结构参考 [`UpdateWagerTask`](app/command/UpdateWagerTask.php),但换 foundation Consumer
```php
$consumer = RabbitMQConsumer::getInstance(
WalletUserStatMqSupport::config(),
MQKeyManagerService::EXCHANGE_WALLET_USER_STAT,
MQKeyManagerService::QUEUE_WALLET_USER_STAT,
'direct',
MQKeyManagerService::QUEUE_WALLET_USER_STAT,
);
$consumer->consume(function (AMQPMessage $message) use ($consumer) {
$payload = $consumer->decodeMessage($message);
(new WalletUserStatConsumeService())->handle(is_array($payload) ? $payload : []);
}, true, false);
```
- 成功回调后 **auto ack**foundation 默认行为)
- 业务异常:`LoggerService::error`;是否 requeue 首版 `false`(与 wager 手工 ack 策略不同,更依赖 Redis 幂等 + 日志补数)
启动:
```bash
docker exec -w /app/www/ray/slot-wallet php82 php webman updateWalletUserStat
```
## 移除 WalletStatModel
| 文件 | 改动 |
|------|------|
| [`WalletLogic.php`](app/api/logic/WalletLogic.php) | 删除 `WalletStatModel` use 及 `inc`/`insertData`commit 后 `WalletUserStatMQService::publish` |
| [`RegisterService.php`](app/service/wallet/RegisterService.php) | 删除 `$walletStatModel``insertData`;注册 commit 后发 `init` 事件 |
| [`WalletLogic::getWallet`](app/api/logic/WalletLogic.php)`needStat==1` | 改读 `WalletUserStatModel::findByUidCurrency``r``total_deposit_amount``tw``total_withdraw_amount` |
| [`WalletStatModel.php`](app/model/multi/WalletStatModel.php) | **删除文件**(无引用后) |
### 发 MQ 关键路径post-commit
| 场景 | event | amount |
|------|-------|--------|
| 注册 | `init` | 0 |
| `recharge` / `rechargeSign` | `deposit` | `recharge` |
| 提现完成 `BIZ_TYPE_WITHDRAW` | `withdraw` | `fee` |
`biz_id` 使用请求 DTO 已有 `biz_id`(幂等维度与资金接口一致)。
## 不涉及
- 不改 `config/thinkorm.php`
- 不把统计写回资金事务
- 不改造 wager/console 等仍用 `RabbitMqService` 的旧队列(仅新统计队列用 foundation
## 验证
1. `composer` 已含 `slot/foundation`lock 中已有 MQ 类)
2. 启动 consumer + 走注册/充值/提现
3.`rh_wallet.wallet_user_stat`
4. 重复 `biz_id` 验证 Redis 幂等
5. 确认 `wallet_stat_XX` 不再被写入;`getWallet``r`/`tw` 来自新表
## 文件一览
| 操作 | 路径 |
|------|------|
| 新增 | `app/entity/mq/WalletUserStatEventEntity.php` |
| 新增 | `app/constants/WalletUserStatEvent.php` |
| 新增 | `app/service/wallet/WalletUserStatMqSupport.php`(可选,集中 Config/实例化) |
| 新增 | `app/service/wallet/WalletUserStatMQService.php` |
| 新增 | `app/service/wallet/WalletUserStatConsumeService.php` |
| 新增 | `app/command/UpdateWalletUserStat.php` |
| 修改 | `app/service/MQKeyManagerService.php``app/service/RedisKeyManagerService.php` |
| 修改 | `app/api/logic/WalletLogic.php``app/service/wallet/RegisterService.php` |
| 删除 | `app/model/multi/WalletStatModel.php` |
| 新增/修改 | `doc/` 说明 foundation MQ 与启动命令 |

View File

@@ -0,0 +1,56 @@
---
name: 拆分win派奖文档
overview: 将 wallet PRD 中与 win派奖强相关的需求从主文档拆分到独立文档降低耦合并保持规则可追踪。计划默认采用“从 wallet.md 迁移到 win.md并在原处保留索引入口”的方式。
todos:
- id: identify-win-sections
content: 标记 wallet.md 中所有 win派奖主规则与引用依赖段落
status: completed
- id: create-win-doc
content: 创建 win.md 并迁移/重组派奖规则、流程与示例
status: completed
- id: refactor-wallet-doc
content: 在 wallet.md 用摘要+链接替换已迁移内容并更新目录
status: completed
- id: consistency-pass
content: 统一术语与交叉引用,确保无重复冲突定义
status: completed
isProject: false
---
# 将 win派奖需求拆分到独立文档
## 目标
把 [doc/wallet.md](doc/wallet.md) 中“win派奖”相关需求整理到新文档 [doc/win.md](doc/win.md),并保证两份文档职责清晰:
- `wallet.md` 保留钱包总规则与对外索引
- `win.md` 承载派奖、Round 结算、出资分配等细则
## 拆分范围(默认)
从 [doc/wallet.md](doc/wallet.md) 迁移以下“win 主体”内容到 [doc/win.md](doc/win.md)
- §12 `Slots 多次派奖与最终结算规则`
- §20 中 win 强相关子流程:
- `20.4 Round 多次派奖`
- `20.5 Round 最终结算`
- 与派奖直接相关且需避免重复维护的规则片段(在 `wallet.md` 中改为引用):
- §10.3 `派奖归属规则`
- §11 中 `Round 总派奖` 分配示例
- §16.2 `派奖按比例拆分余数`
## 文档结构调整
- 在 [doc/wallet.md](doc/wallet.md)
- 保留“钱包主规则”定位
- 将迁移段落替换为简要摘要 + 指向 [doc/win.md](doc/win.md) 的链接
- 更新目录,新增 `win` 文档入口
- 在 [doc/win.md](doc/win.md)
- 新建“win派奖需求”专题文档
- 按“背景 → 结算时序 → 分配规则 → 边界条件 → 示例流程”重组内容
- 明确与 `Lot``Task``Withdrawable` 的关系保留必要术语一致性Round/FinalSettlement/AllocatedPayout
## 一致性与可维护性约束
- 术语与口径与 [doc/wallet.md](doc/wallet.md) 保持一致Bonus/Deposit/Withdrawable、Final Settlement、PlayedOut
- 避免双份正文维护:同一规则只在一个文档定义,另一侧做引用
- 若保留示例在两文档同时出现,仅保留“简版示意”在 `wallet.md`,完整口径放 `win.md`
## 验收标准
- [doc/win.md](doc/win.md) 可单独说明“派奖”完整业务规则
- [doc/wallet.md](doc/wallet.md) 删除/替换原 win 细节后仍可作为钱包总览
- 两文档目录和交叉链接可直接跳转,且不存在明显冲突描述

View File

@@ -0,0 +1,80 @@
---
name: 支持 webp 上传
overview: slot_admin 后端已允许 webp需在 slot_admin_vue 的图片上传组件默认 accept 中加入 `.webp`(及 MIME即可在管理端选择并上传 webp 文件。
todos:
- id: update-accept-default
content: 在 sa-upload-image/index.vue 的 accept 默认值与模板 fallback 中加入 .webp、image/webp并修正 .bpm → .bmp
status: completed
- id: manual-verify-upload
content: 在活动/Banner 等页面上传 webp确认选择器可选且接口返回 URL
status: completed
isProject: false
---
# slot_admin_vue 支持 WebP 图片上传
## 问题定位
管理端图片上传统一走 [`sa-upload-image`](backend/slot_admin_vue/src/components/sa-upload-image/index.vue) 组件,通过 Arco `a-upload``accept` 限制文件选择器可见格式:
```28:28:backend/slot_admin_vue/src/components/sa-upload-image/index.vue
:accept="props.accept ?? '.jpg,jpeg,.gif,.png,.svg,.bpm'"
```
```67:67:backend/slot_admin_vue/src/components/sa-upload-image/index.vue
accept: { type: String, default: '.jpg,jpeg,.gif,.png,.svg,.bpm' },
```
当前默认列表**不含** `.webp`,因此在系统文件对话框中无法选择 webp或部分浏览器会拦截。组件内 `uploadImageHandler` **没有**再做扩展名校验,上传逻辑直接调用 `/saimulti/system/uploadImage`。
**后端已支持 webp**,无需改动:
```10:10:backend/slot_admin/plugin/saimulti/config/upload.php
'upload_allow_image' => 'jpg,jpeg,png,gif,svg,bmp,webp,avif',
```
[`UploadService`](backend/slot_admin/plugin/saimulti/service/storage/UploadService.php) 按扩展名与白名单比对webp 已在列表中。
```mermaid
flowchart LR
Browser["浏览器文件选择器 accept"]
SaUpload["sa-upload-image"]
API["POST /saimulti/system/uploadImage"]
UploadService["UploadService 扩展名校验"]
Browser -->|"缺 .webp 时被过滤"| SaUpload
SaUpload --> API
API --> UploadService
UploadService -->|"webp 已允许"| Storage["S3/本地存储"]
```
全项目使用 `sa-upload-image` 时均未自定义 `:accept`改组件默认值即可覆盖活动图、Banner、头像、配置项等所有入口。
## 修改方案(仅前端,单文件)
**文件**[`backend/slot_admin_vue/src/components/sa-upload-image/index.vue`](backend/slot_admin_vue/src/components/sa-upload-image/index.vue)
1. 将 `accept` 默认值与模板中的 fallback 统一为包含 webp建议与后端白名单对齐
```
image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.jpg,.jpeg,.png,.gif,.webp,.svg,.bmp
```
- 同时写上 **MIME**`image/webp`)和 **扩展名**`.webp`),兼容 macOS/Chrome 对 webp 的识别方式。
- 顺带修正现有笔误:`.bpm` → `.bmp`(与后端 `bmp` 一致)。
2. 两处需同步修改(保持 `props.accept ?? ...` 与 `defineProps` default 一致):
- 第 28 行模板 `:accept`
- 第 67 行 `accept` prop default
**不需要改后端**`upload.php` 已含 `webp`。
## 可选(非本次必须)
若还需在**富文本编辑器**「资源选择器」里插入已有 webp 资源,[`ma-wangEditor/index.vue`](backend/slot_admin_vue/src/components/ma-wangEditor/index.vue) 第 6772 行对图片 URL 的判断未包含 `.webp`,可另开小改;与本地上传 `sa-upload-image` 无关。
## 验证步骤
1. 本地启动 slot_admin_vue打开任意带 `sa-upload-image` 的页面如活动编辑、Banner 编辑)。
2. 点击「本地上传」,文件选择器应能显示 `.webp` 文件。
3. 上传一张 webp建议 &lt; 5MB与 `upload_size` 一致),应成功返回 URL 并预览。
4. 若仍报错「不支持该格式的文件上传」,检查运行环境 `plugin/saimulti/config/upload.php` 是否被覆盖(线上配置应与仓库一致)。

View File

@@ -0,0 +1,94 @@
---
name: 旧提现接口兼容 package_id
overview: 在已落地的 `POST /api/free-credits/withdraw-first-cash` 基础上,恢复 `POST /api/withdraw/apply``package_id` 的兼容:参数校验与业务编排与新接口一致,内部转发 `applyFreeCreditsFirstCash`,不重复维护 WithdrawValidator 的 `*_fc` scene。
todos:
- id: withdraw-controller-compat
content: WithdrawController::apply 恢复 package_id 分支,校验用 FreeCreditsValidator调用 applyFreeCreditsFirstCash
status: completed
- id: comments-sync
content: FreeCreditsController / WithdrawApplyDTO 注释标明兼容与推荐路径
status: completed
- id: regression-test
content: 跑相关 PHPUnit 确认无回归
status: completed
isProject: false
---
# 旧提现接口兼容 package_id
## 背景
[第一档提现独立接口](第一档提现独立接口_6b3aaf7e.plan.md) 已实现新路径,但 [`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php) 当前**一律**走普通提现(`amount` 必填),旧 C 端若仍调用 `POST /api/withdraw/apply` + `package_id` 会校验失败或走错逻辑。
需求:**保留原接口**,传 `package_id` 时行为与 `withdraw-first-cash` 一致(兼容期转发,不删新接口)。
```mermaid
flowchart TD
apply["POST /api/withdraw/apply"]
apply -->|"package_id > 0"| fcPath["FreeCreditsValidator + applyFreeCreditsFirstCash"]
apply -->|"无 package_id"| normalPath["WithdrawValidator + apply"]
newApi["POST /api/free-credits/withdraw-first-cash"]
newApi --> fcPath
```
## 改动(仅 slot_console
### 1. [`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php)
- `use app\api\validator\FreeCreditsValidator`
- 分支逻辑(与拆分前一致,编排指向新方法):
```php
$post = $request->post();
$type = intval($post['type'] ?? input('type', 1));
if (!empty($post['package_id'])) {
$error = (new FreeCreditsValidator())->scene(FreeCreditsValidator::firstCashoutScene($type))->check($post);
// 失败 return PARAMS_ERROR
$this->service->setUid($uid);
$res = $this->service->applyFreeCreditsFirstCash(new WithdrawApplyDTO($post));
return $this->success($res, 'Submitted successfully! Your order is under review...');
}
$error = $this->validate((string) $type, $post);
// 现有普通提现 apply + 'Submit successfully'
```
- 方法 PHPDoc 注明:`package_id` 为**兼容**字段,推荐改用 `/api/free-credits/withdraw-first-cash`
**不在** [`WithdrawValidator`](slot_console/app/api/validator/WithdrawValidator.php) 恢复 `*_fc` scene避免两套校验重复兼容路径复用 [`FreeCreditsValidator`](slot_console/app/api/validator/FreeCreditsValidator.php)。
### 2. 注释同步
| 文件 | 内容 |
| --- | --- |
| [`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php) 类注释 | 补充:旧路径 `withdraw/apply` + `package_id` 仍可用,建议迁移新接口 |
| [`WithdrawApplyDTO`](slot_console/app/api/dto/request/WithdrawApplyDTO.php) | `package_id` 注释改为「新接口首选withdraw/apply 兼容」 |
### 3. 单测(可选、建议加)
在 [`tests/Unit/`](slot_console/tests/Unit/) 或扩展现有 Controller 测:
- 仅验证 `WithdrawController``package_id` 非空时选用 `FreeCreditsValidator::firstCashoutScene`(可 mock validate或沿用 validator 单测 + 文档约定)。
集成行为已由 [`FreeCreditsFirstCashoutApplyTest`](slot_console/tests/Integration/FreeCreditsFirstCashoutApplyTest.php) 覆盖 `applyFreeCreditsFirstCash`,兼容层无额外 Service 逻辑,**可不新增 DB 集成测**。
运行回归:
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit \
tests/Unit/FreeCreditsValidatorWithdrawFirstCashTest.php \
tests/Unit/FreeCreditsValidatorTest.php
```
## 不变项
- [`WithdrawService::applyFreeCreditsFirstCash`](slot_console/app/service/WithdrawService.php) 实现不动
- `POST /api/free-credits/withdraw-first-cash` 仍为推荐入口
- 普通 `withdraw/apply`(无 `package_id`)行为不变
## 验收
1. `withdraw/apply` + `package_id` + 绑卡 → 与 `withdraw-first-cash` 相同返回与档位状态
2. `withdraw/apply``package_id` → 仍须 `amount`,走余额/手续费校验
3. 新接口行为无回归

View File

@@ -0,0 +1,225 @@
---
name: 每日返水活动化改造
overview: 将每日返水从独立档位表方案改为与首充定格Free Credits, type=11一致的活动配置模式新增活动类型 12、档位写入 recharge_gift_config.ext_config保留 daily_rebate_record 做用户日统计与领取,综合统计页独立不变。
todos:
- id: activity-type-12
content: 新增 TYPE_DAILY_REBATE=12RechargeGiftConfigModel、getDailyRebateActiveInfo、Consts、activity_type 字典 SQL
status: completed
- id: activity-ext-config
content: ActivityValidate::checkDailyRebateExt + ActivityController save/updateactivity/edit.vue & index.vue type=12 档位 UI
status: completed
- id: refactor-daily-rebate-service
content: DailyRebateService 改读 recharge_gift_config.ext_configdaily_rebate_record 增加 activity_id
status: completed
- id: remove-tier-table
content: 删除 daily_rebate_tier_config 及相关 Admin CRUD/菜单;更新 daily_rebate.sql
status: completed
- id: complete-stats-api
content: 补全 dailyRebateStats 前端页 + 确认 DailyRebateSettle/Expire/API/流水类型 65
status: completed
- id: tests-acceptance
content: DailyRebateCalcServiceTest + 端到端验收(配置→结算→领取→统计)
status: completed
isProject: false
---
# 每日返水 — 活动化改造方案(对齐首充定格)
## 背景与纠偏
你已指出:**这是活动,应新增活动类型,返水档位在「活动配置」里维护**参考首充定格Free Credits
现有代码库中首充定格模式:
| 层级 | 实现 |
|------|------|
| 配置表 | [`s_recharge_gift_config`](slot_console/app/model/common/RechargeGiftConfigModel.php)(后台称「充值活动配置」) |
| 活动类型 | `TYPE_FREE_CREDITS = 11` |
| 业务参数 | `ext_config` JSON千分位 `_qf` 字段) |
| 后台入口 | [活动管理 → 充值活动配置](backend/slot_admin_vue/src/views/game/activity/index.vue) + [edit.vue type==11 表单项](backend/slot_admin_vue/src/views/game/activity/edit.vue) |
| 校验 | [`ActivityValidate::checkFreeCreditsExt`](backend/slot_admin/app/game/validate/ActivityValidate.php) + [`ActivityController::save/update`](backend/slot_admin/app/game/controller/ActivityController.php) |
| 运行时查询 | [`RechargeGiftConfigModel::getFreeCreditsActiveInfo`](slot_console/app/model/common/RechargeGiftConfigModel.php) |
| 用户状态 | 独立表 `free_credits_player` / `free_credits_package` |
**先前中断实现的问题**:已创建 [`daily_rebate_tier_config`](slot_console/db/daily_rebate.sql) 独立表及 [`DailyRebateTierConfig*`](backend/slot_admin/app/game/controller/DailyRebateTierConfigController.php) 后台 — **与产品约定不符,应废弃并改为 ext_config**
**可保留部分**(逻辑正确,仅需改配置来源):
- [`DailyRebateCalcService`](slot_console/app/service/DailyRebateCalcService.php) — 累进分段计算
- [`daily_rebate_record`](slot_console/db/daily_rebate.sql) — 用户日返水记录(需加 `activity_id`
- [`DailyRebateSettle` / `DailyRebateExpire`](slot_console/app/command/DailyRebateSettle.php) 定时任务
- [`DailyRebateController`](slot_console/app/api/controller/DailyRebateController.php) C 端 info/claim
- [`DailyRebateStats*`](backend/slot_admin/app/game/controller/DailyRebateStatsController.php) — **综合统计/每日返水统计**(独立菜单,同 Free Credits 统计)
```mermaid
flowchart TB
subgraph admin [活动管理 slot_admin]
ActivityUI["activity/edit.vue type=12"]
ActivityAPI["ActivityController + ActivityService"]
end
subgraph config [s_common]
RGC["recharge_gift_config ext_config.tiers"]
DRR["daily_rebate_record"]
end
subgraph runtime [slot_console]
Settle["dailyRebateSettle"]
API["DailyRebateService info/claim"]
end
ActivityUI --> ActivityAPI --> RGC
Settle --> RGC
Settle --> DRR
API --> RGC
API --> DRR
```
---
## 1. 新增活动类型 `12` — 每日返水
### 1.1 常量与模型
- [`RechargeGiftConfigModel`](slot_console/app/model/common/RechargeGiftConfigModel.php) 增加:
```php
const TYPE_DAILY_REBATE = 12; // 每日返水
```
- 新增方法 `getDailyRebateActiveInfo(string $source)`,逻辑**复制** `getFreeCreditsActiveInfo``status=1`、`type=12`、时间窗、`source` 优先于 `all`。
- [`slot_lib/Consts.php`](slot_lib/src/common/const/Consts.php) 增加 `ACTIVITY_TYPE_DAILY_REBATE = 12`(与现有 19 并列,便于订单/日志引用)。
- 字典 `activity_type` 增加一条SQL 或后台字典):`value=12, label=每日返水`(与 type=11「首充定格/Free Credits」同级
### 1.2 `ext_config` 结构(档位配置)
```json
{
"tiers": [
{ "sort": 1, "min_bet_qf": 0, "max_bet_qf": 1000000, "rate_percent": 3 },
{ "sort": 2, "min_bet_qf": 1000001, "max_bet_qf": 3000000, "rate_percent": 2 },
{ "sort": 3, "min_bet_qf": 3000001, "max_bet_qf": 5000000, "rate_percent": 1 },
{ "sort": 4, "min_bet_qf": 5000001, "max_bet_qf": null, "rate_percent": 0.5 }
]
}
```
- 金额与全站一致:**存储千分位(厘)**;后台表单用美元展示,提交时 `×1000`(与 type=11 的 `toQf` 一致)。
- 默认种子:可在活动保存时由前端预填 4 档,或提供 migration 插入一条 `type=12` 的 default 活动(`source=all`)。
### 1.3 校验
在 [`ActivityValidate`](backend/slot_admin/app/game/validate/ActivityValidate.php) 新增 `checkDailyRebateExt(array $extConfig)`
- `tiers` 非空数组,每档含 `sort/min_bet_qf/rate_percent`;非最后一档 `max_bet_qf` 必填。
- 复用 [`DailyRebateCalcService::validateTiersContinuous`](slot_console/app/service/DailyRebateCalcService.php)(入参转为 `min_bet`/`max_bet` 厘)。
在 [`ActivityController`](backend/slot_admin/app/game/controller/ActivityController.php) 的 `save` / `update``updateData` 时)对 `type === 12` 调用该校验(与 type=11 并列)。
---
## 2. 管理后台 — 活动配置 UI核心
参考 type=11在 [`activity/edit.vue`](backend/slot_admin_vue/src/views/game/activity/edit.vue) 增加 **`formData.type == 12`** 区块:
| 表单项 | 说明 |
|--------|------|
| 返水档位表 | 可增删行:排序、下限($)、上限($,最后一档可空)、比例(%) |
| 规则说明 | 复用活动主表 `help` 字段(与 Free Credits 一致) |
| 展示时间/生效时间 | 复用现有 `show_*` / `start_time` / `end_time` |
**交互约束**(与 type=11 相同):
- `onlyGift` 计算属性加入 `12` → 不展示「赠送配置 goods」区块。
- `submit` 时:`type===12` → `data.goods=[]``ext_config.tiers` 做 `toQf` 转换。
- `setFormData` 时:`tiers` 从 `_qf` 还原为美元展示。
[`activity/index.vue`](backend/slot_admin_vue/src/views/game/activity/index.vue) 列表 `ext_config` 列type=12 时展示各档比例摘要(类似 type=11 展示门槛)。
**删除**(不再单独维护档位):
- [`DailyRebateTierConfigController`](backend/slot_admin/app/game/controller/DailyRebateTierConfigController.php) 及 Logic/Validate/Model
- [`dailyRebateTierConfig.js`](backend/slot_admin_vue/src/api/game/dailyRebateTierConfig.js) 与对应 vue若已创建
- [`menu-daily-rebate.sql`](backend/slot_admin/db/menu-daily-rebate.sql) 中「每日返水档位」菜单项3082
- 表 `daily_rebate_tier_config`(不建或迁移后 DROP
**保留菜单**:仅「综合统计 → 每日返水统计」3084挂在活动管理同级或统计分组下。
---
## 3. 运行时改造slot_console
### 3.1 配置读取
改造 [`DailyRebateService`](slot_console/app/service/DailyRebateService.php)
- 删除对 `DailyRebateTierConfigModel` 的依赖。
- 通过 `RechargeGiftConfigModel::getDailyRebateActiveInfo($source)` 取活动;无生效活动 → C 端 `unlocked=false` 或整活动不可用(与未开 Free Credits 一致)。
- 从 `ext_config['tiers']` 解析为 `DailyRebateCalcService` 入参(`min_bet`/`max_bet`/`rate_percent`)。
- `info()` 返回的 `tiers` 来自当前活动配置;`tier_snapshot` 结算时写入完整 tiers JSON。
### 3.2 用户记录表
调整 [`daily_rebate_record`](slot_console/db/daily_rebate.sql)
- 增加 `activity_id bigint``recharge_gift_config.id`),结算/待定时写入。
- **删除** `daily_rebate_tier_config` 建表与种子 SQL。
### 3.3 结算与领取
- [`DailyRebateSettle`](slot_console/app/command/DailyRebateSettle.php):按渠道解析活动配置后再算返利;未充值用户仍跳过。
- 领取:继续 `TRANSACTION_TYPE_DAILY_REBATE = 65`(已在 [`Consts.php`](slot_lib/src/common/const/Consts.php) 规划/部分落地)。
### 3.4 C 端 API不变路径
- `GET /api/daily-rebate/info`
- `POST /api/daily-rebate/claim`
需校验:**当前用户渠道存在生效的 type=12 活动**,否则返回活动未开启。
---
## 4. 综合统计页(保持独立)
与 [Free Credits 统计](backend/slot_admin/app/game/controller/FreeCreditsStatsController.php) 相同定位:
- 菜单:**综合统计 / 每日返水统计**(非活动配置页)
- 实现:沿用已规划的 [`DailyRebateStatsLogic`](backend/slot_admin/app/game/logic/DailyRebateStatsLogic.php) + [`dailyRebateStats/index.vue`](backend/slot_admin_vue/src/views/game/dailyRebateStats/index.vue)(若 vue 未写完则补全)
- 数据源:`daily_rebate_record`;筛选/汇总规则不变
---
## 5. 与首充定格的差异对照
| 项 | Free Credits (11) | 每日返水 (12) |
|----|-------------------|---------------|
| ext_config | 门槛、分档金额、Banner | **tiers 累进档位** |
| 用户状态表 | free_credits_player/package | daily_rebate_record |
| 统计页 | Free Credits 统计 | 每日返水统计 |
| 充值门槛 | 首充定格业务 | **历史充值过即可**wallet `r>0` |
| 计费基础 | 定格池 | 当日 `user_profit_daily.bet` |
---
## 6. 实施步骤(建议顺序)
1. **活动类型与 ext_config 契约**Model 常量 + `getDailyRebateActiveInfo` + ActivityValidate + ActivityController + 字典 SQL。
2. **后台 activity/edit.vue + index.vue**type=12 档位表 UI 与 qf 换算。
3. **重构 DailyRebateService**:读活动 ext_configrecord 加 `activity_id`。
4. **清理**:移除 tier 独立表/Controller/菜单;更新 `daily_rebate.sql`。
5. **补全**:统计 vue、PWA 流水类型 65、单元测试仍用 tiers 数组驱动 CalcService。
6. **验收**:后台新建 type=12 活动 → 用户下注 → 日切结算 → 领取 → 统计页数字一致。
---
## 7. 验收要点
1. 仅在「充值活动配置」可创建/编辑 type=12档位保存后 `ext_config.tiers` 正确落库。
2. 同渠道多条 type=12 时行为与 type=11 一致(优先具体 source
3. 改档位仅影响**新结算日**`tier_snapshot` 审计旧记录)。
4. 不再出现「每日返水档位」独立菜单。
5. $2000 下注 → 次日返水 $50与累进公式一致。
---
## Definition of Done硬规则对齐
- [ ] `grep -r DailyRebateService ~/Documents/project/www/slot --include='*.php'` → 无命中(排除 vendor
- [ ] [`DailyRebateController`](slot_console/app/api/controller/DailyRebateController.php) 对齐 [`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php)`BaseController` + Logic + Validate
- [ ] 已删除 orphan`slot_admin_vue` 下 `dailyRebateTierConfig/*`、`api/game/dailyRebateTierConfig.js`
- [ ] `~/.cursor/hooks/verify-slot-backend.sh` 输出 `PASS`

View File

@@ -0,0 +1,408 @@
---
name: 每日返水需求文档
overview: 在现有 user_profit_daily 日下注统计基础上,新增「每日返水」活动:累进档位计费、仅充值用户参与、次日 00:00 起可领且 24 小时内有效;配套用户端 API、后台档位配置与综合统计页。
todos:
- id: schema
content: 设计并评审 daily_rebate_record、daily_rebate_tier_config 表结构与索引
status: in_progress
- id: calc-service
content: 实现累进分段 RebateCalcService + tier_snapshot + 单元测试(含 $2000→$50 等用例)
status: pending
- id: cron
content: slot_consoledailyRebateSettle / dailyRebateExpire 定时任务
status: pending
- id: user-api
content: slot_console 用户端 info/claim API + slot_wallet 入账与新 transaction type
status: pending
- id: admin-tier
content: slot_admin 每日返水档位 CRUD 页
status: pending
- id: admin-stats
content: slot_admin 综合统计/每日返水统计(列表+筛选+排序+汇总,参考 FreeCreditsStats
status: pending
- id: pwa-ui
content: 前端三态 UI、7 日表、倒计时、领取按钮
status: pending
isProject: false
---
# 每日返水Daily Rebate 3%)需求规格书
## 1. 背景与目标
- **业务目标**:对已充值用户,按自然日累计有效下注给予累进比例返水,提升留存与投注激励。
- **数据基础**`[slot_console](slot_console)` 已通过 `[LoseReturnDeposit](slot_console/app/command/LoseReturnDeposit.php)` + Redis `[UserProfitService](slot_pwa/app/service/user/UserProfitService.php)` 维护 `[s_statistics.user_profit_daily](slot_console/app/model/statistics/UserProfitDaily.php)`(字段 `bet`/`win`/`profit`/`create_date`/`source`)。
- **与 VIP 亏损返利的区别**VIP 返利基于「当日净亏损」且需 VIP 等级;本活动基于「当日总下注额」、仅需历史充值、档位为累进分段,产品独立。
---
## 2. 术语
| 术语 | 定义 |
| ---- | ------------------------------------------------------------------------------------------------------------- |
| 自然日 | 服务器时区 `Asia/Shanghai`(与 `[slot_center/config/app.php](slot_center/config/app.php)` 一致)的 `00:00:00``23:59:59` |
| 有效下注 | **等于** `user_profit_daily.bet`(厘),与 PWA 下注时 `incBet` 累计值一致;无单独过滤规则 |
| 返水金额 | 按档位配置对当日有效下注做**累进分段**计算后的金额(厘) |
| 统计日期 | 发生下注的自然日 `create_date`,非领取日 |
| 充值用户 | 结算时刻钱包 `total_deposit > 0``[WalletStatModel](slot_wallet/app/model/multi/WalletStatModel.php)` |
---
## 3. 核心业务规则
### 3.1 参与资格
- **未充值**不参与返水前端展示「锁定」态Unlock Cashback不可领、列表可置灰或仅展示引导文案。
- **已充值**:自充值成功当日起,当日及之后有有效下注的自然日可产生返水记录;历史未充值日的下注不补发。
### 3.2 返水计算(累进分段,非整笔单一比例)
**已确认**:采用分段累进,与原型及后台示例一致($2000 → $50
默认档位(首版种子数据,**后台可配置**
| 分段序号 | 下注区间(含边界,美元展示) | 比例 |
| ---- | --------------- | ---- |
| 1 | $0 $1,000 | 3% |
| 2 | $1,001 $3,000 | 2% |
| 3 | $3,001 $5,000 | 1% |
| 4 | $5,001 及以上 | 0.5% |
**计算公式**`B` = 当日有效下注,美元;存储与计算用厘,`1 USD = 1000` 厘,与 `[share_config.moneyFormat](slot_center/config/share_config.php)` 一致):
```
rebate_li = Σ segment_i( min(B, max_i) - min(B, min_i) + ε_i ) × rate_i
```
- 各档 `min_i`/`max_i` 以后台配置为准(最后一档 `max` 为空表示无上限)。
- 分段左闭右闭:第 1 档覆盖 `[0, 1000]`,第 2 档覆盖 `(1000, 3000]`,依此类推(实现时用「上一档上限 + 1」作为下一档起点避免重复计费
- **舍入**:返水金额入库前 `floor` 到厘;前端展示保留 2 位小数美元。
**计算示例**
| 有效下注 | 计算过程 | 返水 |
| ------- | --------------------------------------- | ------ |
| $125.56 | 125.56 × 3% | $3.77 |
| $200 | 200 × 3% | $6.00 |
| $2,000 | 1000×3% + 1000×2% | $50.00 |
| $6,000 | 1000×3% + 2000×2% + 2000×1% + 1000×0.5% | $95.00 |
**待定态(当天)**:用 Redis 当日 `bet` 实时重算返水预览;不入库终态金额,或与 DB 行 `status=pending` 同步更新。
### 3.3 结算与领取时间轴
```mermaid
sequenceDiagram
participant User
participant PWA as slot_pwa
participant Redis
participant Cron as slot_console_cron
participant DB as daily_rebate_record
Note over User,Redis: D日 00:00-23:59
User->>PWA: 下注
PWA->>Redis: incBet
Note over DB: status=pending 实时 bet/rebate
Note over Cron,DB: D+1日 00:00后 cron
Cron->>Redis: 读取 D日 bet
Cron->>DB: 写入/更新 settle: claimable, dead_time=D+2 00:00
Note over User,DB: D+1日 仅可领 D日 返水
User->>PWA: claim(stat_date=D)
PWA->>DB: status=claimed
Note over DB: 超过 dead_time 未领
Cron->>DB: status=expired
```
| 时点 | 行为 |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| D 日进行中 | 状态 **待定**;有效下注、返水金额随 Redis 刷新 |
| D+1 日 00:00 | 定时任务结算 D 日:有 `bet>0` 且已充值 → 生成/更新记录,状态 **待领取**`claimable_at = D+1 00:00:00``expire_at = D+2 00:00:00`(领取窗口 **24 小时**,对齐 VIP `[vip_rebate_lose_time](slot_center/config/share_config.php)` |
| D+1 日全天 | 用户**只能领取 stat_date=D** 的返水(「前一天」) |
| D+2 日 00:00 前未领 | 状态 **过期**`rebate_amount>0` 且未领 |
| 领取成功 | 状态 **已领**;记 `claimed_at`;钱包入账 |
**无有效下注**:不生成记录(后台列表、前端 7 日表均不展示该日)。
**返水为 0**:若档位计算为 0`bet=0` 已排除),`bet>0` 但舍入为 0 时可不落库或落库且不可领——建议 **不落库**,与「未有有效下注不显示」一致。
### 3.4 状态机
| 状态码 | 中文 | 条件 |
| ----------- | --- | --------------------------------------------------------------------- |
| `pending` | 待定 | `stat_date = 今天`;实时统计未结束 |
| `claimable` | 待领取 | `stat_date < 今天``rebate_amount > 0``claimed_at` 空;`now < expire_at` |
| `claimed` | 已领 | `claimed_at` 非空 |
| `expired` | 过期 | `rebate_amount > 0`;未领;`now >= expire_at` |
状态迁移:
- `pending``claimable`:日切结算任务(仅昨日及更早批量处理;今日保持 pending
- `claimable``claimed`:用户领取接口(幂等)
- `claimable``expired`:过期扫描任务或领取时校验
前端映射(英文 UI
| 状态 | 展示 | 行样式 |
| --------- | --------- | -------------------- |
| pending | Pending | 绿色标签(进行中) |
| claimable | Claimable | 绿色可点 |
| claimed | Claimed | 白色/灰色 |
| expired | Expired | 红色/灰色 |
| 无记录 | No Bets | 灰字(仅前端 7 日占位,无 DB 行) |
### 3.5 领取规则
- 每次领取**一条**指定 `stat_date`(通常为昨日)。
- 并发DB 行级锁 / `UPDATE ... WHERE status=claimable` 防重复领。
- 入账:新增钱包流水类型(建议 `TRANSACTION_TYPE_DAILY_REBATE = 65`,在 `[Consts.php](slot_lib/src/common/const/Consts.php)` 登记);更新 `total_cashback`
- 失败:事务回滚,状态不变。
---
## 4. 数据设计
### 4.1 沿用表
- `**s_statistics.user_profit_daily`**:只读来源,提供 `bet``source``create_date`**不扩展**状态/返水字段。
### 4.2 新建表 `daily_rebate_record`(建议库:`s_common`
| 字段 | 类型 | 说明 |
| ----------------------- | ----------------- | ----------------------------------------- |
| id | bigint PK | |
| uid | bigint | 用户 ID |
| source | varchar | 渠道号(结算时快照) |
| stat_date | date | 统计日期(下注日) |
| bet_amount | bigint | 有效下注(厘) |
| rebate_amount | bigint | 返水金额(厘) |
| status | tinyint | 1 pending 2 claimable 3 claimed 4 expired |
| tier_snapshot | json | 结算时档位快照(审计) |
| claimable_at | datetime | 可领取开始 |
| expire_at | datetime | 过期时间 |
| claimed_at | datetime nullable | 领取时间 |
| created_at / updated_at | datetime | |
**唯一索引**`(uid, stat_date)`
**索引**`(stat_date, status)``(source, stat_date)``(uid)`
### 4.3 新建表 `daily_rebate_tier_config`(后台可配置)
| 字段 | 说明 |
| ----------------------- | ------------------ |
| id | PK |
| sort | 排序(从小到大) |
| min_bet | 区间下限(厘,含) |
| max_bet | 区间上限NULL=无上限) |
| rate_percent | 比例,如 3.00 表示 3% |
| status | 启用/停用 |
| updated_by / updated_at | 审计 |
校验:档位连续无空洞、无重叠;至少一档;最后一档可无 `max_bet`
---
## 5. 定时任务slot_console
| 任务 | 触发 | 职责 |
| ----------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------- |
| `dailyRebateSettle` | 每日 00:05可配置 | 结算 **昨日** `user_profit_daily` + 充值校验 → upsert `daily_rebate_record` 为 claimable |
| `dailyRebateExpire` | 每小时或 00:10 | 将 `claimable``now>=expire_at` 置为 expired |
| (可选)与现有 `[loseReturnDeposit](slot_console/app/command/LoseReturnDeposit.php)` | 同批读 Redis | 避免重复扫 Redis**推荐独立命令**以免耦合 VIP 逻辑 |
**待定实时**用户打开活动页时Logic 读 Redis `user:profit:{Ymd}` + 当前档位配置计算预览,不写 claimable。
---
## 6. 用户端PWA / Gateway
### 6.1 页面态(对齐原型)
| 态 | 条件 | 主按钮 | 表格 |
| --- | ------------------------------- | --------------- | ------------- |
| 锁定 | `total_deposit==0` | Unlock Cashback | 模糊/遮罩 |
| 已解锁 | 已充值,无可领 | Play Now | 近 7 日明细 |
| 可领取 | 存在 `claimable``stat_date=昨天` | Claim($X.XX) | 昨日行 Claimable |
**倒计时**「THIS ROUND」= 距今日自然日结束的秒数(与 Rates reset 00:00 文案一致)。
### 6.2 API建议落在 slot_console `/api/daily-rebate/*`,经 gateway 转发)
**GET `/api/daily-rebate/info`**
响应示例字段:
```json
{
"unlocked": true,
"countdown_seconds": 86399,
"claimable": { "stat_date": "2026-05-25", "rebate_amount": 3000, "display": "3.00" },
"tiers": [{ "min": 0, "max": 1000000, "rate": 3 }],
"records": [
{ "stat_date": "05/26", "bet_amount": 125560, "rebate_amount": 3770, "status": "pending" }
]
}
```
- `records`:最近 7 个自然日,**有 bet 或 DB 行**的日期;无下注日可不返回或前端填 No Bets。
- 打开弹窗时 **强制刷新** 当日 pending 数据。
**POST `/api/daily-rebate/claim`**
- 入参:`stat_date`(可选,默认昨天)
- 校验已充值、status=claimable、未过期、rebate>0
- 出参:领取后余额/流水号
**GET `/api/daily-rebate/tiers`**可选info 已含则省略)
---
## 7. 管理后台slot_admin + slot_admin_vue
### 7.1 菜单
- **活动配置 / 每日返水档位**新建CRUD [`daily_rebate_tier_config`],校验区间连续。
- **综合统计 / 每日返水统计**(新建):列表 + 汇总。
实现参考:列表+汇总 `[FreeCreditsStats](backend/slot_admin/app/game/logic/FreeCreditsStatsLogic.php)` + 前端 `[freeCreditsStats/index.vue](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue)`
### 7.2 统计列表
**列**ID(uid) | 渠道号 | 有效下注额⇅ | 返水金额⇅ | 状态 | 统计日期
**筛选**
- UID精确
- 渠道(`source`,可搜索下拉,复用 `commonStore.allSourcesOptionsNoAll`
- 状态:待定 / 待领取 / 已领 / 过期(多选)
- 统计日期范围(`stat_date[]`
**排序白名单**`bet_amount``rebate_amount`(默认 `stat_date desc, id desc`
**数据范围**:仅 `total_deposit>0` 用户在结算时已入库的记录;**待定**行可对「今天」合并 Redis 实时 bet与 Free Credits「打开刷新」一致
### 7.3 顶部汇总(随筛选变化)
| 指标 | 计算 |
| ---- | ----------------------------------------------- |
| 总领取 | `status=claimed``sum(rebate_amount)` |
| 总待领取 | `status=claimable` 的 sum |
| 总过期 | `status=expired` 的 sum |
| 待定 | `status=pending` 的 sum**若筛选日期范围不含今天则为 0** |
| 领取率 | `总领取 / (总领取 + 总过期 + 总待领取)`;分母为 0 时显示 `0%``-` |
展示格式:`$888.88``rebate_amount / 1000`2 位小数)。
---
## 8. 非功能需求
- **幂等**:结算任务对 `(uid, stat_date)` upsert领取 CAS 更新状态。
- **性能**:日活结算批量按日期分页;后台列表分页默认 100最大 100。
- **审计**`tier_snapshot` 保留结算时档位;领取写 wallet log `biz_extra: {activity:"daily_rebate", stat_date}`
- **监控**:结算失败/领取失败打日志 + 指标;过期数量日报。
---
## 9. 边界与异常
| 场景 | 处理 |
| ------------- | ----------------------------------------------------------- |
| 结算日用户刚充值、昨日下注 | 昨日结算时已按当时 `total_deposit` 判断;若需「充值后立即对历史补发」→ **不做**,以结算时刻为准 |
| 结算任务重复跑 | upsert不重复入账 |
| 时区变更 | 禁止随意改;变更需重算规则文档化 |
| bet>0 未充值 | 不生成记录 |
| 领取时档位已改 | 以 `tier_snapshot` 为准,不受后续配置变更影响 |
| 跨天未关弹窗 | 前端倒计时结束刷新 infopending→claimable 由后端状态驱动 |
---
## 10. 实施拆分(供研发排期)
```mermaid
flowchart LR
subgraph phase1 [Phase1 数据与配置]
T1[daily_rebate_tier_config]
T2[daily_rebate_record]
T3[admin 档位 CRUD]
end
subgraph phase2 [Phase2 结算与过期]
C1[dailyRebateSettle]
C2[dailyRebateExpire]
L1[rebate calc service]
end
subgraph phase3 [Phase3 用户端]
A1[info API]
A2[claim API + wallet]
F1[PWA 三态 UI]
end
subgraph phase4 [Phase4 后台统计]
B1[DailyRebateStats Logic]
B2[admin vue 列表汇总]
end
phase1 --> phase2 --> phase3
phase2 --> phase4
```
| 仓库 | 改动要点 |
| ---------------------- | ---------------------------------------------------------- |
| slot_console | Model、结算/过期 Command、RebateCalcService、用户 API、Redis 读当日 bet |
| slot_wallet | 新 transaction type、领取入账 |
| slot_lib | Consts 新类型 |
| backend/slot_admin | Controller/Logic/Validate、菜单 SQL |
| backend/slot_admin_vue | 档位页 + 统计页 |
| slot_sdk | 若 gateway 经 sdk 调 console补 Client 方法 |
**不建议**复用 `vip_rebate_record` 表:业务语义、状态机、计费基础均不同。
---
## 11. 验收标准(摘要)
1. 充值用户 D 日下注 $2000D+1 00:00 后显示待领取 $5024h 内领取成功,流水类型正确。
2. 未充值用户仅见锁定态,无领取接口成功路径。
3. 超 24h 未领变过期,不可再领。
4. 后台筛选/status/排序/汇总与文档公式一致;改档位仅影响新结算日。
5. 前端 7 日表、Claim 按钮金额与后台一致;当日为 Pending 且随下注刷新。
---
## 12. 已确认决策
- 计费方式:**累进分段**(非整笔落档单一比例)。
- 有效下注:**等同 `user_profit_daily.bet`**。
- 档位:**后台可配置**`daily_rebate_tier_config`)。

View File

@@ -0,0 +1,160 @@
---
name: 注册奖励随机游戏
overview: 在 slot_console 注册活动type=7领取成功响应中从活动 ext_config.game_ids 配置的候选池里,按当前渠道 game_model_id 过滤已上架游戏后随机返回 game_id供前端直接进游戏。
todos:
- id: model-published-ids
content: GameApiModel 新增 publishedGameIds(gameModelId, gameIds) 查询
status: completed
- id: logic-pick-random
content: 新增 RegisterRewardLogic::pickRandomGameId 解析 ext_config 并随机
status: completed
- id: entity-receive
content: ActivityConfigEntity ACTIVITY_TYPE_REG 成功分支接入 Logic 并扩展返回
status: completed
- id: controller-phpdoc
content: GiftController::receive PHPDoc 补充 game_id 字段说明
status: completed
- id: unit-tests
content: RegisterRewardLogicTest + php82 容器跑单测
status: completed
isProject: false
---
# 注册奖励领取后随机返回 game_id
## 背景与范围
- **入口**[`POST /api/gift/receive`](slot_console/app/api/controller/GiftController.php) → [`ActivityConfigEntity::receive()`](slot_console/app/entity/activity/ActivityConfigEntity.php) 中 `ACTIVITY_TYPE_REG`type=7
- **不在范围**:注册自动到账(`UserRegisterEventService`)、后台 Vue 配置页、前端进游戏逻辑。
- **配置来源**(已确认):活动表 `s_recharge_gift_config.ext_config.game_ids`
当前领取成功仅返回金额:
```750:750:slot_console/app/entity/activity/ActivityConfigEntity.php
return ['gift_coin' => CommonFn::getNumberFormat($giftAmount), 'gift_bonus' => CommonFn::getNumberFormat($giftBonus)];
```
`game_id` 语义与大厅一致:[`s_game_api.game_id`](slot_console/app/model/GameApiModel.php) = `game.id`,前端用该 ID 调 `POST /api/game/login` 的 `gameId` 或 napi 搜索同款字段。
## 配置契约
在对应注册活动type=7的 `ext_config` 中增加:
```json
{
"game_ids": [101, 205, 308]
}
```
- `game_ids``int[]`,运营在 DB/后台 JSON 中维护;本任务不实现 admin UI。
- 未配置、空数组、或过滤后无有效游戏:**不阻断领取**,响应中 **省略 `game_id` 字段**(与 `withdraw_guide` 用 `0` 不同,避免前端误开游戏)。
## 数据流
```mermaid
sequenceDiagram
Client->>GiftController: POST /api/gift/receive id=activityId
GiftController->>ActivityConfigEntity: receive()
ActivityConfigEntity->>WalletService: gift() 注册赠送入账
ActivityConfigEntity->>RegisterRewardLogic: pickRandomGameId(ext_config, gameModelId)
RegisterRewardLogic->>GameApiModel: 过滤 status=1 且已发布
RegisterRewardLogic-->>ActivityConfigEntity: game_id|null
ActivityConfigEntity-->>Client: gift_coin, gift_bonus, game_id?
```
## 实现要点
### 1. 新增 Logic随机选游戏
新建 [`slot_console/app/api/logic/RegisterRewardLogic.php`](slot_console/app/api/logic/RegisterRewardLogic.php)(参考 [`FreeCreditsLogic`](slot_console/app/api/logic/FreeCreditsLogic.php) 的独立 Logic 拆分方式):
| 方法 | 职责 |
| --- | --- |
| `pickRandomGameId(array\|null $extConfig, int $gameModelId): ?int` | 解析 `game_ids` → 去重/转 int → 调 Model 过滤 → `array_rand` 返回一个 id |
解析规则:
- 支持 `game_ids` 为 JSON 数组或逗号分隔字符串(防御性,与部分旧配置风格兼容)。
- 非法/非正整数丢弃。
### 2. Model候选池与渠道上架交集
在 [`GameApiModel`](slot_console/app/model/GameApiModel.php) 增加查询方法,例如:
```php
public static function publishedGameIds(int $gameModelId, array $gameIds): array
```
条件:`game_id IN (...)`、`game_model_id = $gameModelId`、`status = STATUS_ON`;返回可用 `game_id` 列表。
不在 Logic 里直接拼 SQL符合分层规则。
### 3. 改动领取分支
在 [`ActivityConfigEntity::receive()`](slot_console/app/entity/activity/ActivityConfigEntity.php) 的 `ACTIVITY_TYPE_REG` case
- 发奖逻辑保持不变(`WalletService::gift` + `sendRegisterGiftWagerTask` + `setActivityFinishById`)。
- **仅在 `$res` 非空(领取成功)后**
- 用 `$this->where('id', $activityId)->find()` 读取原始 `ext_config``getActivityByInfo()` 经 `getGiftItemsByActivity` 组装,**不含** ext_config与兑换码分支读库方式一致
- `$gameModelId = $this->_modelId`(已由 `setSource` 设置)。
- 调用 `RegisterRewardLogic::pickRandomGameId()`。
- 在方法末尾统一组装返回:有值则追加 `'game_id' => $pickedId`。
[`GiftController::receive()`](slot_console/app/api/controller/GiftController.php) 补充 PHPDoc`data.game_id`int可选领取注册活动成功且配置了有效候选池时返回
### 4. 响应示例
成功且命中游戏:
```json
{
"code": 0,
"data": {
"gift_coin": "10.00",
"gift_bonus": "0.00",
"game_id": 205
}
}
```
成功但无可用游戏:仅 `gift_coin` / `gift_bonus`(与现网兼容)。
## 单测
新建 [`slot_console/tests/Unit/RegisterRewardLogicTest.php`](slot_console/tests/Unit/RegisterRewardLogicTest.php)
- `game_ids` 为空 / 缺失 → `null`
- 字符串 `"1,2,3"` 解析
- Model 层可用 stub 或 sqlite/内存 mock若项目已有 GameApi 测试惯例则对齐)
在 docker 内执行:
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/RegisterRewardLogicTest.php
```
## 运维配置说明(无 UI
对目标渠道 type=7 活动,更新 `s_recharge_gift_config.ext_config`
```sql
-- 示例:在现有 ext_config 上合并 game_ids
UPDATE s_recharge_gift_config
SET ext_config = JSON_SET(COALESCE(ext_config, '{}'), '$.game_ids', JSON_ARRAY(101, 205))
WHERE id = <activity_id> AND type = 7;
```
`game_ids` 须为当前 `model_id` 下已在 `s_game_api` 上架的 `game.id`。
## 风险与边界
| 场景 | 行为 |
| --- | --- |
| 配置了已下架/未发布游戏 | 从池中剔除;池空则不下发 `game_id` |
| 重复领取 | 现有 `Is Got` 拦截,不会二次随机 |
| 多注册活动同渠道 | 按请求的 `id` 读对应活动 `ext_config` |
## 后续可选(本任务不做)
- slot_admin 活动编辑页 type=7 增加「推荐游戏」多选,写入 `ext_config.game_ids`
- `getActivityList` 预展示候选游戏(非领取必需)

View File

@@ -0,0 +1,127 @@
---
name: 注册活动列表 game_id
overview: 在 C 端活动列表type=7 注册活动)透出 `game_id` 供前端展示/进游戏;同时从领取接口 `POST /api/gift/receive` 移除 `game_id` 字段,复用现有 `RegisterRewardLogic``ext_config.game_ids` 配置。
todos:
- id: list-game-id
content: ActivityConfigEntity::getGiftItemsByActivity type=7 分支调用 RegisterRewardLogic 写入 game_id
status: completed
- id: remove-receive-game-id
content: ActivityConfigEntity::receive 移除注册活动 game_id 逻辑
status: completed
- id: logic-stable-pick
content: RegisterRewardLogic 增加 pickGameIdForUser稳定随机并更新注释
status: completed
- id: phpdoc-tests
content: GiftController PHPDoc 调整 + RegisterRewardLogicTest 补充用例
status: completed
isProject: false
---
# 注册活动列表透出 game_id领取不再返回
## 需求变更(相对初版)
| 接口 | 原行为 | 目标行为 |
|------|--------|----------|
| `POST /api/gift/getActivityList` | 注册活动无 `game_id` | type=7 项增加可选 `game_id` |
| `POST /api/gift/receive`type=7 | 成功时随机返回 `game_id` | **仅** `gift_coin``gift_bonus` |
后台 `ext_config.game_ids` 配置与 [`RegisterRewardLogic`](slot_console/app/api/logic/RegisterRewardLogic.php) 过滤已上架游戏逻辑**保持不变**,仅调整调用位置。
## 数据流
```mermaid
sequenceDiagram
Client->>GiftController: getActivityList
GiftController->>GiftService: getUserActivityList
GiftService->>ActivityConfigEntity: getGiftItemsByActivity type=7
ActivityConfigEntity->>RegisterRewardLogic: pickGameId from ext_config.game_ids
ActivityConfigEntity-->>Client: activity item with optional game_id
Client->>GiftController: receive id=activityId
GiftController->>ActivityConfigEntity: receive ACTIVITY_TYPE_REG
ActivityConfigEntity-->>Client: gift_coin, gift_bonus only
```
## 实现(仅 slot_console
### 1. 活动列表组装:增加 `game_id`
文件:[`slot_console/app/entity/activity/ActivityConfigEntity.php`](slot_console/app/entity/activity/ActivityConfigEntity.php)
`getGiftItemsByActivity()``switch ($activityType)` 中新增 `ACTIVITY_TYPE_REG` 分支(与首充/兑换码同级,约在 340 行附近):
- 用已有 `normalizeExtConfig($activity['ext_config'])` 读取 `game_ids`
- 调用 `RegisterRewardLogic` 得到 `game_id`
- 仅当非 `null` 时写入 `$activityInfo['game_id']`(与领取侧旧约定一致:**省略字段**而非 `0`
`$activity` 来自 `getActiveList()` 的 DB 模型,已含 `ext_config``ActivityConfigEntity``GiftService::getUserActivityList()` 中已通过 `setUid` / `setSource` 设置 `_modelId`
**列表随机策略(建议默认)**:同一用户、同一活动多次拉列表应看到同一款游戏,避免卡片闪烁。在 `RegisterRewardLogic` 增加例如 `pickGameIdForUser(?array $extConfig, int $gameModelId, int $uid, int $activityId): ?int`
- 复用 `parseGameIds` + `GameApiModel::publishedGameIds`
-`published` **排序**后,用 `crc32("{$uid}:{$activityId}") % count` 取下标(非 `array_rand`
列表与领取不再需要对齐,但稳定随机对仅列表展示更友好。若产品希望每次刷新换游戏,可继续用现有 `pickRandomGameId`
### 2. 领取分支:移除 `game_id`
同一文件 `receive()`
- 删除 `ACTIVITY_TYPE_REG` 内读取 `ext_config``RegisterRewardLogic::pickRandomGameId``$registerRewardGameId` 变量
- 删除返回数组末尾对 `game_id` 的合并(约 763765 行)
注册发奖、`sendRegisterGiftWagerTask``setActivityFinishById` **不动**
### 3. Controller PHPDoc
[`GiftController.php`](slot_console/app/api/controller/GiftController.php)
- `receive()`:去掉 `game_id` 说明,仅 `gift_coin` / `gift_bonus`
- 可选:为 `getActivityList()` 补充一行说明——type=7 且配置了有效 `game_ids` 时,对应项含 `game_id`int
### 4. Logic 注释与单测
[`RegisterRewardLogic.php`](slot_console/app/api/logic/RegisterRewardLogic.php):类注释改为「活动列表推荐进游戏」;保留 `pickRandomGameId` 或改为对外暴露 `pickGameIdForUser`(二选一,避免死代码)。
[`RegisterRewardLogicTest.php`](slot_console/tests/Unit/RegisterRewardLogicTest.php):为 `pickGameIdForUser` 增加确定性用例(相同 uid+activityId 同结果;无候选返回 null
## 响应示例
**活动列表**type=7 片段):
```json
{
"gift_id": 123,
"type": 7,
"title": "注册奖励",
"gift_coin": "10.00",
"gift_bonus": "0.00",
"game_id": 205,
"data": [{ "goods_id": -1, "gift_coin": "10.00", ... }]
}
```
无有效 `game_ids` / 无上架游戏:与现网其它活动项一致,**无 `game_id` 字段**。
**领取**
```json
{
"gift_coin": "10.00",
"gift_bonus": "0.00"
}
```
## 验证
1. 后台 type=7 活动配置 `ext_config.game_ids`(已有 admin 能力)。
2. `getActivityList`:未领取用户可见注册活动且含 `game_id`(在候选有效时)。
3. `receive`:成功响应**不含** `game_id`
4. `game_ids` 为空或均未上架:列表无 `game_id`,领取正常。
5. Docker 内跑单测:`RegisterRewardLogicTest.php`
## 不涉及
- slot_admin / admin_vue配置已具备
- 前端仓库(由 C 端自行改列表消费、去掉领取后对 `game_id` 的依赖)

View File

@@ -0,0 +1,183 @@
---
name: 注册活动推荐游戏配置
overview: 在 slot_admin 活动管理 type=7 编辑页增加「推荐游戏」多选,写入 ext_config.game_ids配套列表展示与保存校验与 slot_console 已实现的领取随机进游戏逻辑对齐。
todos:
- id: admin-model-api
content: GameApiModel 查询方法 + ActivityController::publishedGames
status: completed
- id: admin-validate
content: ActivityValidate::checkRegisterRewardExt + save/update 调用
status: completed
- id: vue-edit
content: activity/edit.vue type=7 推荐游戏多选与 submit/setFormData
status: completed
- id: vue-api-index
content: activity.js API + index.vue type=7 列表展示
status: completed
- id: manual-verify
content: 后台保存后 C 端领取验证 game_id 随机返回
status: completed
isProject: false
---
# 注册活动 type=7 推荐游戏后台配置
## 背景
C 端已在 [`RegisterRewardLogic`](slot_console/app/api/logic/RegisterRewardLogic.php) 读取 `ext_config.game_ids`,领取注册活动成功后随机返回 `game_id`。本任务补齐 **后台配置入口**,范围:
- [`backend/slot_admin_vue`](backend/slot_admin_vue) 活动编辑/列表
- [`backend/slot_admin`](backend/slot_admin) 保存校验与游戏选项 API
不涉及 slot_console 改动(`updateConfig` 已原样持久化 `ext_config` JSON
## 配置契约(与 C 端一致)
```json
{
"game_ids": [101, 205, 308]
}
```
- 值为 `game.id``s_game_api.game_id`),须属于活动 `model_id`**status=1** 的已发布游戏。
- 允许为空:领取仍成功,响应不含 `game_id`
## 数据流
```mermaid
sequenceDiagram
AdminVue->>ActivityController: GET publishedGames?model_id=
ActivityController->>GameApiModel: 已上架 game_id 列表
AdminVue->>ActivityController: POST save/update ext_config.game_ids
ActivityController->>ActivityValidate: checkRegisterRewardExt
ActivityController->>ConsoleInnerapi: ActivityService add/update
ConsoleInnerapi->>DB: s_recharge_gift_config.ext_config
```
## 1. slot_admin 后端
### 1.1 游戏选项 API
在 [`ActivityController`](backend/slot_admin/app/game/controller/ActivityController.php) 新增:
- **`publishedGames(Request $request)`**
- 入参:`model_id`(必填,对应活动「游戏模型」)
- 出参:`[{ game_id, name }]`,供多选下拉
查询放在 [`GameApiModel`](backend/slot_admin/app/model/GameApiModel.php)`s_common.s_game_api`
```php
public static function optionsByGameModelId(int $gameModelId): array
```
条件:`game_model_id``status = 1``field('game_id,name')`,按 `sort` 排序;可对 `game_id` `group` 去重(与 console 侧一致)。
### 1.2 保存校验
在 [`ActivityValidate`](backend/slot_admin/app/game/validate/ActivityValidate.php) 新增 **`checkRegisterRewardExt(array $extConfig, int $gameModelId)`**
| 规则 | 说明 |
| --- | --- |
| `game_ids` 可选 | 缺失或 `[]` 直接通过 |
| 元素为正整数 | 非法 ID 抛 `ValidateException` |
| 与渠道一致(推荐) | 调用 `GameApiModel::publishedGameIds($gameModelId, $ids)`(与 console 同名语义),提交 ID 必须全部在已发布集合内,否则提示未上架 ID |
`ActivityController::save` / `update``updateData` 全量更新时)中,当 `type === 7` 时调用:
```php
$this->validate->checkRegisterRewardExt(
(array) input('ext_config', []),
(int) input('model_id', 0)
);
```
对齐 type=11 的 [`checkFreeCreditsExt`](backend/slot_admin/app/game/validate/ActivityValidate.php) 调用方式。
可在 `GameApiModel` 复用与 console 相同的 `publishedGameIds` 静态方法admin 连接 `s_common`,表结构一致)。
## 2. slot_admin_vue 前端
### 2.1 API
在 [`src/api/game/activity.js`](backend/slot_admin_vue/src/api/game/activity.js) 增加:
```js
publishedGames(params) {
return request({ url: '/game/activity/publishedGames', method: 'get', params })
}
```
(路径随 Webman 路由约定:`game/activity/publishedGames`。)
### 2.2 编辑页 type=7 表单项
文件:[`src/views/game/activity/edit.vue`](backend/slot_admin_vue/src/views/game/activity/edit.vue)
**仅 type=7**`<template>` 中增加(参考 type=11 独立区块,勿影响 type=8 下载奖励):
```vue
<template v-if="formData.type === 7">
<a-form-item label="推荐游戏" field="ext_config.game_ids"
help="用户领取注册奖励后,从此列表随机推荐一款已上架游戏">
<a-select
v-model="formData.ext_config.game_ids"
:options="publishedGameOptions"
multiple allow-search allow-clear
placeholder="请先选择游戏模型,再选择推荐游戏"
:disabled="!formData.model_id"
/>
</a-form-item>
</template>
```
脚本逻辑(对齐现有 type=10/11 的 `setFormData` / `submit` 分支):
| 时机 | 处理 |
| --- | --- |
| `open` / `model_id` 变更 | `loadPublishedGames()` → 调 `publishedGames({ model_id })`,映射 `{ value: game_id, label: name + ' (' + game_id + ')' }` |
| `setFormData` type=7 | `formData.ext_config.game_ids = (data.ext_config?.game_ids ?? []).map(Number)` |
| `submit` type=7 | `data.ext_config = { ...data.ext_config, game_ids: 去重正整数数组 }`**不**覆盖其他 ext 字段 |
初始 `formData.ext_config` 在 type=7 时保证含 `game_ids: []`(避免 `v-model` 未定义)。
**不**复用首页导航的 cascader[`indexGameNav/edit.vue`](backend/slot_admin_vue/src/views/yyladmin/indexGameNav/edit.vue) 存的是 `nav-{id}-{brandId}`,与 `game_id` 语义不同)。
### 2.3 列表页展示
文件:[`src/views/game/activity/index.vue`](backend/slot_admin_vue/src/views/game/activity/index.vue)
`#ext_config` 插槽增加 type=7 分支:
-`game_ids`:展示 `推荐游戏: 101, 205, ...`(或显示数量 + tooltip
- 无配置:显示「未配置」灰色文案
## 3. 与现有编辑页结构的衔接
当前 type=7 属于 `onlyGift`,仍通过 **goods 卡片** 配置赠送金额(`goods_id: -1` 由 console 汇总),`ext_config` 此前为空。本次仅在 `ext_config` 增加 `game_ids`,与 goods 并存:
- 提交时 **不要** 像 type=11 那样 `data.goods = []`
- `submit` 中 type=7 仅 merge `game_ids``ext_config`
## 4. 验证方式
1. 后台新建/编辑 type=7 活动,选择游戏模型后多选 2+ 款已上架游戏,保存成功。
2. DB 检查:`s_recharge_gift_config.ext_config``"game_ids":[...]`
3. C 端 `POST /api/gift/receive` 领取该活动,响应含随机 `game_id`
4. 提交未上架 `game_id` 应被 admin 校验拦截。
5. `game_ids` 留空:保存成功,领取响应无 `game_id`
## 文件清单
| 仓库 | 文件 | 变更 |
| --- | --- | --- |
| slot_admin | `app/model/GameApiModel.php` | `optionsByGameModelId``publishedGameIds` |
| slot_admin | `app/game/controller/ActivityController.php` | `publishedGames`save/update 校验 type=7 |
| slot_admin | `app/game/validate/ActivityValidate.php` | `checkRegisterRewardExt` |
| slot_admin_vue | `src/api/game/activity.js` | `publishedGames` |
| slot_admin_vue | `src/views/game/activity/edit.vue` | type=7 多选 + 加载/读写 |
| slot_admin_vue | `src/views/game/activity/index.vue` | type=7 ext_config 展示 |
## 风险说明
- 切换「游戏模型」后已选 `game_ids` 可能与新模型不匹配:可在 `model_id` `@change` 时清空 `game_ids` 并提示重新选择(建议在实现时一并处理)。
- 游戏选项依赖 `s_game_api` 发布数据;未发布游戏不会出现在下拉,也无法通过校验提交。

View File

@@ -0,0 +1,73 @@
---
name: 测试 register bet win
overview: 设计一套不改代码的联调测试方案,覆盖 register、bet、win含中间派奖+最终结算)主流程、幂等与关键校验。输出可直接执行的请求序列和验收点。
todos:
- id: collect-endpoints
content: 整理 register/bet/win 的可调用入口与参数最小集合
status: completed
- id: define-test-cases
content: 设计主链路、幂等、参数异常三类用例
status: completed
- id: prepare-request-templates
content: 产出按执行顺序排列的请求模板与预期结果
status: completed
isProject: false
---
# Register/Bet/Win 测试计划
## 目标
- 用最小链路验证 `register -> bet -> win` 的资金变更与返回口径。
- 覆盖 `win` 的两阶段结算(`is_end=0` 中间派奖、`is_end=1` 最终结算)。
- 覆盖关键风控点:幂等(重复 `biz_id`)、参数校验(`round_id``is_end`、金额)。
## 关键实现依据(用于制定用例)
- 接口入口与兼容关系:[`app/api/controller/WalletController.php`](app/api/controller/WalletController.php)
- `wallet/bet``wallet/win` 为独立入口;`wallet/update(type=bet|win)` 复用同链路。
- 业务分发与资金处理:[`app/api/logic/WalletLogic.php`](app/api/logic/WalletLogic.php)
- `register``RegisterService::execute`
- `bet``biz_id` 做幂等。
- `win``is_end=0` 只累计待结算金额,`is_end=1` 才真正入账并清理缓存。
- 注册落账与 Lot 初始化:[`app/service/wallet/RegisterService.php`](app/service/wallet/RegisterService.php)
- 注册会初始化钱包、统计行,并按 `fee/bonus` 创建 Deposit/Bonus Lot。
- 请求参数约束:[`app/validator/Wallet2Validator.php`](app/validator/Wallet2Validator.php)
- bet/win 必传 `round_id``is_end` 仅支持 `0/1`
## 测试范围与步骤
1. **准备阶段**
- 选一个全新 `uid`(避免历史账干扰)。
- 固定 `currency/source/organization`,并准备唯一 `biz_id` 生成规则(例如带时间戳)。
2. **主链路测试Happy Path**
-`wallet/update` 发起 `register``type=register`,带 `fee``bonus`)。
-`wallet/wallet` 查询余额基线。
-`wallet/bet`(带 `round_id`)验证扣款与返回结构。
-`wallet/win``is_end=0`:确认返回中 `withdraw` 临时增加(待结算展示),但不落最终账。
-`wallet/win``is_end=1`:确认最终入账,并清理该 `round_id` 的待结算累计。
3. **幂等与异常场景**
- 重放同一 `bet.biz_id`:应命中幂等,余额不重复变化。
- 重放同一 `win.biz_id`:应命中幂等,余额不重复变化。
- 缺失 `round_id` 调 bet/win应返回参数错误。
- 传非法 `is_end`(如 2应返回参数错误。
4. **验收口径**
- 余额变化符合顺序:下注优先扣 Bonus再 Deposit再 Withdraw。
- `win` 仅在最终结算时落地资金结果;中间派奖只累计。
- 重复请求无重复记账,返回语义稳定。
## 建议请求流(逻辑顺序图)
```mermaid
flowchart TD
registerCall[register(update)] --> walletCheck1[wallet_query]
walletCheck1 --> betCall[bet]
betCall --> winMid[win_is_end_0]
winMid --> walletCheck2[wallet_query]
walletCheck2 --> winFinal[win_is_end_1]
winFinal --> walletCheck3[wallet_query]
walletCheck3 --> idemReplay[replay_same_biz_id]
```
## 交付物
- 一份可直接在 Postman/curl 执行的请求模板(含示例 body
- 一份对照清单:每一步的“预期返回 + 余额期望 + 幂等期望”。

View File

@@ -0,0 +1,82 @@
---
name: 测试报告可追踪输出
overview: 在现有 WalletRegisterBetWinTest 上增加“可追踪测试报告”:每次执行输出并落盘 uid/round_id/biz_id/余额快照,失败时可直接按用户复现。
todos:
- id: add-reporter-support
content: 新增 WalletTestRunContext 与 WalletTestReporterJSON+Markdown 落盘)
status: completed
- id: wire-test-class
content: 改造 WalletRegisterBetWinTest记录 uid/round/biz_idtearDown 输出摘要
status: completed
- id: config-and-doc
content: phpunit.xml 增加 WALLET_TEST_UID/REPORT_DIR更新 doc 执行说明
status: completed
- id: verify-run
content: 容器执行 phpunit 并确认控制台+报告文件含 uid
status: completed
isProject: false
---
# 钱包集成测试可追踪报告方案
## 问题
当前 [tests/Feature/WalletRegisterBetWinTest.php](tests/Feature/WalletRegisterBetWinTest.php) 每个用例都会 `makeUid()` 生成随机用户PHPUnit 默认只显示 `OK (3 tests, 37 assertions)`**看不到本次用了哪个 `uid``round_id``biz_id`**,联调排障和 DB 核对都不方便。
## 目标
- 跑完测试后,**控制台**能看到每个用例的测试用户与关键业务 ID。
- **落盘一份报告**JSON + 可读 Markdown便于复制 `uid` 去查库或手工 curl 复现。
- 可选:通过环境变量固定 `uid`,便于反复验证同一用户。
## 实现方案
### 1. 新增测试上下文与报告器
新增 [tests/Support/WalletTestRunContext.php](tests/Support/WalletTestRunContext.php)
- 字段:`testName`, `uid`, `currency`, `roundId`, `bizIds`register/bet/win_mid/win_final, `traceId`, `steps[]`(每步 API、code、余额快照, `status`, `errorMsg`
新增 [tests/Support/WalletTestReporter.php](tests/Support/WalletTestReporter.php)
- `startRun()` / `recordStep()` / `finishTest()` / `writeReport()`
- 报告目录:`runtime/test-reports/`(文件名含时间戳,如 `wallet-register-bet-win-20260515-160530.json` 与同名 `.md`
- Markdown 表格示例列:用例名 | uid | round_id | biz_id 列表 | 结果 | 最终余额(deposit/withdraw/b)
### 2. 改造现有 Feature 测试
在 [tests/Feature/WalletRegisterBetWinTest.php](tests/Feature/WalletRegisterBetWinTest.php) 中:
- `setUp()`:初始化 reporter若存在 `WALLET_TEST_UID` 则使用该固定 uid否则继续随机
- 每个 `test*` 开始:创建 `WalletTestRunContext` 并记录 uid/round_id/biz_id。
- `postJson()` / `wallet()`:成功后把 `code`、关键 `data`、查询余额写入 `steps`
- `tearDown()`:标记 pass/fail调用 `writeReport()`**向 STDOUT 打印一行摘要**PHPUnit 控制台可见),例如:
- `[WalletTest] testRegisterBetWinFlow uid=90012345 round=r_... bet=bet_... => PASS`
失败时 assertion message 附带 context 摘要,便于一眼定位用户。
### 3. 配置与文档
- [phpunit.xml](phpunit.xml) 增加可选 env
- `WALLET_TEST_UID`(空=随机)
- `WALLET_TEST_REPORT_DIR`(默认 `runtime/test-reports`
- `WALLET_TEST_VERBOSE``1` 时打印每步明细)
- 更新 [doc/register-bet-win-test.md](doc/register-bet-win-test.md) §7
- 报告路径说明
- 固定 uid 复现示例:`WALLET_TEST_UID=90012345 docker compose exec ... phpunit ...`
### 4. 执行验证
容器内执行:
```bash
docker compose exec -T -w /app/www/ray/slot-wallet php82 php vendor/bin/phpunit --filter WalletRegisterBetWinTest
```
验收:
- 控制台出现每个用例的 `uid` 摘要行
- `runtime/test-reports/` 生成 `.json` + `.md`
- 测试仍全部通过3 tests
## 报告结构示意
```mermaid
flowchart LR
testCase[WalletRegisterBetWinTest] --> context[WalletTestRunContext]
context --> reporter[WalletTestReporter]
reporter --> stdout[ConsoleSummary]
reporter --> jsonFile[runtime/test-reports/*.json]
reporter --> mdFile[runtime/test-reports/*.md]
```
## 不在本阶段做的(可选后续)
- HTML 可视化报告、CI artifact 上传
- DB 直连断言wallet_log / wallet_fund_lot

View File

@@ -0,0 +1,59 @@
---
name: 玩家任务进度查询
overview: 新增一个面向玩家的只读接口,返回当前 Bonus/Deposit 任务及其打码进度与详情,数据口径以 PRD 定义的 `wallet_fund_lot` 为准。保持现有资金主流程不变,仅补充查询链路与文档说明。
todos:
- id: define-player-task-endpoint
content: 新建独立玩家任务查询 Controller不改 WalletController并完成入参校验与统一返回
status: pending
- id: add-lot-query-service
content: 在 wallet 域新增只读查询服务,按 PRD 聚合 bonus/deposit 当前任务视图并计算进度字段
status: pending
- id: extend-fund-lot-model
content: 在 WalletFundLotModel 增加面向玩家任务页的查询方法按币种、lot_type、状态过滤与排序
status: pending
- id: document-api-contract
content: 更新 doc/wallet.md补充玩家任务进度查询接口契约与状态语义
status: pending
- id: verify-key-scenarios
content: 按基础与边界场景验证返回结构、进度计算和状态映射
status: pending
isProject: false
---
# 玩家查看Bonus/Deposit任务与打码进度计划
## 目标与口径
- 提供玩家侧查询能力:查看“当前仍在生命周期内”的 `Bonus``Deposit` 任务,以及每条任务的进度与关键详情。
- 数据源以 [`/Users/ray/Documents/project/www/ray/slot-wallet/app/model/multi/WalletFundLotModel.php`](/Users/ray/Documents/project/www/ray/slot-wallet/app/model/multi/WalletFundLotModel.php) 为准,不再使用旧 `wager_task` 作为玩家任务主视图。
- 任务状态遵循 PRD`Waiting/Active/PendingConversion/PlayedOut` 等),并输出前端可直接消费的进度字段。
## 接口设计(新增)
- 新建独立 Controller 承载玩家查询接口(建议新增 [`/Users/ray/Documents/project/www/ray/slot-wallet/app/api/controller/PlayerTaskController.php`](/Users/ray/Documents/project/www/ray/slot-wallet/app/api/controller/PlayerTaskController.php) 并提供 `taskProgress` 方法),不在 [`/Users/ray/Documents/project/www/ray/slot-wallet/app/api/controller/WalletController.php`](/Users/ray/Documents/project/www/ray/slot-wallet/app/api/controller/WalletController.php) 增加行为。
- 入参:`uid``currency`(可复用 [`/Users/ray/Documents/project/www/ray/slot-wallet/app/validator/WalletValidator.php`](/Users/ray/Documents/project/www/ray/slot-wallet/app/validator/WalletValidator.php) 现有校验,或为新 Controller 补充专用 scene/validator
- 出参建议:
- `summary`:当前 `bonus_task_count``deposit_task_count`
- `bonus_tasks[]``deposit_tasks[]`:每条含 `lot_id``lot_no``status``status_text``source_type``source_id``original_amount``remaining_amount``required_wager``current_wager``left_wager``progress_rate``created_at``completed_at``brief`
- “当前任务”默认筛选为:`status in (Waiting, Active, PendingConversion, PlayedOut)`;不返回 `Completed/Cancelled/Reversed`(避免历史噪音)。
## 分层落地
- Logic/Service 层新增只读查询编排(建议放在 wallet 域 service控制器不直接拼查询
- 查询指定用户指定币种的 Deposit/Bonus Lots
- 按类型分组并按 `consume_priority_at, id` 排序;
- 统一计算衍生字段:
- `left_wager = max(required_wager - current_wager, 0)`
- `progress_rate = required_wager > 0 ? min(current_wager / required_wager, 1) : 1`
- 统一状态文案映射(与 PRD 对齐)。
- Model 层在 [`/Users/ray/Documents/project/www/ray/slot-wallet/app/model/multi/WalletFundLotModel.php`](/Users/ray/Documents/project/www/ray/slot-wallet/app/model/multi/WalletFundLotModel.php) 补充专用查询方法(如按币种+lot_type+status 列表查询),保持 Controller/Logic 不下沉 SQL 细节。
## 文档与兼容
- 在 [`/Users/ray/Documents/project/www/ray/slot-wallet/doc/wallet.md`](/Users/ray/Documents/project/www/ray/slot-wallet/doc/wallet.md) 的 API 建议章节补充“玩家任务进度查询”示例(字段说明与状态语义)。
- 明确该接口是玩家视图;旧 [`/Users/ray/Documents/project/www/ray/slot-wallet/app/api/controller/TaskController.php`](/Users/ray/Documents/project/www/ray/slot-wallet/app/api/controller/TaskController.php) 维持兼容,不做破坏式改造。
## 验证计划
- 基础场景:同时存在 Bonus/Deposit Active 任务,返回分组正确、进度计算正确。
- 边界场景:
- `required_wager=0`(进度应视为 100%
- `current_wager > required_wager`(进度封顶 100%
- 仅有 PlayedOut Bonus仍应展示方便玩家理解“已用完未转化”
- 无当前任务(返回空数组与计数 0
- 一致性检查:字段值与 `wallet_fund_lot` 原始记录一致,且状态解释符合 PRD。

View File

@@ -0,0 +1,154 @@
---
name: 移除 RELEASING 状态
overview: 移除 Free Credits 玩家主状态 `STATUS_RELEASING(7)`,并同步实现「跨档领取 + 解锁不等待上一档 claim」后续档进度仅由 package 表表达玩家主状态停留在第一档生命周期4/5/6直至全部完成10
todos:
- id: remove-releasing-status
content: FreeCreditsPlayerModel 删除 STATUS_RELEASINGadvanceByRecharge 去掉写入 status=7
status: completed
- id: cross-claim-logic
content: claim() 去掉顺序校验advanceByRecharge 去掉 nextReadyReleasePackage 解锁门槛
status: completed
- id: update-docs-comments
content: 同步 FreeCreditsLogic/Controller PHPDoc 与 install.sql status 注释
status: completed
- id: admin-vue-status-map
content: slot_admin_vue freeCreditsStats 删除 status=7 映射与筛选项
status: completed
- id: migrate-legacy-7
content: 提供/执行 status=7 历史数据 SQL 修正
status: completed
- id: add-tests
content: 补充跨档 claim 与解锁不等待 claim 的单测/集成测
status: completed
isProject: false
---
# 移除 STATUS_RELEASING 并支持跨档领取
## 结论
**是的,应去掉** [`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php) 第 709 行的 `$player->save(['status' => STATUS_RELEASING])`
**是的,后台展示要改**[`slot_admin_vue`](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue) 的 `PLAYER_STATUS_MAP` 需删除 `7: '后续档释放中'`
**`slot_admin` PHP 无需改**[`FreeCreditsStatsController.php`](backend/slot_admin/app/game/controller/FreeCreditsStatsController.php) 仅透传 `slot_console` innerapi不做 status 映射。
## 背景:为何 status=7 已无意义
原设计里,玩家主状态在「第一档生命周期」之后还有一个独立阶段 `releasing(7)`
```mermaid
stateDiagram-v2
direction LR
firstCashReady: 4 第一档可提现
firstCashProcessing: 5 第一档处理中
firstCashDone: 6 第一档已提现
releasing: 7 后续档释放中
completed: 10 全部完成
firstCashReady --> firstCashProcessing
firstCashProcessing --> firstCashDone
firstCashDone --> releasing: 解锁后续档时写入7
releasing --> completed
```
跨档领取后:
- **后续档是否可领、已领多少** → 由 [`free_credits_package.status`](slot_console/app/model/common/FreeCreditsPackageModel.php) 表达(后台已有 `progress_done/total``claimed_amount_qf`
- **玩家主状态** → 只描述「第一档相关阶段」,全部档位完成时由现有 [`refreshPlayerCompletion()`](slot_console/app/api/logic/FreeCreditsLogic.php) 置为 `10`
`STATUS_RELEASING` 在全仓库**仅写入、从未被读取**做分支判断C 端 [`buildStatus()`](slot_console/app/api/logic/FreeCreditsLogic.php) 原样透出 `player.status`,去掉后不影响 package 列表展示。
## 代码改动slot_console
### 1. 删除 RELEASING 常量与写入
文件:[`FreeCreditsPlayerModel.php`](slot_console/app/model/common/FreeCreditsPlayerModel.php)
- 删除 `const STATUS_RELEASING = 7` 及注释
文件:[`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php) `advanceByRecharge()`
- 删除循环内 `$player->save(['status' => STATUS_RELEASING])`
- 解锁 release 档时**不再改玩家主状态**(保持 4/5/6 等既有值)
### 2. 跨档领取:去掉顺序限制
文件:[`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php)
**claim()** — 删除顺序校验块(约 295298 行):
```php
$nextReady = FreeCreditsPackageModel::nextReadyReleasePackage($player->id);
if (is_null($nextReady) || intval($nextReady->id) !== intval($package->id)) {
throw new BusinessException('Please claim credits in order');
}
```
保留:`package_type=release`、归属 uid、`status=ready`、幂等 biz_id、失败回滚 ready。
**advanceByRecharge()** — 去掉「存在 ready 后续档则不再解锁」门槛(约 695 行):
```php
// 改前
if ($rechargeAmount < $minRecharge || !is_null(FreeCreditsPackageModel::nextReadyReleasePackage($player->id))) {
return;
}
// 改后:仅校验单笔充值下限
if ($rechargeAmount < $minRecharge) {
return;
}
```
`nextReadyReleasePackage()` 方法可保留(暂无其它引用,后续若 C 端需要「推荐下一档」可复用)。
### 3. 文档注释同步
- [`FreeCreditsLogic::claim()`](slot_console/app/api/logic/FreeCreditsLogic.php) PHPDoc去掉「须按 package_no 顺序」
- [`FreeCreditsController::claim()`](slot_console/app/api/controller/FreeCreditsController.php) PHPDoc去掉「须为当前可领的下一档 / claim in order」
- [`install.sql`](slot_console/db/install.sql) `free_credits_player.status` 字段 comment删除 `7后续释放中`
### 4. 历史数据修正(建议一次性 SQL
线上若已有 `status=7` 的记录,应按第一档 package 还原为主状态,避免后台筛选项去掉 7 后显示裸数字:
```sql
UPDATE s_common.free_credits_player p
INNER JOIN s_common.free_credits_package fp
ON fp.player_id = p.id AND fp.package_no = 1
SET p.status = CASE fp.status
WHEN 2 THEN 5 -- 第一档处理中
WHEN 3 THEN 6 -- 第一档已完成
WHEN 1 THEN 4 -- 第一档可提现
ELSE 6
END
WHERE p.status = 7;
```
(若首档 package 不存在,默认 `6` 或按业务确认。)
## 后台展示改动slot_admin_vue
文件:[`freeCreditsStats/index.vue`](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue)
-`PLAYER_STATUS_MAP` 删除 `7: '后续档释放中'`
- `playerStatusOptions` 自动随之更新(由 map 生成)
- **可选兼容**:保留只读展示 `7: '第一档已提现(历史)'` 不加入筛选项,避免迁移前偶发裸数字;迁移 SQL 执行后可删
列表「当前状态」列继续读 `record.status`;后续档进度看已有的「完成进度」「已领取金额」「待释放金额」,不再依赖 releasing 主状态。
## 测试
在 [`slot_console/tests`](slot_console/tests) 补充/调整:
| 场景 | 预期 |
|------|------|
| 两档均为 readyclaim 较高 package_no | 成功,不再抛 `Please claim credits in order` |
| 上一档 ready 未 claim满足充值条件 | `advanceByRecharge` 仍可解锁下一 locked 档 |
| 解锁 release 档后 | `player.status` 不变(如仍为 `STATUS_FIRST_CASH_DONE` |
| 全部 package completed | `player.status``10` |
## 不在本次范围
- [`docs/requirements/首充前免费余额定格与分档释放需求文档.md`](docs/requirements/首充前免费余额定格与分档释放需求文档.md) §16/§20.3 仍写顺序领取与 releasing — 若需文档对齐可另开任务
- C 端前端slot_pwa 等)若曾按 `status===7` 做 UI 分支需自查;当前仓库内无引用

View File

@@ -0,0 +1,174 @@
---
name: 第一档提现审核拒绝
overview: 补齐管理后台「拒绝退钱/拒绝扣钱」对 Free Credits 第一档独立提现free_credit_first_cashout的处理Pay 侧识别业务类型、避免误动钱包,并通过已有 Console 总线回调恢复档位状态。
todos:
- id: persist-biz-type
content: WithdrawalOrderEntity::creatOrder 写入 data_snapshot.biz_type新增 isFreeCreditFirstCashout 判断
status: in_progress
- id: apply-skip-freeze
content: apply() 第一档跳过 withdrawFrozen 与 incTodayWithdrawalInfo
status: pending
- id: audit-reject-branch
content: audit() 拒绝退钱/扣钱:第一档跳过钱包,发 Fail/Rejected 总线;普通单保持原逻辑
status: pending
- id: mq-constants
content: slot_lib MQBusEntity 增加三条 FreeCredits 事件常量
status: pending
- id: event-withdrawal
content: EventWithdrawal 成功/失败按 biz_type 发 Success/Fail 总线,避免误 withdrawSuccess/Fail
status: pending
- id: tests
content: 补充 slot_pay 单测 + 手工验证后台拒绝退钱/扣钱
status: pending
isProject: false
---
# 第一档提现:后台审核拒绝处理
## 问题确认
管理后台 [`slot_admin_vue/.../withdrawal/index.vue`](backend/slot_admin_vue/src/views/game/order/withdrawal/index.vue) 点「拒绝退钱」(`audit_status=2`) / 「拒绝扣钱」(`audit_status=3`) → [`WithdrawalOrderController::audit`](backend/slot_admin/app/game/controller/WithdrawalOrderController.php) → `PayService::withdrawalAudit` → [`WithdrawalOrderEntity::audit`](slot_pay/app/entity/WithdrawalOrderEntity.php)。
当前 `audit()` **一律**对普通提现调用 `withdrawFail` / `withdrawDone`**不**通知 `slot_console``FreeCreditsLogic::handleFirstCashoutResult`
第一档提现([`FreeCreditsLogic::applyFirstCashoutWithdraw`](slot_console/app/api/logic/FreeCreditsLogic.php)`bizType=free_credit_first_cashout`)在提交时已 `markFirstCashoutProcessing`,审核拒绝后档位会卡在 **processing**C 端无法再次提现。
需求文档约定([§20.2](docs/requirements/首充前免费余额定格与分档释放需求文档.md)
| 后台操作 | 产品语义 | 应对 `handleFirstCashoutResult` |
|----------|----------|----------------------------------|
| 拒绝退钱 | 失败,可重试 | `success=false, rejected=false` → 档位 **ready** |
| 拒绝扣钱 | 风控拒绝 | `success=false, rejected=true` → 档位 **rejected** |
`slot_console` 已有消费端([`EventBus.php`](slot_console/app/command/EventBus.php) L133140**全库无生产端** 发送 `FreeCreditsFirstCashoutFail` / `Rejected` / `Success`
```mermaid
flowchart TB
subgraph today [现状]
adminReject[后台拒绝退钱]
payAudit[Pay audit]
walletFail[withdrawFail]
adminReject --> payAudit --> walletFail
end
subgraph target [目标]
adminReject2[后台拒绝退钱]
payAudit2[Pay audit 识别 bizType]
skipWallet[跳过钱包退冻]
mqFail[MQ FreeCreditsFirstCashoutFail]
fcLogic[handleFirstCashoutResult ready]
adminReject2 --> payAudit2 --> skipWallet
payAudit2 --> mqFail --> fcLogic
end
```
## 改造范围(主改 `slot_pay`,常量补 `slot_lib`
### 1. 订单落库时持久化 `biz_type`
文件:[`slot_pay/app/entity/WithdrawalOrderEntity.php`](slot_pay/app/entity/WithdrawalOrderEntity.php) — `creatOrder()`
-`WithdrawalInfo::$bizType` 非空,写入 `data_snapshot.biz_type`(表无独立字段,用现有 JSON 即可)。
- 常量与 C 端一致:`free_credit_first_cashout`(见 [`FreeCreditsLogic::BIZ_TYPE_FIRST_CASHOUT`](slot_console/app/api/logic/FreeCreditsLogic.php))。
新增私有方法(同文件):
```php
private function isFreeCreditFirstCashout(?WithdrawalInfo $info = null, ?self $order = null): bool
```
- 优先读 `$info->bizType`;审核/回调阶段读 `$order->data_snapshot['biz_type']`
### 2. 申请提现:第一档不冻普通钱包
文件:同上 — `apply()`
- `isFreeCreditFirstCashout($info)` 为 true 时:
- **跳过** `withdrawFrozen``UserTagService::incTodayWithdrawalInfo`
-`creatOrder`、人工审核分支、`WebSocketMqService::sendWithdrawalUrl` 保持不变。
- `catch` 中仅当曾冻结时才 `withdrawFail`(与现逻辑一致,第一档不会进入)。
对齐 [`WithdrawalInfo` 注释](slot_lib/src/entity/data/WithdrawalInfo.php) 与需求「第一档不进入普通钱包 withdraw」。
### 3. 审核拒绝/扣钱:分支 + 发总线(核心)
文件:同上 — `audit()`
`audit_status` 设为 2 或 3 后:
| 条件 | 钱包 | Console 总线 `type` | `handleFirstCashoutResult` |
|------|------|---------------------|----------------------------|
| 普通提现 + 拒绝退钱 | `withdrawFail` | 无 | — |
| 普通提现 + 拒绝扣钱 | `withdrawDone` | 无 | — |
| 第一档 + 拒绝退钱 | **不调**钱包;**不调** `decTodayWithdrawalInfo` | `FreeCreditsFirstCashoutFail` | ready可重提 |
| 第一档 + 拒绝扣钱 | **不调**钱包 | `FreeCreditsFirstCashoutRejected` | rejected |
实现方式Pay 依赖已有 `ConsoleMqService`
```php
use slotLib\common\mq\ConsoleMqService;
// audit 拒绝分支内:
if ($this->isFreeCreditFirstCashout(null, $order)) {
$event = $type === self::AUDIT_STATUS_REVIEW
? MQBusEntity::TYPE_FREE_CREDITS_FIRST_CASHOUT_FAIL
: MQBusEntity::TYPE_FREE_CREDITS_FIRST_CASHOUT_REJECTED;
ConsoleMqService::getInstance()->sendConsoleBusEvent($order->uid, $event, [
'order_id' => $order->order_id,
]);
} else {
// 现有 withdrawFail / withdrawDone + decTodayWithdrawalInfo
}
```
站内信逻辑可保留(第一档同样通知用户)。
### 4. 总线常量集中到 `slot_lib`
文件:[`slot_lib/src/entity/mq/MQBusEntity.php`](slot_lib/src/entity/mq/MQBusEntity.php)
新增(与 console 现有字符串一致):
- `TYPE_FREE_CREDITS_FIRST_CASHOUT_SUCCESS = 'FreeCreditsFirstCashoutSuccess'`
- `TYPE_FREE_CREDITS_FIRST_CASHOUT_FAIL = 'FreeCreditsFirstCashoutFail'`
- `TYPE_FREE_CREDITS_FIRST_CASHOUT_REJECTED = 'FreeCreditsFirstCashoutRejected'`
[`slot_console/app/entity/mq/MQBusEntity.php`](slot_console/app/entity/mq/MQBusEntity.php) 可改为 `use slotLib\entity\mq\MQBusEntity as LibMQBusEntity` 引用常量(可选,避免双份字符串)。
### 5. 顺带补齐:打款结果回调(建议同 PR
[`EventWithdrawal::updateOrder()`](slot_pay/app/command/EventWithdrawal.php) 在成功/失败时同样未区分第一档,会误调 `withdrawSuccess` / `withdrawFail`
在同一 PR 中按 `data_snapshot.biz_type` 分支:
- 成功 → 发 `FreeCreditsFirstCashoutSuccess`**不** `withdrawSuccess`
- 失败 → 发 `FreeCreditsFirstCashoutFail`**不** `withdrawFail`
- 普通单保持现状
否则后台「通过」后渠道失败/成功,档位状态仍会错乱。
## 不改动的部分
- **管理后台 UI**:无需改,仍调 `audit(record, 2|3)`
- **`FreeCreditsLogic::handleFirstCashoutResult`**:逻辑已满足需求,只补 Pay 侧触发。
- **数据库 DDL**:用 `data_snapshot`,无需加列。
## 历史订单
已产生、且 `data_snapshot``biz_type` 的第一档单:
- 拒绝时仍会走旧 `withdrawFail`(若当时已冻钱包则退钱正确,但档位仍卡 processing
- 运维可对已知 `order_id` 在 console 手工调用 `handleFirstCashoutResult`,或一次性 SQL 回填 `data_snapshot.biz_type`(按 `free_credits_package.withdraw_order_id` 关联)。
## 测试
| 层级 | 内容 |
|------|------|
| `slot_pay` 单测 | `creatOrder` 写入 `biz_type``isFreeCreditFirstCashout``audit(2)` 第一档 mock 不发 `withdrawFail`、发 MQmock `ConsoleMqService` |
| `slot_console` 已有 | [`FreeCreditsLogicCashoutResultTest`](slot_console/tests/Integration/FreeCreditsLogicCashoutResultTest.php) 覆盖 `handleFirstCashoutResult` |
| 手工 | 后台对第一档人工单:拒绝退钱 → C 端档位回 ready 可再提;拒绝扣钱 → rejected |
## 涉及文件小结
- [`slot_pay/app/entity/WithdrawalOrderEntity.php`](slot_pay/app/entity/WithdrawalOrderEntity.php) — `creatOrder` / `apply` / `audit` + helper
- [`slot_pay/app/command/EventWithdrawal.php`](slot_pay/app/command/EventWithdrawal.php) — 打款结果分支(建议同 PR
- [`slot_lib/src/entity/mq/MQBusEntity.php`](slot_lib/src/entity/mq/MQBusEntity.php) — 事件常量
- (可选)[`slot_console/app/entity/mq/MQBusEntity.php`](slot_console/app/entity/mq/MQBusEntity.php) — 引用 lib 常量

View File

@@ -0,0 +1,176 @@
---
name: 第一档提现或保留余额
overview: 为 Free Credits 第一档定格后首笔金额增加用户二选一沿用现有独立提现或新增「保留到可提现余额」withdraw_balance、不创建打码任务。涉及 slot_wallet 新钱包原子能力、slot_lib RPC、slot_console 新 C 端接口与档位状态同步。
todos:
- id: wallet-first-keep
content: slot_walletWalletLogModel + WalletLogic::freeCreditsFirstCashKeepwithdraw 入账、无 createTask+ 单测
status: completed
- id: slot-lib-rpc
content: slot_lib WalletService 增加 freeCreditsFirstCashKeep RPC
status: completed
- id: console-keep-api
content: slot_consoleFreeCreditsLogic::keepFirstCash + Validator + Controller + 路由
status: completed
- id: console-tests
content: slot_console 单测 FreeCreditsKeepFirstCashTest必要时调整 broadcast action
status: completed
- id: run-phpunit
content: php82 容器跑 console + wallet 相关单测
status: completed
isProject: false
---
# 第一档:提现或保留到可提现余额
## 背景与现状
当前第一档(`package_type=TYPE_FIRST_CASH`)在累计充值解锁后**仅支持**走 [`WithdrawService::applyFreeCreditsFirstCashout`](slot_console/app/service/WithdrawService.php) → Pay 独立提现(`bizType=free_credit_first_cashout`**不经过钱包余额**(需求文档 §15.3)。
后续释放档走 [`FreeCreditsLogic::claim`](slot_console/app/api/logic/FreeCreditsLogic.php) → [`WalletLogic::freeCreditsClaim`](slot_wallet/app/api/logic/WalletLogic.php):入账 **deposit_balance****创建 Y1 打码任务**
新需求:第一档金额用户可二选一:
| 选项 | 行为 |
| --- | --- |
| **Withdraw** | 保持现有 `/api/withdraw/apply` + `package_id` |
| **Keep to balance** | 入账 **withdraw_balance****不** `createTask`,金额立即可参与普通提现规则(无其它未完成打码任务时) |
已确认:保留余额入账 **withdraw_balance**(非 deposit
```mermaid
flowchart TD
firstReady[第一档 status=ready]
firstReady --> withdrawPath["POST /api/withdraw/apply"]
firstReady --> keepPath["POST /api/free-credits/keep-first-cash"]
withdrawPath --> payOrder[Pay 独立提现单]
payOrder --> busCallback[EventBus 提现结果]
busCallback --> pkgDone[package completed]
keepPath --> walletKeep[wallet freeCreditsFirstCashKeep]
walletKeep --> pkgDone
pkgDone --> playerDone[player STATUS_FIRST_CASH_DONE]
```
## 目标契约
### 新接口slot_console
- **路径**`POST /api/free-credits/keep-first-cash`(与现有 `claim` 命名风格一致)
- **入参**`package_id`(必填,`free_credits_package.id`,须为第一档且 `status=ready`
- **成功**`data` 结构同 `status()` / `claim``buildStatus`
- **幂等**`bizId = free_credits_first_keep:{packageId}`;重复请求不重复入账
- **互斥**:同一第一档 `processing/completed` 后不可再提现或 keep提现处理中不可 keep
### 档位与玩家状态(与提现成功对齐)
完成后与 [`handleFirstCashoutResult(success)`](slot_console/app/api/logic/FreeCreditsLogic.php) 一致:
- package → `STATUS_COMPLETED`,写 `completed_time``claim_biz_id` 存幂等键(复用字段,无需改表)
- player → `STATUS_FIRST_CASH_DONE`
- 调用 `refreshPlayerCompletion`
- 首页状态条关闭逻辑与「第一档提现成功」相同C 端仍用 `status` / packages[0] completed
### 钱包slot_wallet
新增 **`freeCreditsFirstCashKeep`**`WalletLogic::run``type` 分发):
- `inc(withdraw=fee, deposit=0, bonus=0)` 增加 **withdraw_balance**
- **不**调用 `createTask`
- 流水:`BIZ_TYPE_FREE_CREDITS_FIRST_KEEP = 'freeCreditsFirstCashKeep'``WALLET_TYPE_GIFT`(与 freeze/claim 一致)
- 事务内完成入账 + `addLog`;成功后 `sendConsoleBus`(可选,用于统计/通知,类型建议 `freeCreditsFirstCashKeep`
对比现有实现:
| 能力 | 入账 | 打码任务 |
| --- | --- | --- |
| `freeCreditsClaim`(后续档) | deposit | Y1 `createTask` |
| **`freeCreditsFirstCashKeep`(新)** | **withdraw** | **无** |
### slot_lib
[`WalletService`](slot_lib/src/services/WalletService.php) 增加常量与方法:
```php
const WALLET_TYPE_FREE_CREDITS_FIRST_KEEP = 'freeCreditsFirstCashKeep';
public function freeCreditsFirstCashKeep($amount, $bizId = ''): Wallet
```
## 实现步骤
### 1. slot_wallet
| 文件 | 改动 |
| --- | --- |
| [`WalletLogModel.php`](slot_wallet/app/model/multi/WalletLogModel.php) | `BIZ_TYPE_FREE_CREDITS_FIRST_KEEP` |
| [`WalletLogic.php`](slot_wallet/app/api/logic/WalletLogic.php) | `freeCreditsFirstCashKeep()`:镜像 `freeCreditsClaim` 事务结构,改为 `inc(fee,0,0)`**删除** `createTask` 调用 |
| 单测 | 新增 `WalletFreeCreditsFirstKeepTest`(或扩展现有 wallet 单测):断言 withdraw 增加、无 task 创建(可 mock `createTask` 不被调用) |
### 2. slot_lib
- [`WalletService.php`](slot_lib/src/services/WalletService.php):常量 + `freeCreditsFirstCashKeep($amount, $bizId)`
### 3. slot_console — Logic
[`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php) 新增 `keepFirstCash(int $uid, int $packageId): array`
1. `assertEligibleParticipant`
2. 查询 package`uid``TYPE_FIRST_CASH``STATUS_READY`
3. `bizId = 'free_credits_first_keep:' . $packageId`
4. package → `STATUS_PROCESSING`,写 `claim_biz_id`
5. `walletService->freeCreditsFirstCashKeep($package->amount_qf, $bizId)`
6. 成功 → package `COMPLETED` + 时间戳player `STATUS_FIRST_CASH_DONE``refreshPlayerCompletion`
7. 失败 → package 回滚 `READY`(同 `claim`
8. 返回 `buildStatus`
**不**改 `advanceByRecharge` / 定格冻结逻辑。
### 4. slot_console — API 层
| 文件 | 改动 |
| --- | --- |
| [`FreeCreditsValidator.php`](slot_console/app/api/validator/FreeCreditsValidator.php) | `SCENE_KEEP_FIRST_CASH``package_id` require\|integer |
| [`FreeCreditsController.php`](slot_console/app/api/controller/FreeCreditsController.php) | `keepFirstCash()` + PHPDoc类注释补充第二路径 |
| 路由配置 | 注册 `POST /api/free-credits/keep-first-cash`(查项目现有 `route` / `config``free-credits` 注册方式,与 `claim` 并列) |
### 5. 广播 / 统计(小改)
- [`buildBroadcastList`](slot_console/app/api/logic/FreeCreditsLogic.php):第一档 completed 若走 keep广播 `action` 可新增 `keep`(或复用 `claim`);与产品确认文案前可先 `keep`
- [`FreeCreditsStatsLogic`](slot_console/app/innerapi/logic/FreeCreditsStatsLogic.php)`first_cashout_*` 按「第一档 completed」统计**keep 与 withdraw 均计入**(无需区分,除非运营后续要拆指标)
### 6. 单测slot_console
| 文件 | 内容 |
| --- | --- |
| 新增 `FreeCreditsKeepFirstCashTest` | harness 注入 mock `WalletService`;断言状态迁移、幂等、非 ready 抛错 |
| 可选集成测 | keep 后 package completed + player `FIRST_CASH_DONE` |
运行:
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/FreeCreditsKeepFirstCashTest.php
docker exec -w /app/www/slot/slot_wallet php82 ./vendor/bin/phpunit tests/Unit/WalletFreeCreditsFirstKeepTest.php
```
## C 端约定(供联调,本次可不改 PWA
第一档 `packages[0].status === 1` 时展示两个入口:
- **Withdraw** → 现有 `POST /api/withdraw/apply` + `package_id`
- **Keep to Balance** → `POST /api/free-credits/keep-first-cash` + `package_id`
完成后 `packages[0].status === 3`,与提现成功 UI 一致(按钮隐藏 / 成功态)。
## 不在本次范围
- slot_pwa / gateway 前端页面与文案
- 需求文档 §1011 全文修订可后续补「Add to Balance」分支
- 修改后续档 `claim` 的打码规则
- 第一档金额改由后台配置动态展示(仍从 `packages[0].amount` 读取)
## 验收清单
1. 第一档 readykeep 成功 → withdraw_balance 增加对应千分位,**无**新 WagerTask
2. 同一 package 重复 keep幂等不重复加钱
3. keep 成功后package/player 状态与提现成功一致;`status` 接口 packages[0] 为 completed
4. 第一档 processing提现中keep 拒绝
5. 第一档 completedwithdraw / keep 均拒绝
6. 相关单测通过

View File

@@ -0,0 +1,162 @@
---
name: 第一档提现独立接口
overview: 将 Free Credits 第一档免打码提现从 `POST /api/withdraw/apply` 拆出,新增 `POST /api/free-credits/withdraw-first-cash`(与 `keep-first-cash` 并列);普通提现接口保持原样,不再识别 `package_id`
todos:
- id: validator-migrate
content: FreeCreditsValidator 增加 withdraw-first-cash 各支付 sceneWithdrawValidator 移除 *_fc
status: completed
- id: controller-endpoint
content: FreeCreditsController::withdrawFirstCash + 注释更新
status: completed
- id: withdraw-service-split
content: WithdrawService 抽出 public applyFreeCreditsFirstCashapply() 去掉 package_id 分支
status: completed
- id: withdraw-controller-clean
content: WithdrawController::apply 仅保留普通提现校验与文案
status: completed
- id: tests-update
content: 迁移/更新 Validator 与 FreeCreditsFirstCashoutApplyTest 单测
status: completed
isProject: false
---
# 第一档提现独立接口
## 现状
第一档独立提现与**普通钱包提现**共用同一入口:
```mermaid
flowchart LR
client[C端]
withdrawApply["POST /api/withdraw/apply"]
withdrawSvc[WithdrawService::apply]
fcLogic[FreeCreditsLogic::applyFirstCashoutWithdraw]
pay[PayService::apply]
client --> withdrawApply
withdrawApply -->|"package_id > 0"| withdrawSvc
withdrawApply -->|"无 package_id"| withdrawSvc
withdrawSvc --> fcLogic --> pay
```
关键代码:
- [`WithdrawController::apply`](slot_console/app/api/controller/WithdrawController.php)`package_id` 非空时走 `WithdrawValidator::firstCashoutScene`
- [`WithdrawService::apply`](slot_console/app/service/WithdrawService.php) L167168`package_id > 0` 时调用 `applyFreeCreditsFirstCashout` → [`FreeCreditsLogic::applyFirstCashoutWithdraw`](slot_console/app/api/logic/FreeCreditsLogic.php)
- 第一档「保留余额」已是独立接口:`POST /api/free-credits/keep-first-cash`[`FreeCreditsController`](slot_console/app/api/controller/FreeCreditsController.php)
目标形态:
```mermaid
flowchart LR
normal["POST /api/withdraw/apply\namount + 绑卡"]
fcWithdraw["POST /api/free-credits/withdraw-first-cash\npackage_id + 绑卡"]
keep["POST /api/free-credits/keep-first-cash\npackage_id"]
normal --> walletWithdraw[余额/手续费/黑规则]
fcWithdraw --> fcLogic[FreeCreditsLogic]
keep --> walletKeep[freeCreditsFirstCashKeep]
```
## 新接口契约
| 项 | 约定 |
| --- | --- |
| 路径 | `POST /api/free-credits/withdraw-first-cash`Webman 默认路由 → `FreeCreditsController::withdrawFirstCash` |
| 入参 | `package_id`(必填)、`type`1/2/3/6、各支付方式绑卡字段与现 `WithdrawValidator``*_fc` scene 一致)、可选 `pay_net` |
| 成功 `data` | `{ order_id, amount }`(与现第一档走 `withdraw/apply` 的返回一致,**不**改 `buildStatus` |
| 成功 `msg` | 沿用现第一档文案:`Submitted successfully! Your order is under review...` |
| 业务逻辑 | **复用** `FreeCreditsLogic::applyFirstCashoutWithdraw`Pay 回调、`handleFirstCashoutResult`、档位状态机**不变** |
与 [`keep-first-cash`](slot_console/app/api/controller/FreeCreditsController.php) 对称:同属 Free Credits 活动域,第一档二选一:
- **Withdraw** → `POST /api/free-credits/withdraw-first-cash`
- **Keep** → `POST /api/free-credits/keep-first-cash`
## 实现步骤(仅 slot_console
### 1. 新 Controller 方法
文件:[`FreeCreditsController.php`](slot_console/app/api/controller/FreeCreditsController.php)
- 新增 `withdrawFirstCash(Request $request)`
- 校验 → 构建 `WithdrawApplyDTO`(复用现有 DTO`package_id` + 绑卡字段)→ 调用提现编排(见下)
- 更新类注释:第一档提现改指向新路径
### 2. 校验迁移到 FreeCreditsValidator
文件:[`FreeCreditsValidator.php`](slot_console/app/api/validator/FreeCreditsValidator.php)
从 [`WithdrawValidator`](slot_console/app/api/validator/WithdrawValidator.php) **迁入**
- `SCENE_WITHDRAW_FIRST_CASH_CASH` / `_BTC` / `_USDT` / `_PAYPAL`(命名可与原 `*_fc` 对齐或重命名)
- `type``user_name``cash_tag``btc``usdt``paypal_*``email` 等 rule`*_fc` scene 字段集)
- 静态方法 `firstCashoutScene(int $type): string`(原 `firstCashoutScene`
[`WithdrawValidator`](slot_console/app/api/validator/WithdrawValidator.php)**删除** `SCENE_APPLY_*_FC``firstCashoutScene` 及对应 scene 配置。
### 3. 提现编排仍放 WithdrawService绑卡 + 锁)
文件:[`WithdrawService.php`](slot_console/app/service/WithdrawService.php)
- 将现有 `applyFreeCreditsFirstCashout` 提升为 **`public function applyFreeCreditsFirstCash(WithdrawApplyDTO $applyDTO): array`**,内容包含:
- `is_bind_name` 校验
- Redis 防重复提交锁(与 `apply` 相同 key
- `checkBankInfo` + `FreeCreditsLogic::applyFirstCashoutWithdraw`
- [`apply()`](slot_console/app/service/WithdrawService.php)**删除** `if ($applyDTO->package_id > 0)` 分支,仅保留普通提现路径
说明:绑卡逻辑仍在 `WithdrawService` 私有方法中Logic 层不重复实现,避免违反分层「不中转 Service」时仍复用已有绑卡能力。
### 4. 精简 WithdrawController
文件:[`WithdrawController.php`](slot_console/app/api/controller/WithdrawController.php)
- `apply()` 仅按 `type` 选普通 scene`(string) $type`
- 移除 `package_id` 分支与差异化 `msg`
**不对**旧接口传 `package_id` 做拒绝或转发(按你的确认:无需处理)。
### 5. DTO / 字段
[`WithdrawApplyDTO`](slot_console/app/api/dto/request/WithdrawApplyDTO.php)`package_id` 字段可保留供新接口使用;注释改为「仅 free-credits/withdraw-first-cash 使用」。普通 `withdraw/apply` 不再读取该字段。
### 6. 单测与文档注释
| 文件 | 改动 |
| --- | --- |
| [`WithdrawValidatorFreeCreditsTest.php`](slot_console/tests/Unit/WithdrawValidatorFreeCreditsTest.php) | 迁至 `FreeCreditsValidatorWithdrawFirstCashTest`(或合并进 `FreeCreditsValidatorTest`),断言新 scene |
| [`FreeCreditsFirstCashoutApplyTest.php`](slot_console/tests/Integration/FreeCreditsFirstCashoutApplyTest.php) | 改为调用 `WithdrawService::applyFreeCreditsFirstCash`(或经新 Controller harness断言仍是不走余额校验、Pay 失败回滚 ready |
| [`FreeCreditsController.php`](slot_console/app/api/controller/FreeCreditsController.php) / [`FreeCreditsValidator.php`](slot_console/app/api/validator/FreeCreditsValidator.php) | 注释同步新路径 |
运行:
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit \
tests/Unit/FreeCreditsValidatorTest.php \
tests/Unit/WithdrawValidatorFreeCreditsTest.php \
tests/Integration/FreeCreditsFirstCashoutApplyTest.php
```
(迁移后调整具体测试文件名。)
## 不在本次范围
- slot_pwa / gateway 前端改 URL联调时 C 端将 Withdraw 按钮从 `/api/withdraw/apply` 改为 `/api/free-credits/withdraw-first-cash`
- slot_pay / wallet / EventBus 回调逻辑
- `keep-first-cash``claim``status` 行为
## C 端联调要点
第一档 `packages[0].status === 1` 时:
- **Withdraw** → `POST /api/free-credits/withdraw-first-cash` + `package_id` + 支付方式字段
- **Keep** → 现有 `POST /api/free-credits/keep-first-cash`
普通提现页仍用 `POST /api/withdraw/apply``amount` 必填,**不传** `package_id`)。
## 验收
1. 新接口:第一档 ready + 合法绑卡 → 返回 `order_id`/`amount`package → `processing`Pay 失败回滚 `ready`
2. 新接口:非 ready / 无资格 / processing 中 → 业务异常与现逻辑一致
3. `POST /api/withdraw/apply`:仅普通提现;带 `amount` 走余额/手续费校验;**不再**因 `package_id` 进入 Free Credits 分支
4. `keep-first-cash` / `claim` / 提现结果回调回归不受影响
5. 相关单测通过

View File

@@ -0,0 +1,120 @@
---
name: 第一档提现统计入账
overview: 第一档免打码提现成功后Pay 的 `EventWithdrawal` 在发 Console 总线之外,需经 slot_lib 调用 wallet 新增「仅累加 total_withdraw」接口把提现金额记入分片 `wallet_stat_xx`,与普通提现 `withdrawSuccess` 的统计口径对齐。
todos:
- id: wallet-stat-method
content: slot_walletWalletLogModel 常量 + WalletLogic::freeCreditsFirstCashoutStatinc total_withdraw + 幂等)
status: completed
- id: slot-lib-client
content: slot_lib WalletService常量 + freeCreditsFirstCashoutStat() 封装 HTTP update
status: completed
- id: pay-event-hook
content: slot_pay EventWithdrawal第一档成功分支调用 freeCreditsFirstCashoutStat(order_id, amount)
status: completed
- id: tests
content: wallet 单测幂等与 stat incpay 单测/mock 验证调用链
status: completed
- id: backfill-optional
content: (可选)历史成功第一档订单回填 total_withdraw 脚本说明
status: completed
isProject: false
---
# 第一档提现成功wallet_stat 累计提现额补齐
## 背景与缺口
首充定格第一档(`free_credit_first_cashout`)在 [EventWithdrawal.php](slot_pay/app/command/EventWithdrawal.php) 打款成功时已按设计 **跳过** `withdrawSuccess` / `withdrawFail`,只发 Console 总线:
```117:122:slot_pay/app/command/EventWithdrawal.php
if (WithdrawalOrderEntity::isFreeCreditFirstCashoutOrder($order)) {
ConsoleMqService::getInstance()->sendConsoleBusEvent($order->uid, MQBusEntity::TYPE_FREE_CREDITS_FIRST_CASHOUT_SUCCESS, [
'order_id' => $order->order_id,
]);
} else {
$walletService->withdrawSuccess($withdrawalAmount, $order->order_id);
```
普通提现的 `total_withdraw` 仅在 [WalletLogic::withdraw()](slot_wallet/app/api/logic/WalletLogic.php)`type=withdraw`)里 `WalletStatModel::inc(..., 'total_withdraw', fee)`,且会 `finishWithdraw` 扣减 `withdraw_lock`。
第一档 **不冻结** 普通钱包([WithdrawalOrderEntity::apply](slot_pay/app/entity/WithdrawalOrderEntity.php) 已跳过 `withdrawFrozen`),因此 **不能** 直接调 `withdrawSuccess`——会动 `withdraw_lock` 并可能失败。
遗留问题:**`wallet_stat_{xx}.total_withdraw` 未累加**,后台用户列表/盈亏等读 `tw`/`total_withdraw` 会偏小。
```mermaid
sequenceDiagram
participant Pay as slot_pay EventWithdrawal
participant Console as slot_console EventBus
participant Wallet as slot_wallet WalletLogic
Note over Pay: 现状(成功)
Pay->>Console: FreeCreditsFirstCashoutSuccess
Note over Wallet: total_withdraw 未更新
Note over Pay: 目标(成功)
Pay->>Console: FreeCreditsFirstCashoutSuccess
Pay->>Wallet: freeCreditsFirstCashoutStat(order_id, amount)
Wallet->>Wallet: inc total_withdraw + wallet_log
```
---
## 推荐方案wallet 新增「仅统计」类型
与现有 `freeCreditsFreeze` / `freeCreditsFirstCashKeep` 一致:走 `POST api/wallet/update``WalletLogic::run()` 按 `type` 分发,**不改** `wallet_account` 余额与 `withdraw_lock`。
### 1. slot_wallet
| 文件 | 改动 |
|------|------|
| [WalletLogModel.php](slot_wallet/app/model/multi/WalletLogModel.php) | 新增 `BIZ_TYPE_FREE_CREDITS_FIRST_CASHOUT_STAT = 'freeCreditsFirstCashoutStat'` |
| [WalletLogic.php](slot_wallet/app/api/logic/WalletLogic.php) | 新增 `freeCreditsFirstCashoutStat()``fee>0` 时事务内 `WalletStatModel::inc(currency, 'total_withdraw', fee)` + `addLog(0, balance, 新 biz_type, WALLET_TYPE_GIFT)`(流水 amount=0 或记负向备注,与 first_keep 风格一致) |
| 幂等 | `biz_id` 使用 **提现订单号** `order_id`(与 `withdrawSuccess` 一致);`wallet_log` 表已有 `uniq_uid_bizid_type (uid, biz_id, biz_type)`。重复 MQ 消费时 `addLog` 唯一键冲突应 **视为成功**(查已有流水则直接返回当前 wallet 快照,不二次 `inc`)——可参考项目内其它 Free Credits 写法的异常处理,若无统一模式则在方法开头 `findOne(['biz_id','biz_type'])` 短路 |
**刻意不做:**
- 不调 `finishWithdraw` / `doneWithdraw`
- 不 `sendConsoleBus('Withdrawal')`(避免与普通提现总线混淆)
- 失败/拒绝路径 **不** dec `total_withdraw`(申请时未 inc与 `manualRefund` 对称性无关)
### 2. slot_lib
| 文件 | 改动 |
|------|------|
| [WalletService.php](slot_lib/src/services/WalletService.php) | 常量 `WALLET_TYPE_FREE_CREDITS_FIRST_CASHOUT_STAT = 'freeCreditsFirstCashoutStat'` + 方法 `freeCreditsFirstCashoutStat($amount, $bizId = '')` |
Pay 继续用现有 `slotLib\services\WalletService`(与 `withdrawSuccess` 同路径),**不**在本需求引入 `slot_sdk`(与当前 pay→wallet 一致)。
### 3. slot_pay
| 文件 | 改动 |
|------|------|
| [EventWithdrawal.php](slot_pay/app/command/EventWithdrawal.php) | 第一档成功分支:在发 Console 总线 **之后**(或之前,顺序无关)调用 `$walletService->freeCreditsFirstCashoutStat($withdrawalAmount, $order->order_id)` |
| 可选对齐 | 普通成功会 `UserTagService::incSuccessWithdrawalInfo`;若产品希望第一档也计入「成功提现次数/金额」标签,可同分支补上;**若仅关心 wallet_stat可不加**(需你确认时可单独加) |
失败/拒绝分支 **保持现状**(只发 Fail/Rejected 总线,不调 wallet
### 4. 测试
| 仓库 | 内容 |
|------|------|
| `slot_wallet` | 单测:`freeCreditsFirstCashoutStat` 累加 `total_withdraw`、相同 `biz_id` 幂等 |
| `slot_pay` | 扩展 [WithdrawalOrderFreeCreditsTest.php](slot_pay/tests/Unit/WithdrawalOrderFreeCreditsTest.php) 或 mock `EventWithdrawal::updateOrder`:第一档成功应调用新方法、不调 `withdrawSuccess` |
---
## 历史数据
已打款成功、但 `total_withdraw` 未记的第一档订单:可写一次性运维脚本(按 `free_credits_package.withdraw_order_id` + pay 订单金额)批量调新接口或 SQL `inc``biz_id` 用原 `order_id` 保证幂等。
---
## 涉及文件小结
- [slot_wallet/app/api/logic/WalletLogic.php](slot_wallet/app/api/logic/WalletLogic.php)
- [slot_wallet/app/model/multi/WalletLogModel.php](slot_wallet/app/model/multi/WalletLogModel.php)
- [slot_lib/src/services/WalletService.php](slot_lib/src/services/WalletService.php)
- [slot_pay/app/command/EventWithdrawal.php](slot_pay/app/command/EventWithdrawal.php)
- 测试:`slot_wallet/tests/Unit/...`、`slot_pay/tests/Unit/WithdrawalOrderFreeCreditsTest.php`
**不改:** `FreeCreditsLogic::handleFirstCashoutResult`档位状态已覆盖、Console EventBus 消费端。

View File

@@ -0,0 +1,148 @@
---
name: 跨服务 slot_sdk 约束
overview: 在用户级 Cursor 规则目录新增一条「跨服务通信」约束,用统一、可操作的术语规定:业务服务之间的 HTTP 互调必须经 `slot/sdk``slotsdk`)完成,并与现有 `backend-layering` 规则互补而不重复。
todos:
- id: create-mdc
content: 新建 /Users/ray/.cursor/rules/cross-service-sdk.mdcalwaysApply + 术语与调用规范)
status: completed
- id: consistency-check
content: 对照 backend-layering.mdc 确认交叉引用一致、无重复分层表
status: completed
- id: optional-readme
content: (可选)在 slot_sdk/readme.md 增加简短架构说明
status: completed
isProject: false
---
# 跨服务通信 Cursor 用户级约束
## 背景与目标
当前用户级规则在 [`/Users/ray/.cursor/rules/`](file:///Users/ray/.cursor/rules/) 已有:
- [`backend-layering.mdc`](file:///Users/ray/.cursor/rules/backend-layering.mdc) — 单服务内 Controller / Logic / Service 分层
- [`dev-environment.mdc`](file:///Users/ray/.cursor/rules/dev-environment.mdc) — Docker 本地开发
- [`php-doc.mdc`](file:///Users/ray/.cursor/rules/php-doc.mdc) — PHPDoc 规范
[`backend-layering.mdc`](file:///Users/ray/.cursor/rules/backend-layering.mdc) 仅在 Service 层职责里顺带提到「sdk」**没有**规定跨服务 HTTP 的入口、命名与新增 API 的流程。本次新增**独立规则**(单一职责,符合 create-rule 实践),`alwaysApply: true`,与现有三条规则一致。
代码库事实(供规则用语对齐):
- 包名:`slot/sdk`,命名空间 `slotsdk\`,仓库 [`slot_sdk`](file:///Users/ray/Documents/project/www/slot/slot_sdk)
- 典型消费方:`slot_admin``slot_agent``slot_console``slot_pwa`composer 依赖 `slot/sdk`
- 典型被调方:`slot_wallet``slot_user``slot_center` 等,对外暴露 `innerapi/*``api/*`
- 推荐调用链:`new {Domain}Client($config)->service()->{method}(...)`
- 项目内已有表述:[`slot_agent/doc/feature_agent.md`](file:///Users/ray/Documents/project/www/slot/slot_agent/doc/feature_agent.md) —「代理服不直连用户域表,统一通过 `slot_sdk` 调用户服 innerapi」
按你的选择:**规则只约束新代码走 slot_sdk不写 InnerCurlService / slot_lib 等 legacy 迁移条款。**
---
## 术语优化(写入规则正文)
| 避免说法 | 推荐说法 | 说明 |
| --- | --- | --- |
| 后端服务之间调用 / 中转 | **跨服务 HTTP 调用** | 明确是进程间 HTTP不是本地 Logic/Service |
| 通过 slot/sdk 中转 | **经 slot_sdk 调用** | `slot_sdk` = 客户端库;被调服务仍直接处理请求,库不做业务中转 |
| SDK / 封装 | **slot_sdk`slot/sdk`** | 与 composer 包名、仓库目录一致 |
| 各服务自己拼 URL | **在 slot_sdk 增加 `{Domain}Service` 方法** | 路径与 DTO 单点维护 |
| `app\service\WalletService` | **本地 WalletService** vs **slotsdk WalletService** | 防止与 SDK 类名混淆 |
核心定义(规则开篇 1 段):
> **slot_sdk** 是跨服务 HTTP 客户端库(`composer` 包 `slot/sdk`,命名空间 `slotsdk\`),用于**调用方服务**访问**被调服务**的 `innerapi/*` 或 `api/*` 接口。它不是独立部署的微服务。
---
## 拟新增文件
**路径:** [`/Users/ray/.cursor/rules/cross-service-sdk.mdc`](/Users/ray/.cursor/rules/cross-service-sdk.mdc)
**Frontmatter**
```yaml
---
description: 跨服务 HTTP 须经 slot_sdkslot/sdk调用禁止在业务服务内散落直连
alwaysApply: true
---
```
**正文结构(约 3545 行,中文为主):**
### 1. 适用范围
- 一个 Webman 服务需要 HTTP 访问另一个服务的 `innerapi` / `api` 时适用。
- **被调服务**自身实现 Controller/Logic/Model**不**为「被别人调」而引入 `slot/sdk`
- **调用方**若需调第三个服务,必须通过 `slot/sdk`(不在业务代码里手写 Guzzle/curl/拼 host+path
### 2. 标准调用方式
```php
use slotsdk\Config as SDKConfig;
use slotsdk\service\wallet\WalletClient;
$config = new SDKConfig([
'host' => ShareConfigService::get('walletApiHost'),
'headers' => ['server-name' => config('app.server_name')],
]);
$result = (new WalletClient($config))->service()->getStatistics($uids, $currency);
```
要点:
- Host 来自 center 下发的 `*ApiHost`(如 `walletApiHost``userApiHost`)。
- 请求头带 `server-name`,值为**当前调用方**服务名。
- 非零 `code``slotsdk\exception\ApiException` 抛出,调用方在 Logic/Gateway 层处理。
### 3. 新增 / 变更远程接口的流程
1. 在 [`slot_sdk/src/service/{domain}/`](file:///Users/ray/Documents/project/www/slot/slot_sdk/src/service/) 增加 `{Domain}Service` 方法、路径常量、必要时 `entity/*Entity`
2. 在调用方 Logic 或 `*GatewayService` 中调用Controller 保持薄。
3. **禁止**在 `slot_admin` / `slot_agent` 等多仓库重复写同一路径字符串。
### 4. 与 backend-layering 的衔接12 句)
- 跨服务访问在调用方落在 **Service 或 `*GatewayService`**Logic 编排用例;不把 HTTP 细节散落在 Controller。
- 与 [`backend-layering.mdc`](file:///Users/ray/.cursor/rules/backend-layering.mdc) 中「Service 可承载 sdk」一致本规则专门约束**跨服务边界**,不重复写分层表。
### 5. 命名与混淆规避
- SDK`slotsdk\service\{domain}\{Domain}Client``{Domain}Service``entity\{Name}Entity`
- 本地:`app\service\*`;若同名,用 `SDKConfig`、完整 namespace 或 import alias。
- 消费方封装重复调用:`UserReferralGatewayService` 这类 `*GatewayService` 模式。
---
## 与现有规则的关系
```mermaid
flowchart LR
subgraph userRules [用户级 .cursor/rules]
layering[backend-layering]
crossSdk[cross-service-sdk 新增]
docker[dev-environment]
phpdoc[php-doc]
end
layering -->|"单服务内分层"| Logic
crossSdk -->|"服务间 HTTP"| slot_sdk
slot_sdk --> innerapi[被调服务 innerapi/api]
```
- **不修改** [`backend-layering.mdc`](file:///Users/ray/.cursor/rules/backend-layering.mdc):避免一条规则过长;仅在 `cross-service-sdk` 末尾用交叉引用衔接。
- **不修改** [`dev-environment.mdc`](file:///Users/ray/.cursor/rules/dev-environment.mdc) / [`php-doc.mdc`](file:///Users/ray/.cursor/rules/php-doc.mdc)。
---
## 实施步骤(确认计划后执行)
1. 创建 [`cross-service-sdk.mdc`](/Users/ray/.cursor/rules/cross-service-sdk.mdc),填入上述 frontmatter 与正文。
2. 通读四条 `alwaysApply` 规则,确认无矛盾表述(尤其 Service 层与 Gateway 分工)。
3. (可选)在 [`slot_sdk/readme.md`](file:///Users/ray/Documents/project/www/slot/slot_sdk/readme.md) 补 510 行架构说明并链到 Cursor 规则 — **仅当你希望仓库内也有文档镜像**;非本次必需。
---
## 验收标准
- 新开 Cursor 会话、编辑任意 slot PHP 文件时Agent 应自动带上「跨服务须经 slot_sdk、新 API 先改 slot_sdk」约束。
- 术语统一使用:**跨服务 HTTP**、**调用方 / 被调方**、**slot_sdk**,避免「中转」「服务间随便 HTTP」等模糊说法。
- 规则正文不含 legacy 迁移条款(按你的选择)。

View File

@@ -0,0 +1,223 @@
---
name: 首充定格资金修复
overview: 本次仅改 slot_wallet 与 slot_console。wallet 删除 Recharge bus、首充发 free_credit_initconsole 消费该事件完成定格。pay 等其它服务不在本次范围。
todos:
- id: wallet-remove-recharge-bus
content: 删除 WalletLogic recharge/rechargeSign 中 sendConsoleBus('Recharge')
status: completed
- id: wallet-send-free-credit-init
content: recharge + rechargeSign 首充时发送 free_credit_initinc/改账前采 balance_before_qf
status: completed
- id: wallet-remove-create-wager-first-recharge
content: CreateWagerTask 删除 version1/version2 首充特殊打码分支,首充走普通充值打码
status: completed
- id: console-event-bus-handler
content: EventBus case free_credit_init + FreeCreditInitEvent
status: completed
- id: console-free-credit-init-logic
content: handleFreeCreditInitRechargeEvent 移除首充定格,保留档位推进
status: completed
- id: console-safe-freeze-order
content: freezeFirstRecharge 先 RPC 后落库;上限与幂等
status: completed
- id: mq-reliability
content: free_credit_init 失败 nack/requeue仅 console EventBus
status: completed
- id: tests-and-repair
content: wallet/console 单测与集成测FreeCreditsFreezeDev 补偿console
status: completed
isProject: false
---
# 首充定格资金操作修复方案(修订 v4
## 变更范围(硬约束)
**本次仅编辑以下仓库/服务,不修改任何其它服务(含 slot_pay、slot_lib 等):**
| 在范围内 | 不在范围内 |
|----------|------------|
| `slot_wallet` | `slot_pay` |
| `slot_console` | `slot_lib``slot_agent`、… |
> `Recharge` 总线消息假定由 **pay 或其它既有链路** 发送;本次不从 wallet 重复发送,也**不改 pay** 去补发。若线上 pay 未发 `Recharge`,统计/档位问题需另开 pay 任务,**不纳入本 PR**。
---
## 设计原则
> **情愿用户定格失败,也不能让系统亏钱。**
| 服务(本次) | 职责 |
|--------------|------|
| **slot_wallet** | 入账;首充发 **`free_credit_init`****删除** `Recharge` bus**移除** `CreateWagerTask` 旧首充打码拆分 |
| **slot_console** | 消费 `free_credit_init` → 定格扣款 + 活动落库;`RechargeEvent` **不再**做首充定格 |
---
## 目标架构
```mermaid
sequenceDiagram
participant Ext as 外部_pay等_本次不改
participant Wallet as slot_wallet
participant MQ as console_bus
participant Console as slot_console
Ext->>Wallet: recharge / rechargeSign
Wallet->>Wallet: 采 balance_before_qf入账
Wallet->>MQ: free_credit_init
Ext->>MQ: Recharge本次不实现
MQ->>Console: FreeCreditInitEvent
Console->>Wallet: freeCreditsFreeze RPC
Console->>Console: player/package 落库
MQ->>Console: RechargeEvent既有逻辑无定格
```
---
## 实现步骤
### 1. slot_wallet
#### 1.1 删除 `Recharge` bus
从 [`WalletLogic::recharge()`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php)、[`rechargeSign()`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) 移除:
```php
$this->sendConsoleBus('Recharge', $this->requestDTO->recharge);
```
保留 `sendConsoleBus('reward', ...)` 及其它非 Recharge 类型(本次不动)。
#### 1.2 `maybeSendFreeCreditInit()` + 扩展 `sendConsoleBus`
- 改账/ `inc()` **前**`balance_before_qf``is_first_recharge``total_deposit == 0`,在 `statModel->inc` 之前判断)。
- 改账成功后:`is_first_recharge && balance_before_qf > 0` 时发送:
```php
$this->sendConsoleBus('free_credit_init', 0, [
'balance_before_qf' => $balanceBeforeQf,
'recharge_amount' => $this->requestDTO->recharge,
]);
```
- [`sendConsoleBus()`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) 增加可选参数 `array $extraData = []`
#### 1.3 `recharge()` 与 `rechargeSign()` 均接入
签到购买 [`rechargeSign()`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) 与普通 [`recharge()`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php) 使用同一套 `maybeSendFreeCreditInit()` 逻辑。
#### 1.4 移除 `CreateWagerTask` 旧首充打码逻辑(已废弃)
[`app/command/CreateWagerTask.php`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/command/CreateWagerTask.php) 在 **Free Credits 上线前** 于首充时本地拆分余额打码与现「console 定格 + 分档释放」重复且易冲突,**本次删除**。
**旧逻辑位置**(条件均为 `RechargeExchangeService::total` 累计充值等于本笔 `recharge_amount`,即首充):
| 方法 | 行号(约) | 行为 |
|------|-----------|------|
| `version1()` | L87140 | `SOURCE_TYPE_FIRST_RECHARGE` 任务 + `SOURCE_TYPE_FREE` 免打码任务 + `SOURCE_TYPE_FIRST_LEFT`「首充剩余」打码 |
| `version2()` | L186244 | 首充充值/赠送打码 + `SOURCE_TYPE_FIRST_LEFT``first_recharge_left` 系数) |
**与新方案关系**
- 充值前免费余额 → 由 console `free_credit_init``freeCreditsFreeze` 扣出活动池(不再在 wallet 侧拆 `FREE` / `FIRST_LEFT` 任务)。
- 免打码第一档 / 后续释放 → 由 console `FreeCreditsLogic` + `freeCreditsClaim` 创建 Y1`WalletLogic::freeCreditsClaim``createTask`)。
- 首充**本笔充值金额**的打码 → 与其它充值相同,走 `elseif ($dto->required_wager > 0)` 通用分支即可。
**改动要点**
1. 删除 `version1` / `version2` 中整段 `if ($entity->recharge > 0 && $entity->recharge == $dto->recharge_amount) { ... }`
2. 首充与普通充值统一落入后续 `elseif ($dto->required_wager > 0)``version1` L142+、`version2` L246+)。
3. **保留**其中对 `SOURCE_TYPE_BUY_SIGN`(购买签到解锁额度为 0的处理——该逻辑在 `elseif` 分支内已有,无需首充专用块。
4. 删除后确认无引用孤立的 `SOURCE_TYPE_FIRST_RECHARGE` / `SOURCE_TYPE_FIRST_LEFT` 首充专用路径(常量可保留供历史任务读)。
---
### 2. slot_console
#### 2.1 EventBus 注册 `free_credit_init`
[`EventBus::deal()`](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/EventBus.php) 增加显式分支(类名不能走 default 动态加载):
```php
case 'free_credit_init':
(new FreeCreditInitEvent())->handle($busEntity);
break;
```
新建 [`app/command/event/FreeCreditInitEvent.php`](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/event/FreeCreditInitEvent.php)。
#### 2.2 `FreeCreditsLogic::handleFreeCreditInit`
- 入参:`uid``balance_before_qf``wallet_amount``orderId`(来自 bus `data`)。
- `frozenAmount = max(balance_before_qf, 0)`**不以**充值后再读余额反推为主路径。
- 活动未开启 / 已定格 → 幂等 return。
- 调用调整后的 `freezeFirstRecharge()`
#### 2.3 调整 `freezeFirstRecharge`(先扣款、后落库)
[`freezeFirstRecharge()`](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php)
1. 幂等:已有 `free_credits_freeze:{orderId}` 流水则跳过 RPC。
2. `frozenAmount = min(事件金额, RPC 前当前可扣余额)`≤0 不扣。
3. **先** `freeCreditsFreeze` RPC**后** `Db::transaction` 写 player/packages。
4. RPC 失败 → 不落库,**抛异常**。
#### 2.4 `RechargeEvent` 去掉首充定格
[`RechargeEvent::handle()`](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/event/RechargeEvent.php) **删除** L73-82 对 `handleRecharge` 的调用(首充定格改由 `free_credit_init` 触发)。
保留并可继续调用 **仅档位推进** 的逻辑,例如:
- 新增 `FreeCreditsLogic::advanceAfterRecharge($uid, $walletAmount)`,或
- `handleRecharge` 内去掉首充 `freezeFirstRecharge` 分支,仅保留 `advanceByRecharge`(供既有 `Recharge` 消息使用)。
统计、黑名单、代理首充等 **RechargeEvent 现有代码不动**(本次范围外行为保持)。
#### 2.5 MQ 可靠性(仅 console
- `FreeCreditInitEvent` / `free_credit_init`:异常上抛;`EventBus` 对该 type 失败时 **nack/requeue**(需对齐现有 consumer
- `Recharge` 路径统计块仍可独立 try/catch本次不改 pay 发消息前提)。
---
### 3. 测试(仅 wallet + console
| 用例 | 位置 |
|------|------|
| wallet 删除 Recharge bus | slot_wallet |
| wallet 首充发 `free_credit_init`(含 rechargeSign | slot_wallet |
| 首充不再走 CreateWagerTask 特殊分支 | slot_wallet CreateWagerTask |
| `FreeCreditInitEvent` 定格成功 | slot_console 集成测 |
| RPC 失败不落库 / 先扣后落库 / 幂等 | slot_console |
| `RechargeEvent` 不再触发定格 | 调整 [`FreeCreditsHandleRechargeTest`](file:///Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsHandleRechargeTest.php) 等 |
**不新增** pay 侧联调用例。
---
## 关键改动文件(仅此两份)
**slot_wallet**
- [`app/api/logic/WalletLogic.php`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/api/logic/WalletLogic.php)
- [`app/command/CreateWagerTask.php`](file:///Users/ray/Documents/project/www/slot/slot_wallet/app/command/CreateWagerTask.php)
**slot_console**
- [`app/command/EventBus.php`](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/EventBus.php)
- `app/command/event/FreeCreditInitEvent.php`(新建)
- [`app/api/logic/FreeCreditsLogic.php`](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php)
- [`app/command/event/RechargeEvent.php`](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/event/RechargeEvent.php)
- 相关 testsconsole 仓内)
---
## 验收标准(本 PR
- **wallet**`recharge` / `rechargeSign` 不再发送 `type=Recharge`;首充且 inc 前有免费余额时发送 `free_credit_init``balance_before_qf` 正确。
- **wallet**:首充不再触发 `CreateWagerTask``FIRST_RECHARGE` / `FREE` / `FIRST_LEFT` 拆分,仅按本笔充值/赠送金额走通用打码任务。
- **console**:收到 `free_credit_init` 后完成定格扣款与落库;扣款 ≤ 事件金额且 ≤ 可扣余额;幂等。
- **console**`RechargeEvent` 不再执行首充定格;已定格用户经 `Recharge` 仍可 `advanceByRecharge`(依赖外部 pay 发消息,**本 PR 不验证 pay**)。
- **范围**`git diff` 仅涉及 `slot_wallet``slot_console` 路径。

View File

@@ -0,0 +1,106 @@
---
name: 首充第一档金额下限
overview: 首充定格时,第一档金额应至少为活动配置 `ext_config.first_cash_amount`(千分位),不能仅按 `balanceBeforeQf``min` 压到低于配置;同步调整 `handleFreeCreditInit` 的定格基数与 `calcFirstCashAmount` 公式,并更新单测。
todos:
- id: fix-calc-first-cash
content: 修改 FreeCreditsLogic::calcFirstCashAmount 为至少 ext_config.first_cash_amount
status: completed
- id: update-unit-tests
content: 更新 FreeCreditsLogicAmountTest 与 handleFreeCreditInit 相关单测
status: completed
- id: verify-integration
content: 按需跑 FreeCreditsFirstRechargeFreezeTest 验证落库第一档金额
status: completed
isProject: false
---
# 首充定格:第一档金额不低于配置
## 问题
[`handleFreeCreditInit`](slot_console/app/api/logic/FreeCreditsLogic.php) 当前:
```php
$frozenAmount = max($balanceBeforeQf, 0);
```
定格总额仍用入账前余额(正确),但第一档金额在 [`freezeFirstRecharge`](slot_console/app/api/logic/FreeCreditsLogic.php) 里走:
```php
$firstCashAmount = self::calcFirstCashAmount($frozenAmount, $this->configAmount($config, 'first_cash_amount', ...));
// calcFirstCashAmount = min(frozen, configured)
```
**定格额 &lt; 配置免打码额** 时,第一档会变成定格额本身(例如池内 $6、配置 $20 → 第一档 $6与「第一档最少给 `ext_config.first_cash_amount`」不符。
单测 [`FreeCreditsLogicAmountTest`](slot_console/tests/Unit/FreeCreditsLogicAmountTest.php) 里 `'定格小于配置' => [30, 20, 20]` 已按「至少配置值」写期望,与实现 **不一致**`min(30,20)=20` 碰巧相等,但 `frozen=15, config=20` 会得到 15
## 目标行为
| 字段 | 规则 |
|------|------|
| **定格总额** `frozen_amount_qf` | 仍为入账前免费余额 `balance_before_qf`(需求 §5.3**不**强行抬到配置 |
| **第一档金额** `first_cash_amount_qf` / `package_no=1.amount_qf` | `max(min(定格额, 配置), 配置)` → 等价于 **至少为配置值**:定格 ≥ 配置时取配置上限;定格 &lt; 配置时仍记 **配置值**(运营承诺的档位面额) |
```mermaid
flowchart LR
event[free_credit_init] --> init[handleFreeCreditInit]
init --> freeze[freezeFirstRecharge]
freeze --> frozen["frozen = balanceBefore"]
freeze --> first["firstCash = max(min(frozen, cfg), cfg)"]
```
**边界说明**:若定格额实际小于配置,玩家可提现金额仍受池内 `package.amount_qf` 与钱包冻结额约束;本次按你的要求把 **落库第一档金额** 抬到配置下限,与需求文档 §5.6「定格 &lt; $20 → 第一档 = 定格额」存在冲突——以你本次口径为准;若需「可提现 ≤ 定格」再单独加提现校验。
## 实现(仅 slot_console
### 1. 修改 `calcFirstCashAmount`
文件:[`FreeCreditsLogic.php`](slot_console/app/api/logic/FreeCreditsLogic.php)
```php
public static function calcFirstCashAmount(int $frozenAmount, int $configuredFirstCash): int
{
return max(min($frozenAmount, $configuredFirstCash), $configuredFirstCash);
}
```
- 注释改为:第一档面额不低于配置,且不超过定格总额与配置上限。
- 逻辑上等价于 `return $configuredFirstCash`(当 `configuredFirstCash > 0`);保留 `min(frozen, …)` 形式便于日后若配置为 0 时回退。
### 2. `handleFreeCreditInit` 显式读配置(可选但建议)
在同文件 `handleFreeCreditInit` 内,在调用 `freezeFirstRecharge` 前读取一次配置(便于日志/后续扩展;**定格额仍用 `balanceBeforeQf`**
```php
$configuredFirstCash = $this->configAmount($config, 'first_cash_amount', self::DEFAULT_FIRST_CASH);
$frozenAmount = max($balanceBeforeQf, 0);
// 不把 frozenAmount 改成 max(balanceBefore, configuredFirstCash),避免改变定格总额语义
```
若你希望 **尝试多冻** 到配置下限,可另议;当前计划 **只改第一档金额公式**,不改定格总额。
### 3. 单测
| 文件 | 改动 |
|------|------|
| [`FreeCreditsLogicAmountTest.php`](slot_console/tests/Unit/FreeCreditsLogicAmountTest.php) | 修正/补充:`[15, 20, 20]`(定格小于配置 → 第一档仍为 20保留 `[100, 20, 20]` |
| [`FreeCreditsHandleFreeCreditInitTest.php`](slot_console/tests/Unit/FreeCreditsHandleFreeCreditInitTest.php) | 增加用例:`balanceBefore=15000``first_cash_amount_qf=20000`Harness 落库后 `first_cash_amount_qf` / 第一档 `amount_qf` 为 20000若 Harness 走完整 `freezeFirstRecharge` 需开 DB 或扩展 Harness 断言) |
| 集成测 [`FreeCreditsFirstRechargeFreezeTest`](slot_console/tests/Integration/FreeCreditsFirstRechargeFreezeTest.php) | 当 DB 活动配置 `first_cash_amount_qf=20000``balanceBefore < 20000` 时断言 `player.first_cash_amount_qf === 20000` |
运行Docker
```bash
docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit \
--filter 'FreeCreditsLogicAmountTest|FreeCreditsHandleFreeCreditInitTest'
```
## 不改动的部分
- **wallet** `maybeSendFreeCreditInit`:仍 `balance_before_qf > 0` 即发事件。
- **定格扣款金额**:仍 `freeCreditsFreeze(frozenAmount)``frozenAmount``balanceBefore` 为准(经 `freezeFirstRecharge``min(…, wallet.balance)` 封顶)。
- **解锁第一档**:仍用 `recharge_unlock_amount` 配置,与本次无关。
## 风险
- 定格 &lt; 配置时,`first_cash_amount_qf` &gt; 实际可冻余额C 端可能展示 $20 但池内不足;提现/保留时需依赖现有 `package.amount_qf` 与钱包校验,必要时后续在 `applyFirstCashoutWithdraw` 增加 `amount_qf <= frozen` 断言。