This commit is contained in:
ray zhou
2026-06-29 14:51:55 +08:00
parent 225fb2bd28
commit 2dd9f17da9
319 changed files with 29461 additions and 9412 deletions

View File

@@ -0,0 +1,128 @@
<!-- 78ee2e02-349b-4b66-a20c-03e7ee129a45 -->
---
todos:
- id: "rabbitmq-prefetch"
content: "RabbitMqServiceconnect 去掉 prefetch=10getMessage 增加 singleMessagePrefetch 参数并在 true 时 basic_qos(1)"
status: pending
- id: "eventbus-call"
content: "EventBus::execute 调用 getMessage([$this, 'deal'], true)"
status: pending
- id: "consumer-tag"
content: "可选consumer_tag 加 getmypid() 便于管理台区分多进程"
status: pending
- id: "verify-supervisor"
content: "Supervisor numprocs 配置 + RabbitMQ 管理台确认 N consumer 且 Unacked≤1"
status: pending
isProject: false
---
# EventBus 多进程「每进程一次只处理一条」方案
## 现状与问题
[`slot_console/app/service/RabbitMqService.php`](slot_console/app/service/RabbitMqService.php) 在 `connect()` 里写死了:
```php
$this->setExhcange($type)->setBasicQos(null, 10, null)->setQueue()->queueBind();
```
`prefetchCount = 10` 表示:**每个 consumer 最多预取 10 条未 ack 的消息**。Supervisor 开 N 个 `php webman event:bus` 时,理论上最多有 `N × 10` 条消息被「占住」但未必在处理(尤其处理慢时,其余 9 条会堵在该进程本地)。
[`EventBus.php`](slot_console/app/command/EventBus.php) 当前调用:
```php
$rabbitMqService->getMessage([$this, 'deal']);
```
已是 **manual ack**`no_ack = false``deal()` 末尾 `$message->ack()`),只差把 prefetch 改成 1。
```mermaid
sequenceDiagram
participant Q as console_bus
participant P1 as Process1
participant P2 as Process2
Q->>P1: deliver msg1 (prefetch=1)
Q->>P2: deliver msg2
Note over P1: 处理 msg1未 ack 前不再投递
P1->>Q: ack msg1
Q->>P1: deliver msg3
```
## 推荐改动(对齐 slot_pwa / slot_hub 既有模式)
同仓库已有先例:
- [`slot_pwa/app/service/RabbitMqService.php`](slot_pwa/app/service/RabbitMqService.php) — `getMessage($callback, $qos=false)``$qos=true``basic_qos(null, 1, false)`
- [`slot_hub/plugin/slot/hub/WorkerBusiness.php`](slot_hub/plugin/slot/hub/WorkerBusiness.php) — 消费侧 `getMessage(..., true)`
### 1. 改 `RabbitMqService`
文件:[`slot_console/app/service/RabbitMqService.php`](slot_console/app/service/RabbitMqService.php)
- **connect 阶段**:去掉 `setBasicQos(null, 10, null)`(发布端不需要 prefetch消费端在 `getMessage` 里单独设置更明确)。
- **getMessage**:增加第二/along slot_pwa 风格:
```php
public function getMessage(callable $callback, bool $singleMessagePrefetch = false): void
{
if ($singleMessagePrefetch) {
$this->_channel->basic_qos(null, 1, false); // 每 consumer 未 ack 前最多 1 条
}
// basic_consume + wait 循环保持不变
}
```
- **consumer_tag可选但建议**:由固定 `$queueName . 'consumer'` 改为带 pid便于 RabbitMQ 管理台区分多个 Supervisor 进程,例如 `$this->queueName . '_consumer_' . getmypid()`。同一 channel 内唯一即可,多进程各自独立 connection不会冲突。
### 2. 改 `EventBus` 调用
文件:[`slot_console/app/command/EventBus.php`](slot_console/app/command/EventBus.php)
```php
$rabbitMqService->getMessage([$this, 'deal'], true);
```
仅此一处消费 `console_bus` 的入口需要改;其它只 `sendMessage` 的调用不受影响。
### 3. Supervisor 多进程部署(你已选此方式)
每个 worker 仍是独立 PHP 进程 + 独立 AMQP connection**订阅同一队列** `console_bus`[`MQKeyManagerService::QUEUE_CONSOLE_BUS`](slot_console/app/service/MQKeyManagerService.php)。RabbitMQ 默认 round-robin有 3 个 consumer 就 3 路并行,且配合 prefetch=1 后每个 consumer 同时只「持有」1 条未 ack 消息。
示例(进程数按 CPU/吞吐调整):
```ini
[program:slot_console_event_bus]
command=docker exec -w /app/www/slot/slot_console php82 php webman event:bus
process_name=%(program_name)s_%(process_num)02d
numprocs=3
autostart=true
autorestart=true
```
本地联调可开多个终端各跑一条(与 [`slot_console/doc/lucky_reward_deploy.md`](slot_console/doc/lucky_reward_deploy.md) 一致):
```bash
docker exec -w /app/www/slot/slot_console php82 php webman event:bus
```
## 机制说明(为何这样就够)
| 机制 | 作用 |
| --- | --- |
| `basic_qos(0, 1, false)` | 该 consumer 未 ack 前broker 不再向它推第 2 条 |
| `no_ack = false` + `deal()` 末尾 `ack()` | 处理完成后才释放「占用槽位」 |
| 多进程各 `basic_consume` 同一 queue | broker 在多个 consumer 间分发 |
| 每进程独立 connection | 不共享 channel`getInstance` 单例仅进程内有效 |
**不需要** `exclusive = true`(那会变成单 consumer 独占队列,与多进程目标相反)。
## 多进程额外注意(非本次必改,但上线前心里有数)
1. **同 uid 消息顺序**:多 consumer 后,同一用户的两条 bus 消息可能乱序执行;若某类 event 强依赖顺序,需在 Logic 层加锁/幂等(例如按 `uid` 分布式锁),不能单靠 prefetch。
2. **失败重试**:目前仅 `TYPE_FREE_CREDIT_INIT` 在 catch 里 `nack(true)` 重入队;其它类型异常后仍 `ack()`,多进程不会放大这个问题,但重试策略需业务上接受。
3. **验证**RabbitMQ 管理台 → Queues → `console_bus` → Consumers应看到 N 个 consumer压测时每个 consumer 的 **Unacked** 应 ≤ 1。
## 改动范围
- 必改:[`RabbitMqService.php`](slot_console/app/service/RabbitMqService.php)、[`EventBus.php`](slot_console/app/command/EventBus.php)
- 不改Supervisor 配置可在运维侧按 `numprocs` 调整,不必动 PHP 代码
- 不测 RabbitMQ 真连接的单测可保持现状;若有 mock 消费测试,传入第二参数 `true` 即可

View File

@@ -0,0 +1,326 @@
<!-- 46ed22aa-9d18-40e3-a7fb-ed92e69d49a2 -->
---
todos:
- id: "fix-fbc-format"
content: "FbService 增加 resolveFbcForCapiraw fbclid 格式化为 fbccreationTime 优先从 ad_fbp 解析"
status: pending
- id: "fix-purchase-event-id"
content: "purchaseSelf 恢复使用 orderId 作为 event_id 保证幂等"
status: pending
- id: "hash-external-id"
content: "可选registerSelf/purchaseSelf 对 external_id 做 SHA256 哈希,对齐 TikTokDot"
status: pending
- id: "align-user-data"
content: "registerSelf 使用 array_filter解析 4xx 响应 body 写日志;可选异常上抛"
status: pending
- id: "fix-400-debug"
content: "记录 Meta error JSON不含 token日志脱敏 access_token建议轮换已泄露 token"
status: pending
- id: "verify-meta-test-events"
content: "用 test_event_code 在 Meta Test Events 验证 fbc 格式与 Purchase 去重"
status: pending
isProject: false
---
# FbService 像素打点问题分析与修复计划
## 结论:**有问题**,且会影响 FB 广告归因;**400 很可能就是 fbc 格式错误导致**
当前 [`slot_console/app/service/dot/FbService.php`](slot_console/app/service/dot/FbService.php) 的 `registerSelf` / `purchaseSelf` 在重构为 Guzzle 直调 Graph API 后,**把原始 fbclid 当作 `user_data.fbc` 上报**。Meta CAPI 对 `fbc` 有严格格式校验,**不符合 `fb.1.{ms}.{fbclid}` 时会返回 HTTP 400**Graph API error code 100Invalid parameter
---
## 关于「URL 上传来的 fbc/fbclid 没有点」——这是正常的
对照 Meta 官方文档([ClickID and the fbp and fbc Parameters](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc/)**Updated: Jan 9, 2026**
| 阶段 | 字段 | 格式 | 是否带点 |
|---|---|---|---|
| 广告落地 URL | `fbclid` 查询参数 | 原始 ClickID`IwAR2F4-dbP0l7Mn1IawQQGCINEz7PYXQvwjNwB_qa2ofrHyiLjcbCRxTDMgk` | **否** |
| 浏览器 `_fbc` cookie / CAPI `user_data.fbc` | 格式化 ClickID | `fb.{subdomainIndex}.{creationTimeMs}.{fbclid}` | **是**3 个点分隔 4 段) |
| 浏览器 `_fbp` cookie / CAPI `user_data.fbp` | Browser ID | `fb.{subdomainIndex}.{creationTimeMs}.{random}` | **是** |
官方示例:
- URL`https://example.com/?fbclid=IwAR2F4-dbP0l7Mn1IawQQGCINEz7PYXQvwjNwB_qa2ofrHyiLjcbCRxTDMgk`
- CAPI payload`"fbc": "fb.1.1554763741205.IwAR2F4-dbP0l7Mn1IawQQGCINEz7PYXQvwjNwB_qa2ofrHyiLjcbCRxTDMgk"`
官方明确说明:
> If the `_fbc` cookie is not available ... it is still possible to send the `fbc` event parameter ... if an `fbclid` query parameter is in the URL ... **The formatted ClickID value must be of the form** `version.subdomainIndex.creationTime.fbclid`.
> **creationTime** ... If you don't save the `_fbc` cookie, use the timestamp **when you first observed or received this fbclid value**.
因此:
- **落地页 / 注册侧存 raw fbclid无点是对的**,与你们 [`ImeiRegisterService`](slot_user/app/service/register/ImeiRegisterService.php) 注释一致。
- **问题不在采集,而在 CAPI 发送前缺少格式化**`FbService` 应把 raw fbclid 组装成 `fb.1.{ms}.{fbclid}` 再写入 `user_data.fbc`,而不是原样上报。
- `fbp` 来自浏览器 `_fbp` cookie本身带点`fb.1.1769311259905.2389032359262323`),当前做法正确。
**creationTime 补充**:官方要求用「首次观察到 fbclid 的时间」,不是转化事件发送时间。同一次落地页访问里,`fbp` 已含首访毫秒时间戳,可从中解析作为 `fbc``creationTime`(见下方真实样例)。
---
## 真实生产样例SiteController 日志)
来源:[`SiteController::fb`](slot_user/app/api/controller/SiteController.php) 第 28 行日志。
| 字段 | 实际值 | 说明 |
|---|---|---|
| `post.fbp` | `fb.1.1782099747655.910276663728351279` | 浏览器 `_fbp` cookie**已带点、格式正确**,可直接作为 CAPI `fbp` |
| `post.fbc` | `IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRjBmFwcF9pZAo2NjI4NTY4Mzc5AAEe0aCSkd3i8J9sLoIGWHNcZF4YJuNXtOMXsFspgYX_76nQTzSUrDDtOzE4qJY_aem_gZtG64LnGIXu3gNiyhejkQ` | URL 上的 **raw fbclid无点**,命名虽叫 `fbc` 但不是 CAPI 最终格式 |
| `post.ua` | `...[FBAN/FBIOS;...]` | Facebook iOS 内置浏览器 |
注册后写入用户:`ad_fbc` = 上面 raw fbclid`ad_fbp` = 上面 fbp。
**当前 FbService 错误上报**(原样发送 `ad_fbc`
```json
"fbc": "IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRjBmFwcF9pZAo2NjI4NTY4Mzc5AAEe0aCSkd3i8J9sLoIGWHNcZF4YJuNXtOMXsFspgYX_76nQTzSUrDDtOzE4qJY_aem_gZtG64LnGIXu3gNiyhejkQ"
```
**修复后应上报**(从 `fbp` 解析 `creationTime=1782099747655`,拼接 raw fbclid
```json
"fbc": "fb.1.1782099747655.IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRjBmFwcF9pZAo2NjI4NTY4Mzc5AAEe0aCSkd3i8J9sLoIGWHNcZF4YJuNXtOMXsFspgYX_76nQTzSUrDDtOzE4qJY_aem_gZtG64LnGIXu3gNiyhejkQ",
"fbp": "fb.1.1782099747655.910276663728351279"
```
**creationTime 策略(推荐)**
1. 优先从 `entity.fbp` / `ad_fbp` 解析第 3 段毫秒时间戳(同会话首访时间,符合 Meta 要求)。
2. 解析失败时 fallback 到 `generateFBC()` 当前毫秒时间。
3. 禁止对 fbclid 做大小写变换。
**附带发现(可选后续)**`SiteController` 第 33 行要求 UA 含 `FB_IAB`,但 iOS Facebook 内置浏览器 UA 为 `FBAN/FBIOS`,该条日志在 info 之后可能被 `PARAMS_ERROR` 拒绝、未写入 Redis。若 iOS 用户依赖落地页 Redis 回退取归因,需放宽 UA 校验(如同时接受 `FB_IAB` / `FBAN/`)。客户端直传 fbclid 注册路径不受影响。
---
## HTTP 400 原因分析(结合当前代码)
### 生产报错确认2026-06-22
实际日志(`FbService::registerSelf`uid 3895162
```
Client error: POST https://graph.facebook.com/v24.0/211064574998002/events?access_token=...
resulted in a `400 Bad Request` response:
```
可确认:
- 失败方法:`registerSelf`(注册打点 CompleteRegistration
- Pixel ID`211064574998002`
- API`v24.0/events`
- **响应 body 被 Guzzle 截断/未记录**,日志里看不到 Meta 的 `error.message`(通常为 `Invalid parameter` / `fbc` 相关)
- **安全**`access_token` 完整出现在 ERROR 日志中Guzzle 异常 message 含 query string应在 Meta 后台 **立即轮换 token**,并修复日志脱敏(禁止记录含 token 的 URL
结合同一用户链路(落地页 raw fbclid + 合法 fbp**400 与未格式化的 `user_data.fbc` 高度吻合**。
### 最可能根因:`user_data.fbc` 格式非法(与真实样例直接相关)
当前发送(错误):
```json
"fbc": "IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRj..."
```
Meta 要求([Customer Information Parameters](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters/)Jan 9, 2026
> The format is: `fb.${subdomain_index}.${creation_time}.${fbclid}`.
未格式化的 raw fbclid **不以 `fb.` 开头**,会被判定为 invalid parameter → **HTTP 400**。这与「采集端无点是对的、发送端必须带点」完全一致。
修复后应发送:
```json
"fbc": "fb.1.1782099747655.IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRj..."
```
### 为什么日志里可能看不清 Meta 具体报错
Guzzle 默认 `http_errors=true`,收到 400 会抛 `ClientException`,当前代码:
```php
} catch (\Throwable $e) {
LoggerService::error(__METHOD__, $e->getMessage());
}
```
- **成功分支的 `LoggerService::info($body)` 不会执行**400 时进 catch
- 只记 `$e->getMessage()`**通常不含 Meta 返回的完整 JSON**`error.message``error_user_msg``error_subcode`)。
- 修复时应从 `$e->getResponse()->getBody()` 解析并记录,便于确认是否为 `Invalid parameter: fbc`
### 其它可能导致 400 的次要因素
| 因素 | 当前代码 | 风险 |
|---|---|---|
| `registerSelf``array_filter` | 可能发送 `"fbc":""` / `"fbp":""` | 空字符串也可能触发参数校验失败 |
| `external_id` 类型不一致 | register 传 `[1689380]`整数purchase 传 `["1689380"]`(字符串) | 部分 Graph 版本对类型敏感,应统一为字符串数组 |
| `client_user_agent` 为空 | `action_source=website` 时 Meta 标注为 required | 若 `ad_ua` 为空仍发送 `""`,可能 400 |
| `Test.php` 测试 | `fbRegister` 未设置 `entity.fbc/fbp` | 本地测试必 400空 fbc |
| access_token / pixel_id 错误 | 配置问题 | 多为 401/403 或 OAuth error不一定是 400 |
**不太可能是 400 的项**`fbp` 格式(你的样例 `fb.1.1782099747655.910276663728351279` 合法)、`currency` 大小写、`event_time` 秒级时间戳。
---
## 数据流(当前)
```mermaid
sequenceDiagram
participant LP as LandingPage
participant User as slot_user注册
participant Dot as FbDot
participant FB as FbService
participant Meta as Meta_CAPI
LP->>User: POST fbp + fbc(实为URL fbclid)
User->>User: ad_fbc = 原始fbclid
Dot->>FB: entity.fbc = ad_fbc
FB->>Meta: user_data.fbc = 原始fbclid
Note over FB,Meta: 错误:应为 fb.1.{ms}.{fbclid}
```
---
## 已确认的问题
### 1. 【严重】`fbc` 格式错误(根因)
- 用户侧 [`slot_user/app/service/register/ImeiRegisterService.php`](slot_user/app/service/register/ImeiRegisterService.php) 明确注释:**落地页 `fbc` 实际是 URL 上的 `fbclid`,直接写入 `ad_fbc`**。
- [`UserInfoEntity::$ad_fbc`](slot_lib/src/entity/user/UserInfoEntity.php) 注释也是「FB点击id」不是 `_fbc` cookie。
- [`FbDot.php`](slot_console/app/command/dot/FbDot.php) 把 `ad_fbc` 赋给 `$entity->fbc` 后,`FbService` 原样上报:
```82:98:slot_console/app/service/dot/FbService.php
$fbc = $entity->fbc;// $this->generateFBC();
$fbp = $entity->fbp ;// $this->generateFPB();
// ...
"user_data" => [
// ...
"fbc" => $fbc,
"fbp" => $fbp
]
```
- Meta 要求 `fbc` 格式:`fb.{subdomainIndex}.{creationTimeMs}.{fbclid}`。
- 类内已有正确实现 [`generateFBC()`](slot_console/app/service/dot/FbService.php)(旧 SDK 代码也在用),但新代码里被注释掉未调用:
```235:244:slot_console/app/service/dot/FbService.php
public function generateFBC()
{
if (!$this->fbclid) return null;
return "fb.1.$creationTime.$this->fbclid";
}
```
- 构造函数已接收 fbclid`FbDot` 传的是 `ad_fbc`),但 `registerSelf`/`purchaseSelf` 完全没用 `$this->fbclid` 和 `generateFBC()`。
### 2. 【中等】Purchase 的 `event_id` 丢失幂等
- 旧 SDK 实现使用 `$entity->orderId` 作为 `event_id`(可去重、可重试)。
- 新 `purchaseSelf` 改为 `uniqid('purchase_', true)`,同一订单重复打点会被 Meta 视为不同事件,**无法幂等**。
```174:182:slot_console/app/service/dot/FbService.php
$eventId = uniqid('purchase_', true);
// 旧代码: ->setEventId($entity->orderId)
```
### 3. 【低】`external_id` 未 SHA256 哈希(官方为 recommended非 required
- 直调 CAPI 当前发送明文 uid`"external_id" => [$entity->uid]` / `["{$entity->uid}"]`。
- [Customer Information Parameters](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters/)Jan 9, 2026对 `external_id` 写的是 **Hashing recommended**,不是必须。
- 旧 Facebook SDK 的 `UserData::setExternalId()` 会自动哈希;同项目 [`TikTokDot`](slot_console/app/command/dot/TikTokDot.php) 也已哈希。建议对齐,但优先级低于 fbc 格式化。
### 4. 【低】其它可改进点
| 项 | 说明 |
|---|---|
| `registerSelf` 未 `array_filter` | `purchaseSelf` 会过滤空字段,`registerSelf` 可能上报空 `fbc`/`fbp` |
| 响应未校验 | 仅 `LoggerService::info` 打 body不检查 `events_received` / `error` |
| 异常被吞 | `catch` 只记日志不抛出,上层无法感知失败 |
| 死代码 | 大段注释 SDK 代码 + 未使用的 FacebookAds import |
| `FbDot` 命名误导 | `$fbclid = $userInfo->ad_fbc` 逻辑对,但 `$entity->fbc = $fbclid` 易让人误以为已是 `_fbc` 格式 |
`fbp` 侧目前是正确的:来自浏览器 cookie`ad_fbp`),不应服务端 `generateFPB()`。
---
## 修复方案(建议最小改动)
### A. 在 `FbService` 内统一解析 `fbc`(核心)
新增私有方法,例如 `resolveFbcForCapi(string $rawFbclid, string $fbp = ''): ?string`
1. 若 `$rawFbclid` 已匹配 `^fb\.\d+\.\d+\.` → 直接使用(兼容未来若存完整 `_fbc` cookie
2. 否则取 raw fbclid优先 `$rawFbclid`fallback `$this->fbclid`)→ 格式化为 `fb.1.{creationTimeMs}.{fbclid}`。
- `creationTimeMs` **优先从 `$fbp` 解析**(正则 `^fb\.\d+\.(\d+)\.`),与真实样例 `1782099747655` 对齐 Meta「首访观察时间」。
- 解析失败时 fallback 到 `round(microtime(true) * 1000)`。
- 服务端生成时 `subdomainIndex` 固定 `1`(官方推荐)。
3. 无 fbclid 时返回 `null`,配合 `array_filter` 不上报空字段。
4. **禁止修改 fbclid 大小写**官方ClickID is case sensitive
`registerSelf` / `purchaseSelf` 均改为:
```php
$fbc = $this->resolveFbcForCapi($entity->fbc, $entity->fbp);
```
不再直接把 `ad_fbc` 当 `fbc` 上报。
### B. 恢复 Purchase 幂等
```php
"event_id" => $entity->orderId !== '' ? $entity->orderId : uniqid('purchase_', true),
```
优先订单号;仅测试/缺单号时 fallback。
### C. 哈希 `external_id`
```php
"external_id" => [hash('sha256', (string) $entity->uid)],
```
register / purchase 保持一致。
### D. 对齐 `registerSelf` 与 `purchaseSelf` + 改善 400 排障
- `registerSelf` 的 `user_data` 也使用 `array_filter`(空 fbc/fbp/ua 不上报)。
- `external_id` 统一为字符串数组:`[(string) $entity->uid]`。
- Guzzle 请求增加 `'http_errors' => false` **或** catch `ClientException` 时读取 response body**日志禁止输出含 access_token 的完整 URL**(只记 pixel_id、status、body
```php
LoggerService::error(__METHOD__, [
'status' => $resp->getStatusCode(),
'body' => (string) $resp->getBody(),
'pixel_id' => $this->pixelId,
'event_name' => 'CompleteRegistration',
]);
```
- 可选:`events_received < 1` 或 HTTP >= 400 时抛业务异常,让 FbDot 感知失败。
### E. `FbDot` 小清理(可选)
- 变量改名:`$fbclid = $userInfo->ad_fbc` 保留语义。
- `$entity->fbc` 仍可传 raw fbclid由 Service 格式化),或只依赖构造函数 fbclid、entity 不再设 fbc——二选一避免双份来源**推荐只传 raw fbclid 给构造函数entity.fbc 可选**。
---
## 验证方式
1. 用 [`Test.php`](slot_console/app/command/Test.php) 的 `fbRegister` / `fbPurchase`(带 `test_event_code`)在 Meta Events Manager → Test Events 查看:
- `fbc` 应为 `fb.1.{13位毫秒}.{fbclid}` 格式
- `fbp` 保持 `fb.1.{ms}.{random}`
- Purchase 重复同一 `orderId` 不应产生 duplicate 事件
2. 对比修复前后 payload 日志(`registerSelf` / `purchaseSelf` 已有 body 日志)。
3. 跑 slot 后端 completion report改动 PHP 后必跑)。
---
## 改动范围
| 文件 | 改动 |
|---|---|
| [`slot_console/app/service/dot/FbService.php`](slot_console/app/service/dot/FbService.php) | 核心resolve fbc、event_id、external_id 哈希、array_filter、可选响应校验 |
| [`slot_console/app/command/dot/FbDot.php`](slot_console/app/command/dot/FbDot.php) | 可选:变量命名/clarity无业务逻辑必须改 |
**不需要改** `slot_user` 注册写入逻辑(`ad_fbc` 存 raw fbclid 是合理设计,格式化应在 CAPI 发送层完成)。

View File

@@ -0,0 +1,122 @@
<!-- 6b933c7f-da14-42c8-8223-1fd4068c9b61 -->
---
todos:
- id: "unify-pending-query"
content: "WagerTaskModelgetUncompletedTasksVersion2 / getActiveCountVersion2 / lockedTasksV2 统一为 status IN (1,11) AND current_wager < required_wager"
status: pending
- id: "version2-loop-repair"
content: "UpdateWagerTask::version2()needed=0 自动完成 is_completed=1 时纠正并累加打码"
status: pending
- id: "add-unit-tests"
content: "补充单测覆盖 is_completed 脏数据与 required_wager=0 边界"
status: pending
- id: "data-hotfix-optional"
content: "可选uid 1876592 任务1 is_completed 改回 0 作为上线前救火"
status: pending
isProject: false
---
# 修复 version2 有待打码任务却不扣除的问题
## 根因(对应你这两条数据)
解析你贴的数据按表字段顺序
| 字段 | 任务1 `7733765055921637` | 任务2 `7745776340511881` |
|------|--------------------------|--------------------------|
| required_wager | **1000** | 0 |
| current_wager | **0** | 0 |
| is_completed | **1** | 1 |
| status | 1等待中 | 1等待中 |
| brief | 恢复提现额数据 | 提现退回 |
**下注不累加的直接原因**[`UpdateWagerTask::version2()`](slot_wallet/app/command/UpdateWagerTask.php) 调用 [`getUncompletedTasksVersion2()`](slot_wallet/app/model/multi/WagerTaskModel.php)查询条件是
```php
is_completed = 0 AND status IN (1, 11)
```
两条记录 **`is_completed` 都是 1**查询结果 **为空** `empty($tasks)` **直接 return**下注额不会分配
任务1 明明 `current_wager(0) < required_wager(1000)`本应吃打码但因 `is_completed=1` 被排除
```mermaid
flowchart TD
betMQ[下注MQ updateWagerTask] --> version2[version2]
version2 --> query[getUncompletedTasksVersion2]
query --> filter{"is_completed=0 AND status IN 1,11"}
filter -->|任务1 is_completed=1| empty[tasks 为空]
empty --> returnEarly[return 不扣除]
filter -->|正常任务| loop[按 id FIFO 累加 current_wager]
```
## 附带不一致(同一用户会「不能提现」但「也不扣打码」)
version2 提现判断走 [`WalletService::calculateWithdrawAmount()`](slot_wallet/app/service/WalletService.php)
```php
$num = $wagerTaskModel->getActiveCountVersion2($uid); // 只按 status IN (1,11) 计数
if ($num > 0) return 0;
```
[`getActiveCountVersion2()`](slot_wallet/app/model/multi/WagerTaskModel.php) **不看 `is_completed`**两条 `status=1` 都会计入 提现被挡
但打码更新 **看 `is_completed=0`** 不累加
这是典型的 **卡死状态**有任务不能提现打码也不前进
## 代码修复方案(推荐)
### 1. 统一 version2「待打码任务」定义
[`WagerTaskModel`](slot_wallet/app/model/multi/WagerTaskModel.php) version2 待处理任务定义为
> `status IN (STATUS_ON, STATUS_PROGRESS)` **且** `current_wager < required_wager`
理由真正决定是否还需打码的是进度而不是可能脏写的 `is_completed`
**改动点**
- **重写或替换** `getUncompletedTasksVersion2()` `where + whereExp('current_wager', '< required_wager')`或等价 ThinkPHP 写法去掉对 `is_completed=0` 的硬依赖
- **同步** `getActiveCountVersion2()``lockedTasksV2()`使用同一套待打码条件目前 `lockedTasksV2` 也不过滤 `is_completed`与打码查询语义不一致)。
- **同步** [`WalletService::calculateLeftWithdrawNeedBet()`](slot_wallet/app/service/WalletService.php) version2 分支已用 `lockedTasksV2` Model 后自动对齐)。
### 2. 加强 `UpdateWagerTask::version2()` 循环
[`UpdateWagerTask::version2()`](slot_wallet/app/command/UpdateWagerTask.php) foreach 内补充
- `needed == 0` `status` 仍为 ON/PROGRESS 调用 `markTaskCompletedVersion2()` 收尾避免 `required_wager=0` 的脏任务永远占着 `status=1`)。
- `needed > 0` `is_completed=1`脏数据累加打码时顺带把 `is_completed` 纠正为 0或在 `incrementWagerVersion2` 内一并更新避免下次再被旧逻辑挡住
### 3. 单测补充
[`tests/Unit/UpdateWagerTaskContributionTest.php`](slot_wallet/tests/Unit/UpdateWagerTaskContributionTest.php) 或新增 Model 单测覆盖
- `is_completed=1` `current_wager < required_wager` 的任务应被纳入待打码列表
- `required_wager=0` `current_wager=0` 的任务不应纳入对应你的任务2
- `getActiveCountVersion2` 与打码查询计数一致
## 该用户即时数据修复(可选,上线代码前可先救火)
uid `1876592`任务 `7733765055921637`
```sql
UPDATE wager_task_{shard}
SET is_completed = 0
WHERE id = 7733765055921637 AND uid = 1876592;
```
分表名按 uid 取模规则确定。)
任务2 `required_wager=0` 在代码修复后不会再挡打码若仍 `status=1` `is_completed=1`修复 `getActiveCountVersion2` 后也不再挡提现
## 不纳入本次范围
- 不判断 `withdraw_back` 业务倍数应为多少任务2 `required_wager=0` 在修复后的查询里自然不会参与打码分配
- 不追溯任务1为何 `is_completed=1` `completed_at=0`数据脏写来源代码修复以 **自愈脏数据** 为主
## 验收
1. uid 1876592 下注后任务1 `current_wager` 递增
2. `calculateLeftWithdrawNeedBet` 显示的剩余打码与 `current_wager` 同步下降
3. 任务1 打满后 `status=12``is_completed=1``getActiveCountVersion2` 0可提现
4. `slot-backend-completion-report` 门禁通过

167
plans/HTTP-c8f2b444.plan.md Normal file
View File

@@ -0,0 +1,167 @@
<!-- c8f2b444-d26a-4f73-b3e8-317e7228c8ab -->
---
todos:
- id: "redis-keys"
content: "RedisKeyManagerService 增加 duration buckets/sum/count 三个 key 常量"
status: pending
- id: "prometheus-service"
content: "PrometheusService 实现 recordHttpRequestDuration + render histogram 输出"
status: pending
- id: "middleware-timing"
content: "PrometheusMiddleware 计时并在 finally 记录 duration"
status: pending
- id: "grafana-panels"
content: "app_http_services_dashboard.json 增加 P50/P95/P99/Avg 与 route 耗时面板"
status: pending
- id: "verify"
content: "curl /metrics 验证 + 跑 completion-report 门禁"
status: pending
isProject: false
---
# slot_wallet HTTP 响应时长监控
## 现状
当前链路:
```mermaid
sequenceDiagram
participant Client
participant Middleware as PrometheusMiddleware
participant Handler
participant Redis
participant Metrics as MetricsController
Client->>Middleware: HTTP Request
Middleware->>Handler: handler()
Handler-->>Middleware: Response
Middleware->>Redis: hIncrBy requests_total
Middleware-->>Client: Response
Note over Metrics,Redis: Prometheus scrape
Metrics->>Redis: hGetAll
Metrics-->>Client: text/plain metrics
```
- 中间件:[`slot_wallet/app/middleware/PrometheusMiddleware.php`](slot_wallet/app/middleware/PrometheusMiddleware.php) 在 `finally` 中调用 `recordHttpRequest()`,只记请求数。
- 指标服务:[`slot_wallet/app/service/PrometheusService.php`](slot_wallet/app/service/PrometheusService.php) 用 Redis Hash 存 counter经 [`slot_wallet/app/controller/MetricsController.php`](slot_wallet/app/controller/MetricsController.php) 的 `/metrics` 输出。
- Grafana[`slot_wallet/doc/grafana/app_http_services_dashboard.json`](slot_wallet/doc/grafana/app_http_services_dashboard.json) 仅查询 `app_http_requests_total` / `app_business_responses_total`
**结论:可以加响应时长**,且与现有架构兼容;推荐新增标准 Prometheus Histogram而不是改现有 counter。
---
## 目标指标
新增 metric`app_http_request_duration_seconds`histogram
| 子指标 | 含义 |
|--------|------|
| `_bucket{le="..."}` | 各耗时桶累计次数 |
| `_sum` | 累计耗时(秒) |
| `_count` | 观测次数 |
**Labels** 与现有请求 counter 保持一致:`service`, `method`, `route`, `status`,便于在 Grafana 与请求量 join。
**Histogram buckets**Webman API 常用区间):
`0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, +Inf`
---
## 代码改动
### 1. Redis Key — [`slot_wallet/app/service/RedisKeyManagerService.php`](slot_wallet/app/service/RedisKeyManagerService.php)
新增 3 个常量(与现有 `PROMETHEUS_*` 命名一致):
- `PROMETHEUS_HTTP_REQUEST_DURATION_BUCKETS``prometheus:http:duration:buckets`
- `PROMETHEUS_HTTP_REQUEST_DURATION_SUM``prometheus:http:duration:sum`
- `PROMETHEUS_HTTP_REQUEST_DURATION_COUNT``prometheus:http:duration:count`
### 2. 记录耗时 — [`slot_wallet/app/service/PrometheusService.php`](slot_wallet/app/service/PrometheusService.php)
新增 `recordHttpRequestDuration(string $method, string $route, int $status, float $durationSeconds)`
- field 仍用 `json_encode` 存 labelsbucket 额外带 `le`
- `_bucket` / `_count``Redis::hIncrBy(..., 1)`
- `_sum``Redis::hIncrByFloat(..., $durationSeconds)`(避免整数毫秒精度损失)
- 对每个 `le` bucket`$durationSeconds <= le` 则递增;最后递增 `+Inf` bucket
- 抽取私有方法 `buildHttpRequestLabels()`,供 `recordHttpRequest` 与 duration 复用,避免重复 JSON 拼装
扩展 `render()`,在现有 counter 之后输出:
```text
# HELP app_http_request_duration_seconds HTTP request latency in seconds.
# TYPE app_http_request_duration_seconds histogram
app_http_request_duration_seconds_bucket{...,le="0.1"} N
...
app_http_request_duration_seconds_sum{...} X.XXX
app_http_request_duration_seconds_count{...} N
```
### 3. 中间件计时 — [`slot_wallet/app/middleware/PrometheusMiddleware.php`](slot_wallet/app/middleware/PrometheusMiddleware.php)
`process()` 开头 `$startedAt = microtime(true)``finally` 中:
```php
$durationSeconds = microtime(true) - $startedAt;
PrometheusService::recordHttpRequestDuration($method, $route, $status, $durationSeconds);
```
优化:将 `resolveRoute()` 结果缓存到局部变量,避免 `finally` 里重复解析;`recordHttpRequest``recordHttpRequestDuration` 共用同一份 `method/route/status`
耗时口径:**中间件包裹的完整 handler 执行时间**(含业务逻辑,不含 Prometheus 写 Redis 本身;写在 `finally` 末尾,影响极小)。
---
## Grafana 仪表盘扩展
更新 [`slot_wallet/doc/grafana/app_http_services_dashboard.json`](slot_wallet/doc/grafana/app_http_services_dashboard.json)在现有请求量面板下方新增一行「Latency」区域`version` 递增为 3
| 面板 | 类型 | PromQL 示例 |
|------|------|-------------|
| P50 Latency | stat | `histogram_quantile(0.50, sum by (le) (rate(app_http_request_duration_seconds_bucket{service="$service"}[$__rate_interval])))` |
| P95 Latency | stat | 同上 `0.95` |
| P99 Latency | stat | 同上 `0.99` |
| Avg Latency | stat | `sum(rate(..._sum...)) / sum(rate(..._count...))` |
| Latency Trend (P50/P95/P99) | timeseries | 三条 quantile 曲线unit=`s` |
| Top Slow Routes (P95) | bargauge | `topk(10, histogram_quantile(0.95, sum by (route, le) (rate(..._bucket{service="$service"}[$__range]))))` |
| Route Latency Details | table | 合并 route 的 P95、Avg、Requests与现有 Route Request Details 面板并列或扩列) |
所有新面板 `unit` 设为 `s`threshold 可按业务再调。
---
## 数据流(改后)
```mermaid
sequenceDiagram
participant Middleware as PrometheusMiddleware
participant Redis
participant Grafana
Middleware->>Middleware: startedAt = microtime(true)
Middleware->>Middleware: handler()
Middleware->>Redis: incr requests_total
Middleware->>Redis: incr duration buckets/sum/count
Note over Grafana: rate + histogram_quantile
Grafana->>Grafana: P50/P95/P99/Avg
```
---
## 验证步骤
1. 本地发若干 HTTP 请求到 slot_wallet含快/慢接口)。
2. `curl /metrics`,确认出现 `app_http_request_duration_seconds_bucket/_sum/_count`
3. Prometheus scrape 后,在 Grafana 导入更新后的 dashboard JSON选择 `service=slot_wallet`,确认 P50/P95 有数据。
4.`slot-backend-completion-report` 门禁(`php -l` + verify 脚本)。
---
## 注意事项
- **仅改 slot_wallet**(按你的选择);`slot_console` / `slot_pwa` 结构相同,后续可 copy 同一套改动。
- Redis Hash 会随 `route × status × bucket` 增长;与现有 `app_http_requests_total` 同一量级,可接受。
- Histogram 在 Prometheus 侧用 `rate()` + `histogram_quantile()` 算分位Grafana 查询写法与标准 Prometheus 一致。
- 无需改 Prometheus scrape 配置metric 名新增,非替换)。

View File

@@ -0,0 +1,176 @@
<!-- 3a02c714-3517-4fd8-ba3a-0c9172c34575 -->
---
todos:
- id: "parse-fbc-fbclid"
content: "在 insertADTag 回退路径实现 fbc→fbclid 解析与字段映射ad_fbp/ad_ua/ad_ip"
status: pending
- id: "redis-pop-latest"
content: "实现 resolveLatestDotPayloadBySourcezPopMax user:dot:set:{source} + unserialize 校验"
status: pending
- id: "imei-only-guard"
content: "回退逻辑仅 IMEI 新注册触发(覆写 ImeiRegisterService::insertADTag 或在 Abstract 中加平台/类型判断)"
status: pending
- id: "redis-cleanup"
content: "可选SiteController 或 pop 侧增加 ZSet 过期/长度清理"
status: pending
- id: "verify-fbdot"
content: "联调:落地页上报 → IMEI 注册 → 检查 user tag 与 FbDot 注册打点"
status: pending
isProject: false
---
# IMEI 注册 FB 像素归因补写方案评估
## 结论
**方案可行**,且与现有设计意图一致:[`slot_user/app/api/controller/SiteController.php`](slot_user/app/api/controller/SiteController.php) 负责落地页采集,[`slot_user/app/api/controller/UserController.php`](slot_user/app/api/controller/UserController.php) 里已有被注释的 `zPopMax` 回退逻辑,[`slot_user/app/service/register/ImeiRegisterService.php`](slot_user/app/service/register/ImeiRegisterService.php) 在新用户路径调用 `insertADTag`
你已确认范围:**仅 IMEI 新用户注册**(现有 `insertADTag` 调用点),不覆盖老用户登录补写。
---
## 现有数据流
```mermaid
sequenceDiagram
participant Landing as LandingPage_FB_IAB
participant Site as SiteController_fb
participant Redis as Redis_ZSet
participant App as iOS_App
participant Reg as ImeiRegisterService
participant Tag as insertADTag
participant Dot as slot_console_FbDot
Landing->>Site: POST fbp,fbc,ua,source
Site->>Redis: zAdd user:dot:set:{source}
App->>Reg: IMEI register (无 fb 参数)
Reg->>Tag: insertADTag(uid)
Tag->>Redis: 若无 fbclid 则取最新
Tag->>Tag: UserInfoCache 写入 ad_fbc/ad_fbp/ad_ua/ad_ip
Dot->>Tag: 读 ad_fbc 作为 fbclid 打 CompleteRegistration
```
| 环节 | 现状 |
|------|------|
| 落地页采集 | [`SiteController::fb`](slot_user/app/api/controller/SiteController.php) 校验 `FB_IAB` UA写入 `user:dot:set:{source}`member 为 `serialize(fbp,fbc,ua,ip,source)`score 为 `microtime` |
| IMEI 注册 | [`ImeiRegisterService::run()`](slot_user/app/service/register/ImeiRegisterService.php) 新用户分支 L115 调用 `insertADTag` |
| 写入用户标签 | [`insertADTag`](slot_user/app/service/register/AbstractRegisterService.php) 当前固定用 `$this->fbclid``ad_fbc`iOS 客户端通常为空 |
| FB 正式打点 | [`FbDot`](slot_console/app/command/dot/FbDot.php) 读 `ad_fbc`[`FbService::generateFBC()`](slot_console/app/service/dot/FbService.php) 用 fbclid 生成 fbc/fbp |
---
## 方案为何可行
1. **基础设施已就绪**Redis key [`USER_DOT_SET_KEY`](slot_user/app/service/RedisKeyManagerService.php)、落地页写入、注册侧回退思路UserController 注释代码)均已存在。
2. **iOS 场景匹配**iOS 包拿不到 WebView cookie但 FB 广告落地页在 `FB_IAB` 内可拿到 `fbp`/`fbc`,先上报再装 App 是合理补偿路径。
3. **与 PWA 不冲突**PWA 仍优先用请求里的 `fbclid`;仅当 `$this->fbclid`(及关联 fb 字段)为空时才回退 Redis。
4. **打点链路可通**`FbDot` 只要求 `ad_fbc` 非空;存入正确 fbclid 后现有 `FbService` 可继续生成 CAPI 所需 fbc/fbp。
---
## 必须处理的 4 个技术点
### 1. 字段映射(最关键)
当前 `insertADTag` 写:
```348:352:slot_user/app/service/register/AbstractRegisterService.php
protected function insertADTag(int $uid)
{
// ...
$userInfo->setInfo(['ad_fbc' => $this->fbclid, 'ad_fbp' => $this->fbp, ...], '');
}
```
Redis 里存的是落地页 **`fbc`/`fbp`**,不是 `fbclid`。而 `FbService` 期望 `ad_fbc` 存 **原始 fbclid**(见测试数据与 `generateFBC()` 实现)。
**实现要求**:从 Redis 取出 `fbc` 后,需解析出 fbclid
- `fbc` 格式:`fb.{subdomainIndex}.{creationTime}.{fbclid}`
- 取最后一个 `.` 之后的片段作为 fbclid 写入 `ad_fbc`
- `ad_fbp`、`ad_ua`、`ad_ip` 直接使用 Redis 中的值(比服务端随机生成 fbp 更准确)
- 若 `fbc` 为空SiteController 允许),则无法补 attribution应跳过写入并打 warn 日志
### 2. 取「最新」的方式
- 使用 `ZPOPMAX user:dot:set:{source} 1`(与注释代码一致),**消费**一条,避免同一条数据被下一个用户重复使用。
- 若 `insertADTag` 后续 `setInfo` 失败,可考虑不 pop 或失败写回(当前 `insertADTag` 有 try/catch失败只记日志pop 后丢失风险可接受,或改为「先 ZREVRANGE 再 ZREM 成功后再写」)。
### 3. 同渠道并发碰撞(已知局限)
按 `source` 取全局最新一条,**无法 100% 保证**「这条 fbc 就是当前这台设备」。同渠道短时间多用户注册可能串 attribution。
可接受的 MVP与当初 UserController 注释方案一致。若要提升准确率,后续可加:
- 时间窗口(如 30 分钟内)
- IP 辅助匹配(落地页 IP vs 注册 IP不完全可靠
- 落地页生成 `dot_token` 透传到 App需客户端配合改动更大
### 4. Redis 集合维护
当前 `zAdd` **无 TTL、无上限**,长期会膨胀。建议实现时顺带:
- 写入时 `ZREMRANGEBYSCORE` 清理超过 N 天(如 7 天)的旧 member
- 或按 source 限制 ZSet 最大长度
---
## 推荐改动位置(实现阶段)
不在 `UserController::register` 做(你已选 register_only + insertADTag 内聚),建议:
**主改文件**[`slot_user/app/service/register/AbstractRegisterService.php`](slot_user/app/service/register/AbstractRegisterService.php)
- 新增私有方法,例如 `resolveLatestDotPayloadBySource(string $source): ?array`
- `Redis::zPopMax(RedisKeyManagerService::getDotSetKey($source), 1)`
- `unserialize` + 校验必需字段(至少 `fbp` 非空;`fbc` 非空才能解析 fbclid
- 改造 `insertADTag`
- 若 `$this->fbclid` 为空,调用上述方法补全 `fbclid/fbp/ua/ip`
- 若补全后仍无 fbclid`return`(不写空 tag
- 否则按现有逻辑 `setInfo`
**可选**:若希望仅 IMEI 走回退、不影响 Name 注册,可把回退逻辑放在 [`ImeiRegisterService`](slot_user/app/service/register/ImeiRegisterService.php) 覆写 `insertADTag`,而非改 Abstract 基类。
---
## 前置条件(产品/客户端)
方案生效依赖用户路径:
1. 用户从 FB 广告进入落地页UA 含 `FB_IAB`
2. 落地页成功调用 `SiteController::fb`(带正确 `source`
3. 用户在合理时间窗口内完成 iOS 首次 IMEI 注册,且 `source` 与落地页一致
若用户跳过落地页直接装包注册,仍无法归因——这是架构固有限制,不是实现 bug。
---
## 风险清单
| 风险 | 级别 | 说明 |
|------|------|------|
| fbc 为空 | 中 | 落地页允许空 fbc补写无效FbDot 仍会报 fbclid 为空 |
| 同 source 串号 | 中 | 多用户并发时可能拿错上一条 fbc |
| pop 后写失败 | 低 | 数据丢失,仅影响单次 attribution |
| ad_fbp 未被 FbDot 直接使用 | 低 | 当前 FbDot 用 fbclid 重新 generate fbp落地页 fbp 写入 user tag 但 CAPI 仍用生成值。MVP 可接受;若要最佳 attribution 需后续改 FbDot 优先用 `ad_fbp`/真实 `fbc` |
---
## 验证建议
1. 落地页 mockPOST `SiteController::fb`,确认 Redis `user:dot:set:{source}` 有 member
2. IMEI 新用户注册(不带 fb 参数),查 `UserInfoCache` / user tag 中 `ad_fbc`、`ad_fbp`、`ad_ua`
3. 触发 `slot_console` 注册打点,确认 `FbDot::registerEvent` 不再报 fbclid 为空
4. 并发两条落地页 + 两次注册,观察是否出现串号(评估是否需加时间窗)
---
## 总结
**可行,且是 iOS 上架场景下合理的补偿方案。** 实现重点不是「能不能取 Redis」而是
1. 正确从 `fbc` 解析 fbclid 再写入 `ad_fbc`
2. 仅在客户端无 fb 参数时回退
3. 用 `zPopMax` 消费最新一条
4. 接受同渠道并发下的 attribution 碰撞,或后续迭代匹配策略
按你确认的范围,**只改 IMEI 新注册 `insertADTag` 即可**,无需动登录链路。

View File

@@ -0,0 +1,132 @@
<!-- 4459211d-83e4-4bc9-9a52-2fc0b9f1f467 -->
---
todos:
- id: "create-base-models"
content: "新建 app/model/GmStatSummaryModel.php 与 app/model/SlotUserModel.php表映射 + @property"
status: pending
- id: "tenant-extends"
content: "租户 Model 改为 Tenant* 继承基类,迁移统计/分页方法"
status: pending
- id: "update-imports"
content: "更新 DashboardLogic、MarketLogic 的 use 与实例化"
status: pending
- id: "docs-sync"
content: "同步 00/03 文档中的 Model 路径约定"
status: pending
- id: "verify"
content: "php -l + Logic 冒烟 + verify-slot-backend"
status: pending
isProject: false
---
# 统一数据表 Model 到 app/model 层
## 结论
**可以,且与项目既有约定一致。** [00-总览与公共约定.md](saas6.x/docs/商户后台/00-总览与公共约定.md) §4.3 已写明订单复用 `app\model\RechargeOrderModel` / `WithdrawalOrderModel``GameServerModel` 也已在 `app/model` 并被租户 Logic 直接调用。
当前实际是「**半统一**」:
| 表 | 现状 | 目标 |
|---|---|---|
| `s_pay.recharge_order` | 基类 [`app/model/RechargeOrderModel`](saas6.x/server/app/model/RechargeOrderModel.php) + 租户扩展 [`TenantRechargeOrderModel`](saas6.x/server/app/tenant/model/order/TenantRechargeOrderModel.php) | 保持不变(已是最佳实践) |
| `s_pay.withdrawal_order` | 同上 `WithdrawalOrderModel` + `TenantWithdrawalOrderModel` | 保持不变 |
| `s_common.game_server` | 已在 `app/model/GameServerModel` | 保持不变 |
| `s_statistics.gm_stat_summary` | 全在 [`app/tenant/model/market/GmStatSummaryModel`](saas6.x/server/app/tenant/model/market/GmStatSummaryModel.php) | **拆分**:基类上移到 `app/model` |
| `slot_all.user` | 全在 [`app/tenant/model/user/SlotUserModel`](saas6.x/server/app/tenant/model/user/SlotUserModel.php) | **拆分**:基类上移到 `app/model` |
```mermaid
flowchart TB
subgraph app_model [app/model 表映射层]
GmStatSummaryModel
SlotUserModel
RechargeOrderModel
GameServerModel
end
subgraph tenant_model [app/tenant/model 租户扩展层]
TenantGmStatSummaryModel
TenantSlotUserModel
TenantRechargeOrderModel
TenantWithdrawalOrderModel
end
subgraph tenant_logic [app/tenant/logic]
DashboardLogic
MarketLogic
end
GmStatSummaryModel --> TenantGmStatSummaryModel
SlotUserModel --> TenantSlotUserModel
RechargeOrderModel --> TenantRechargeOrderModel
TenantGmStatSummaryModel --> DashboardLogic
TenantGmStatSummaryModel --> MarketLogic
TenantSlotUserModel --> DashboardLogic
```
## 分层原则(你选的 base_only 方案)
### 放 `app/model`(共享表映射)
- `$table` / `$pk` / `$json`
- 完整 `@property` PHPDoc对齐 DDL
- **与业务场景无关**的基础查询:按 `source+date` 查单行、通用 scope 等
- **不**放 `organization + channels` 租户隔离逻辑(隔离仍在 Logic/Controller
### 保留 `app/tenant/model`(租户扩展)
- `extends app\model\*Model`
- 商户后台专用统计方法,例如:
- `paginateDailyRows()` / `listChannelSummaryRows()`(市场数据)
- `sumDailyPrecompute()` / `sumTotals()`(首页)
- `countRegisterByWindow()`(首页注册数)
- `sumSuccessByPayTime()`(订单统计,已有)
### 关键约束:不要误用 `BaseSourceModel`
[`BaseSourceModel`](saas6.x/server/app/model/BaseSourceModel.php) 带 `Source` 请求头全局 scope适用于 **admin 总后台**`RechargeOrderModel` 等)。
`GmStatSummaryModel` / `SlotUserModel`**直接 `extends think\Model`**(与现网一致),**不要**继承 `BaseSourceModel`,否则租户请求可能被错误的 `Source` header 隐式过滤。
租户数据隔离仍由以下保证(迁 Model 位置**不改变**
- `MerchantBaseController::ensureMerchantChannels()`
- Logic 入参显式 `organization` + `channels`
- `restrictRequestSourceByChannels()` 收敛请求 `source`
## 建议的文件变更
### 1. 新建 `app/model/GmStatSummaryModel.php`
- 从现有 tenant 版抽出:`$table = s_statistics.gm_stat_summary`、完整 `@property`
- 可保留 1~2 个中性方法:`findBySourceAndDate(string $source, string $date): ?self`
### 2. 新建 `app/model/SlotUserModel.php`
- 表映射 `slot_all.user` + 完整 `@property`(对齐 [03-用户管理.md](saas6.x/docs/商户后台/03-用户管理.md) 字段)
- 文档写的是 `UserAllModel`,代码可统一命名为 `SlotUserModel`(与表库名一致)或按文档改为 `UserAllModel`(二选一,建议 `SlotUserModel` 减少重命名面)
### 3. 租户扩展改名/改继承
| 现文件 | 调整后 |
|---|---|
| `app/tenant/model/market/GmStatSummaryModel.php` | 重命名为 `TenantGmStatSummaryModel.php``extends app\model\GmStatSummaryModel`,保留 `paginateDailyRows` / `listChannelSummaryRows` / `sumDailyPrecompute` / `sumTotals` |
| `app/tenant/model/user/SlotUserModel.php` | 重命名为 `TenantSlotUserModel.php``extends app\model\SlotUserModel`,保留 `countRegisterByWindow` |
### 4. 更新引用
- [`DashboardLogic.php`](saas6.x/server/app/tenant/logic/dashboard/DashboardLogic.php)`TenantGmStatSummaryModel` / `TenantSlotUserModel`
- [`MarketLogic.php`](saas6.x/server/app/tenant/logic/market/MarketLogic.php)`TenantGmStatSummaryModel`
### 5. 文档同步(小改)
- [00-总览与公共约定.md](saas6.x/docs/商户后台/00-总览与公共约定.md) §4.3:补充「`app/model` 放表映射,租户扩展放 `app/tenant/model`
- [03-用户管理.md](saas6.x/docs/商户后台/03-用户管理.md)Model 路径改为 `app/model/SlotUserModel` + `app/tenant/model/user/TenantSlotUserModel`
## 不在本次范围
- 不改 `RechargeOrderModel` / `WithdrawalOrderModel`(已符合模式)
- 不把租户 Logic 里的 `organization/channels` 过滤下沉到 Model global scope避免与 admin 混用)
- 不新建无业务价值的 `*Service` 中转层
## 验收
- `php -l` 全部改动 PHP 通过
- `DashboardLogic` / `MarketLogic` 行为不变(冒烟:`paginateDailyList``buildOverviewSummary`
- verify-slot-backend 门禁 PASS

View File

@@ -0,0 +1,53 @@
<!-- 921d6aef-b550-4dca-b907-d43c81593092 -->
---
todos:
- id: "move-logic"
content: "将 FirmwareLogic、UpgradeRecordQueryLogic 迁到 app/admin/logicnamespace 改为 app\\admin\\logic"
status: pending
- id: "move-validate"
content: "将 FirmwareCreateValidate、FirmwareListValidate、UpgradeRecordListValidate 迁到 app/admin/validatenamespace 改为 app\\admin\\validate"
status: pending
- id: "update-controllers"
content: "更新 FirmwareController、UpgradeRecordController 的 use 语句指向新命名空间"
status: pending
- id: "update-doc"
content: "同步 docs/requirements/OTA-admin.md §9 实现落点路径"
status: pending
- id: "verify"
content: "跑 php -l 与后端门禁脚本校验迁移结果"
status: pending
isProject: false
---
# OTA 后台专属 Logic/Validate 按应用拆分
## 背景与判断
这是 webman 多应用结构,控制器已按应用拆(`app/admin/controller/``app/internal/controller/`),但 Logic/Validate 仍堆在共用目录。`FirmwareLogic` / `UpgradeRecordQueryLogic` 与 3 个 Validate 只被 `app/admin/controller/*` 使用,属后台专属,挪到 `app/admin/` 下符合现有约定。`app\ → ./app` 的 PSR-4 映射使 `app\admin\logic` / `app\admin\validate` 自动加载,无需改 composer。
## 迁移清单(仅改 namespace + 路径,类体不动)
- Logic`namespace app\logic``app\admin\logic`
- `app/logic/FirmwareLogic.php``app/admin/logic/FirmwareLogic.php`
- `app/logic/UpgradeRecordQueryLogic.php``app/admin/logic/UpgradeRecordQueryLogic.php`
- Validate`namespace app\validate``app\admin\validate`
- `app/validate/FirmwareCreateValidate.php``app/admin/validate/FirmwareCreateValidate.php`
- `app/validate/FirmwareListValidate.php``app/admin/validate/FirmwareListValidate.php`
- `app/validate/UpgradeRecordListValidate.php``app/admin/validate/UpgradeRecordListValidate.php`
## 引用更新
- `app/admin/controller/FirmwareController.php``use app\logic\FirmwareLogic;``use app\admin\logic\FirmwareLogic;``use app\validate\FirmwareCreateValidate;` / `FirmwareListValidate;``app\admin\validate\...`
- `app/admin/controller/UpgradeRecordController.php``use app\logic\UpgradeRecordQueryLogic;``app\admin\logic\...``use app\validate\UpgradeRecordListValidate;``app\admin\validate\...`
## 不改动
- 内部专属 `OtaCheckLogic``OtaReportLogic` 与进程专属 `OtaRecordExpireLogic` 保留在 `app/logic/`(本次范围只动后台 CRUD
- DTO`OtaCheckDTO`/`OtaReportDTO`、Model、tests 不涉及这些类,无需改。
## 文档同步
- `docs/requirements/OTA-admin.md` §9 实现落点:把 `app/logic/FirmwareLogic.php``app/logic/UpgradeRecordQueryLogic.php` 改为 `app/admin/logic/...``app/validate/*` 改为 `app/admin/validate/*`
## 收尾校验
- 迁移后对 5 个新文件与 2 个控制器跑容器内 `php -l`,并执行后端门禁脚本 `~/.cursor/skills/slot-backend-completion-report/scripts/report.sh`,确认删除的旧类名不在其它已改文件中出现。

View File

@@ -0,0 +1,180 @@
<!-- 454dc363-a500-4be5-a576-c2dd86e5515a -->
---
todos:
- id: "remove-prewallet-check"
content: "Phase 1: 删除 executeBetWalletTransfer 中 getWallet 预检,改由 wallet bet 返回映射余额不足"
status: pending
- id: "defer-ledger-query"
content: "Phase 1: finalizeSuccessfulTransfer 去掉同步 queryBizLogmarkWalletSuccess(0) + 异步 ledger 回填"
status: pending
- id: "wallet-return-ledger"
content: "Phase 2: slot-wallet update 响应增加 ledger_idPOP 直接写入 provider_tx"
status: pending
- id: "merge-db-updates"
content: "Phase 3可选: 合并 callback_log / provider_tx 多次 UPDATE并加分段耗时监控"
status: pending
isProject: false
---
# POP modifyFee 性能分析与队列化建议
## 结论(直接回答)
**是的,同步路径偏重,但不宜把「整单回调」丢进队列。**
- Controller [`slot-pwa/app/pop/controller/CashController.php`](slot-pwa/app/pop/controller/CashController.php) 本身很薄,性能问题在 [`slot-pwa/app/pop/logic/CashLogic.php`](slot-pwa/app/pop/logic/CashLogic.php)。
- POP 协议要求 **同步返回最新 `balance`**,且 **钱包扣款/派奖必须在响应前完成**,否则厂商会重试或判失败。
- 相比 jdb/quick/gasea只做 wallet + Redis 幂等 + 异步流水POP 额外引入了 **callback_log、provider_tx、game_round** 三套分表状态机,单次成功回调大约 **6~10 次 DB 写 + 2~3 次 wallet HTTP**
- **已有异步**:用户交易流水(`TransactionLogService::log``pwa_transaction_log` worker、局终事件`GameRoundEventService::publishFinalSettled`)、试玩活动(`TrialRewardNotifyService` MQ publish
因此:**可以队列化的是「不影响 POP 响应正确性的旁路写/追踪」****不能队列化的是幂等、钱包、局状态校验**。
---
## 当前同步链路(成功路径 BET
```mermaid
sequenceDiagram
participant POP
participant MW as ProviderCallbackLogMiddleware
participant Logic as CashLogic
participant DB as ShardDB
participant Wallet as slot_wallet
participant MQ as RabbitMQ
POP->>MW: TransferInOut
MW->>DB: insert callback_log
MW->>Logic: modifyTransferInOut
Logic->>DB: update callback_log parse/in_progress
Logic->>DB: createOrGet provider_tx
Logic->>DB: loadOrCreate game_round
Logic->>DB: markProcessing / markWalletCalling
Logic->>Wallet: getWallet (BET 余额预检)
Logic->>Wallet: update bet
Logic->>Wallet: queryBizLog
Logic->>DB: markWalletSuccess + round update
Logic->>MQ: TransactionLog (async)
Logic->>DB: markProcessSuccess
Logic->>POP: balance
```
对比 legacy [`slot-pwa/app/quick/service/CashService.php`](slot-pwa/app/quick/service/CashService.php)**无 callback_log / provider_tx / game_round**wallet 成功后只发 MQ 写流水,同步面小很多。
---
## 同步步骤分类
| 步骤 | 位置 | 是否必须同步 | 说明 |
|------|------|-------------|------|
| callback_log 收包落库 | Middleware | 建议保留同步 | 审计与 trace但可合并多次 update |
| provider_tx create-or-get | `resolveProviderTx` | **必须** | 幂等键,防双花;必须在 wallet 前 |
| game_round load/create/validate | `RoundService` | **必须** | 后续 WIN/FINAL 依赖局状态 |
| 风控 evaluate + applyRisk | `handleTransferRiskEvaluation` | **必须**(接入真实风控后) | HOLD/REJECT 需在 wallet 前 |
| getWallet 余额预检 | `executeBetWalletTransfer` | **可优化掉** | wallet 本身会拒余额不足,多 1 次 RTT |
| WalletService bet/win | `invokeWalletBetOrWin` | **必须** | 响应 balance 来源 |
| queryBizLog | `finalizeSuccessfulTransfer` | **可移出热路径** | 仅回填 `wallet_ledger_id`;已有补偿 [`ProviderTxCompensationService`](slot-pwa/app/service/game/ProviderTxCompensationService.php) |
| game_round applyBet/Win/Final | `applyRoundUpdateAfterSuccess` | **必须** | 同局后续回调校验 |
| Redis round 缓存 | `updateRoundStateInfo` | 可弱一致异步 | 有 DB fallback但异步需防乱序 |
| TransactionLog | `TransactionLogService::log` | **已异步** | worker 内还做 VIP/日报/big win |
| callback_log 终态 | `markProcessSuccess` | 可延迟 | 不影响 POP 响应;影响实时排障 |
| UserProfit / RTP stat | `WalletService::bet/win` 内 | 可异步 | Redis 统计,非 POP 协议字段 |
---
## 主要性能瓶颈(按收益排序)
### 1. 冗余 wallet HTTP最高收益、最低风险
**BET 路径双查钱包:**
```686:711:slot-pwa/app/pop/logic/CashLogic.php
$currentWallet = WalletService::getWallet($userInfo->uid, $userInfo->currency);
if ($currentWallet->balance < $feeAmount) {
// ... markRejected ...
}
// ...
return $this->invokeWalletBetOrWin($walletCommand, true);
```
- 先 `getWallet`,再 `updateWallet(bet)`**多 1 次完整 HTTP 往返**。
- 建议:删除预检,直接 `bet()`,按 wallet 返回/错误码映射 `CODE_INSUFFICIENT_FUNDS`(与 legacy 一致)。
**成功后二次查询 ledger**
```829:839:slot-pwa/app/pop/logic/CashLogic.php
$ledgerQuery = $this->walletGatewayService->queryBizLog(
$transferContext->uid(),
$finalizeContext->walletRequestId,
ProviderTxService::resolveWalletBizType($finalizeContext->txType)
);
$walletLedgerId = (int) ($ledgerQuery['ledger_id'] ?? 0);
$this->providerTxService->markWalletSuccess(..., $walletLedgerId);
```
- 钱包 `update` 响应 [`WalletEntity`](slot-pwa/app/entity/WalletEntity.php) **不含 `ledger_id`**,故又调 `queryBizLog`**再多 1 次 HTTP**。
- 两条路(二选一或组合):
- **A推荐**:扩展 [`slot-wallet`](slot-wallet/app/api/logic/WalletLogic.php) `update` 响应带 `ledger_id`POP 直接写入,去掉热路径 `queryBizLog`。
- **B低改动**:热路径 `markWalletSuccess(ledger_id=0)`,通过现有 `ProviderTxWalletCompensate` 或新建轻量 MQ consumer 异步回填 `wallet_ledger_id`(与 reward_grant 文档中 queryBizLog 补偿模式一致)。
### 2. 多次分表状态机 UPDATE中等收益
单次回调对 `callback_log`、`provider_tx` 分别多次 `UPDATE`parse → in_progress → wallet_calling → successmiddleware 还有 insert。可考虑
- 合并 `markParseSuccess` + `markProcessInProgress` 为一次写(需改 Model SQL
- provider_tx 状态迁移合并processing + wallet_calling 若对排障非强依赖可合并)。
- **注意**:这是 DB 优化,不是 MQ队列化这些状态反而增加一致性复杂度。
### 3. 适合队列、且不影响 POP 响应的旁路
| 候选 | 做法 | 风险 |
|------|------|------|
| `wallet_ledger_id` 回填 | MQ / 定时补偿 | 低;审计字段短暂为 0 |
| `callback_log` 终态写 | 响应后 defer 或 MQ | 中;实时查 log 延迟 |
| `UserProfitService` / `GameUserRechargeRtpStatService` | 挪到 TransactionLog worker | 低;统计延迟秒级 |
| Redis round 缓存更新 | 响应后 MQ | 中;需 uid+round 有序消费 |
**不适合队列:**
- 整个 `modifyFee` / wallet bet-win厂商等 balance
- `provider_tx` 幂等创建(必须先于 wallet
- `game_round` 聚合更新(同局后续回调依赖)
---
## 与「全异步回调」方案的边界
若把 wallet 也异步化:
- POP 无法立即拿到 balance → **协议不满足**
- 失败重试会与幂等、局状态、钱包状态产生 **竞态**,需大量补偿与对账
现有设计已采用更合理模式:**同步关键路径 + 异步旁路**流水、局终事件。POP 的问题是 **关键路径上叠了审计表 + 多余 HTTP**,而非「异步做得不够」。
---
## 建议实施顺序(若后续要改代码)
### Phase 1 — 热路径减负(推荐先做,改动集中在 slot-pwa
1. 去掉 BET 前 `getWallet` 预检,统一由 wallet 拒单。
2. `finalizeSuccessfulTransfer` 去掉同步 `queryBizLog``markWalletSuccess(ledger_id=0)` + 触发异步回填(复用 `ProviderTxCompensationService` 或新增 `provider_tx_ledger_backfill` 队列)。
3. 补充 Feature 测试余额不足、幂等重复、wallet 成功但 ledger 延迟回填。
### Phase 2 — 跨服务优化(需 slot-wallet 配合)
1. `POST /api/wallet/update` 响应增加 `ledger_id`bet/win 写入后返回)。
2. slot-pwa 优先读响应字段,补偿任务作兜底。
### Phase 3 — DB/观测优化(可选)
1. 合并 callback_log / provider_tx 多次 UPDATE。
2. 对 `modifyTransferInOut` 加分段耗时 metricsmiddleware / provider_tx / round / wallet / finalize用数据验证 Phase 1/2 收益。
---
## 预期收益(量级)
- Phase 1BET 成功路径 **减少 1~2 次 wallet HTTP**(通常 20~80ms/次,视网络与 wallet 负载)。
- Phase 2WIN/FINAL 成功路径再 **减少 1 次 queryBizLog**
- 队列化旁路ledger 回填、统计):**不缩短 POP 感知 RTT 的主矛盾**,但降低峰值时 wallet/DB 叠加压力。
**不能指望**:把 callback_log / provider_tx / game_round 全部异步后 POP RTT 接近 quick/jdb——这三张表是 POP 架构的有意同步成本,用于幂等、审计、局聚合。

146
plans/PRD-9774f234.plan.md Normal file
View File

@@ -0,0 +1,146 @@
<!-- 9774f234-db58-4d1d-a960-14f15586ebc9 -->
---
todos:
- id: "user-input"
content: "用户下一条消息提供:变更一句话、涉及仓库、原因、数据影响、优先级"
status: pending
- id: "draft-prd"
content: "按 docs/requirements 体例撰写变更 PRD含变更前后对比表与验收标准"
status: pending
- id: "index-link"
content: "若属已有需求体系,更新 README/父文档索引并链到新文档"
status: pending
- id: "user-review"
content: "根据用户反馈修订开放问题与验收项"
status: pending
isProject: false
---
# 协作撰写变更需求 PRD
## 可以做什么
可以帮你写**变更需求Change RequestPRD**,结构对齐你仓库里已有规范,例如:
- 父文档:[docs/requirements/game_gateway_risk_prd_v_2.md](docs/requirements/game_gateway_risk_prd_v_2.md)
- 子需求:[docs/requirements/game_gateway/01_launch_session.md](docs/requirements/game_gateway/01_launch_session.md)
这些文档已包含:**目标与范围In/Out scope**、**依赖与边界**、**业务流程**、**数据表/接口**、**验收标准**——与你选择的「标准 PRD」一致。
---
## 当前阻塞:需要你补充变更说明
你已选择:**其它主题** + **变更需求** + **下一条消息文字说明**。在 Plan 模式下还缺少以下最小信息(建议下一条回复用 510 行写完):
| 必填 | 示例 |
|------|------|
| 变更一句话 | 「渠道创建时编号由运营手填改为系统生成」 |
| 涉及系统/仓库 | `slot-admin``slot-admin-vue``slot_wallet` 等 |
| 变更原因 | 运营误填、编号冲突、对接要求 |
| 是否影响线上数据 | 仅新数据 / 需迁移历史 |
| 期望上线时间或优先级 | P0 / 本迭代 / 可排期 |
可选但有助于写准:
- 相关页面或接口路径
- 已知约束(不能改表、必须兼容某厂商等)
- 是否需挂到现有父文档(如 Game Gateway 系列)
---
## 变更需求 PRD 文档结构(将按此产出)
在标准 PRD 基础上,**变更类**会额外突出「前后差异」,建议章节如下:
```mermaid
flowchart TB
input[你提供的变更说明]
code[可选:对照现有代码/表结构]
draft[撰写 PRD 初稿]
review[你确认/修订]
output[Markdown 落库或对话交付]
input --> code
code --> draft
draft --> review
review --> output
```
### 1. 文档元信息
- 标题、版本(如 V1.1-change、日期、作者/评审人
- 关联父文档(若属于 Game Gateway 等已有体系)
### 2. 背景与变更动机
- 现状问题As-Is
- 变更目标To-Be
- 不做的范围Out of scope
### 3. 变更摘要(核心)
| 维度 | 变更前 | 变更后 |
|------|--------|--------|
| 业务规则 | … | … |
| 界面/交互 | … | … |
| 接口/字段 | … | … |
| 数据/缓存 | … | … |
### 4. 功能需求(按角色或模块)
- 管理端 / 用户端 / innerapi / 跨服务slot_sdk分项
- 每条需求:**描述 + 规则 + 异常/边界**
### 5. 接口与数据(如涉及)
- API 路径、请求/响应字段变更(新增/废弃/只读)
- 表字段、索引、Redis key、MQ若有
- 幂等、事务、兼容性说明
### 6. 非功能需求
- 性能、安全、日志、监控、回滚策略
### 7. 验收标准(可测试)
- Given / When / Then 或检查清单
- 回归范围(哪些旧功能不能坏)
### 8. 实施与发布建议(可选)
- 涉及仓库与大致文件(实施阶段再细化)
- 发布顺序、配置开关、数据迁移步骤
### 9. 风险与开放问题
- 待产品/技术确认项列表
---
## 撰写时我会怎么做(确认计划后执行)
1. **读你提供的变更说明**必要时只读检索相关代码Controller / Logic / Model / Vue保证 PRD 与实现或意图一致。
2. **对齐仓库体例**表格元信息、In/Out scope、mermaid/文本流程图,与 [01_launch_session.md](docs/requirements/game_gateway/01_launch_session.md) 风格一致。
3. **落盘位置**(默认,你可改):
- 独立变更:`docs/requirements/<模块>/<简短英文名>_change.md`
- 若属 Game Gateway 体系:在 [game_gateway/README.md](docs/requirements/game_gateway/README.md) 索引中增加一行,并链到父文档
4. **交付方式**:默认写入仓库;若你只希望对话里预览,说明「不要写文件」即可。
**不会**在未经你确认前修改业务代码Plan 阶段仅产出文档。
---
## 与「反推 PRD」的区别
你选的是**变更需求**,不是从零或纯反推。若变更其实已部分落地(例如 [GameServerLogic.php](slot-admin/app/game/logic/GameServerLogic.php) 里渠道编号自动生成),可在 PRD 中标注 **「已实现 / 待实现」**,便于评审与补测。
---
## 你确认计划后的下一步
请在下一条消息直接粘贴变更说明(不必等再点选)。收到后我将:
1. 定文档标题与存放路径
2. 输出完整 PRD 初稿Markdown
3. 请你审阅「开放问题」与验收项,再按需修订一版
若变更属于 Game Gateway / 渠道 / 钱包等已有域,请点名,我会自动挂接对应父文档索引。

157
plans/Plan-0abba57d.plan.md Normal file
View File

@@ -0,0 +1,157 @@
<!-- 0abba57d-3d88-4972-ad6e-96d899cbee65 -->
---
todos:
- id: "expiry-service-mark-only"
content: "TrialBalanceExpiryService新增 markTrialExpiredIfNeeded / isTrialPlayBlocked / ensureCanLaunchGame停止对外清零编排"
status: pending
- id: "ws-connect"
content: "TrialBalanceExpiryConnectService仅打 tag + 弹窗,移除 clearExpiredTrialBalanceIfNeeded"
status: pending
- id: "entry-logic"
content: "TrialRewardEntryLogic移除全路径清零修正 trial_balance_expired 判定"
status: pending
- id: "game-launch-guard"
content: "GameLogic + GameController::login体验过期未充值时 BusinessException 拦截"
status: pending
- id: "constants-copy"
content: "TrialRewardEntryConstants更新弹窗与 launch 拒绝文案"
status: pending
- id: "verify"
content: "docker php -l + slot-backend-completion-report 自检"
status: pending
isProject: false
---
# 体验到期:仅标识、不清余额、禁止进游戏
## 背景与现状
当前 [`TrialBalanceExpiryConnectService`](slot-console/app/service/trial/TrialBalanceExpiryConnectService.php) 在 WS 连接时:
1. 调用 `clearExpiredTrialBalanceIfNeeded` → wallet `expireTrialBalance` + activity 删 `trial_reward_record`
2. 打 user tag `trial_bonus_expired`
3. 推送弹窗文案写「Trial balance has been cleared」
[`TrialRewardEntryLogic`](slot-console/app/api/logic/TrialRewardEntryLogic.php) 在 entry/status/withdraw-click 三条路径同样调用清零。
这与需求文档 [`03_wallet_fund_flow.md`](docs/requirements/trial_withdrawal/03_wallet_fund_flow.md)「不再产生 `TRIAL_BONUS_EXPIRE`」不一致。wallet 侧 [`isTrialPhase`](slot-wallet/app/service/WalletService.php) 在超期后已返回 `false`,体验金留在 `frozen_bonus` 但无法用于试玩下注;需在 **console 启动接口** 显式拦截。
## 目标行为
```mermaid
flowchart TD
subgraph wsConnect [WS连接事件]
A[未充值且体验期已过] --> B[打 user tag trial_bonus_expired]
B --> C[推送 WS 弹窗一次]
A --> D[不调用 wallet expireTrialBalance]
A --> E[不调用 activity trial-balance/expire]
end
subgraph gameLaunch [game/launch 与 game/login]
F[未充值且体验期已过] --> G[拒绝启动 BusinessException]
H[已充值或仍在体验期内] --> I[正常 launch]
end
```
| 场景 | 余额 | activity 记录 | user tag | 进游戏 |
|------|------|---------------|----------|--------|
| 体验期内、未充值 | 保留 | 保留 | 无 | 允许 |
| 体验过期、未充值 | **保留 frozen_bonus** | **保留** | 标记过期 | **拒绝** |
| 已充值 | 按首充规则 | 按首充规则 | 不处理 | 允许 |
## 实现步骤
### 1. 收敛「到期处理」到仅标识(`TrialBalanceExpiryService`
文件:[`slot-console/app/service/trial/TrialBalanceExpiryService.php`](slot-console/app/service/trial/TrialBalanceExpiryService.php)
- 新增 `markTrialExpiredIfNeeded(int $uid, string $source, string $currency, int $organization): bool`
- 已充值 → 直接返回 `false`
- `resolveWalletSnapshot` + `resolveTrialBalanceExpiry` 未过期 → `false`
- 已过期 → 仅 `markTrialBonusExpiredUserTag($uid)`,返回 `true`
- **不**调用 `expireTrialBalance`、**不**调用 `markActivityTrialBalanceExpired`
- 新增 `isTrialPlayBlocked(int $uid, string $source, string $currency, int $organization): bool`(供 launch 守卫复用):
- 逻辑:`!has_recharged && resolveTrialBalanceExpiry(...).expired`
- 新增 `ensureCanLaunchGame(...)`:当 `isTrialPlayBlocked` 为真时 `throw new BusinessException(...)`
- `clearExpiredTrialBalanceIfNeeded` 保留方法体但 **删除所有调用方**(避免误用);类注释改为「到期判定与标识」,去掉「清零」表述
- `markActivityTrialBalanceExpired` 可保留 private 方法(历史兼容),但本需求下不再被调用
### 2. 改写 WS 连接处理
文件:[`slot-console/app/service/trial/TrialBalanceExpiryConnectService.php`](slot-console/app/service/trial/TrialBalanceExpiryConnectService.php)
- 删除 `clearExpiredTrialBalanceIfNeeded``wasBalanceCleared` / `hadNoTrialBalance` 分支
- 流程简化为:
1. user tag 已标记 → return幂等防重复弹窗
2. 已充值 → return
3. 未过期 → return
4. `markTrialBonusExpiredUserTag` + `WsService::notifyClientPOP` + info 日志
- 更新类 PHPDoc「检测试玩到期打 user tag、推送弹窗不清零余额
### 3. 入口 Logic 停止清零并修正 `trial_balance_expired`
文件:[`slot-console/app/api/logic/TrialRewardEntryLogic.php`](slot-console/app/api/logic/TrialRewardEntryLogic.php)
-`buildEntryPayload` / `buildStatusPayload` / `resolveWithdrawClick` 中:
- 移除 `clearExpiredTrialBalanceIfNeeded` 调用及 `trialBalanceBeforeClear` 相关刷新逻辑
- `trial_balance_expired` 改为:`$trialExpiry['expired'] && !$walletSnapshot['has_recharged']`(不再依赖 `bonus_amount <= 0`
- 删除 private `clearExpiredTrialBalanceIfNeeded` 方法
- 移除对 [`TrialExpiredBalanceClearCommand`](slot-console/app/api/dto/TrialExpiredBalanceClearCommand.php) 的 useDTO 可保留文件以免大范围删除,但无引用)
### 4. game/launch 与 game/login 拦截
文件:[`slot-console/app/api/logic/GameLogic.php`](slot-console/app/api/logic/GameLogic.php)
- 构造注入或懒加载 `TrialBalanceExpiryService`
- `launch()` 开头调用 `ensureCanLaunchGame($dto->uid, source, currency, organization)`
- source/currency/organization 从 `GameLaunchDTO``userEntity` 读取(与 wallet 快照现有参数对齐)
- [`GameController::login`](slot-console/app/api/controller/GameController.php) 在调用 `SlotPlatformService::login` 前同样守卫deprecated 路径仍可能被 `launchByGameId` 使用)
错误处理:抛 `Webman\Exception\BusinessException`,消息使用新常量;[`api/exception/Handler`](slot-console/app/api/exception/Handler.php) 已注册,前端 `useGameLaunch` 会 toast 该 message。
### 5. 常量与文案
文件:[`slot-console/app/constants/TrialRewardEntryConstants.php`](slot-console/app/constants/TrialRewardEntryConstants.php)
- 更新 `POPUP_MESSAGE_TRIAL_BALANCE_EXPIRED`去掉「balance has been cleared」改为体验期结束、需充值后才能继续游戏英文与产品口径一致
- 新增 `GAME_LAUNCH_BLOCKED_MESSAGE`launch 拒绝文案,可与弹窗语义一致)
`CLEAR_SOURCE_*` 常量暂保留activity 接口仍存在,仅本链路不再调用)。
### 6. 不改动的服务
- **slot-wallet**:不删 `expireTrialBalance` API历史兼容本需求不再调用
- **slot-activity**`trial-balance/expire` innerapi 保留,本需求不再调用
- **slot-pwa**:按你的选择不拦截
- **lobby**:依赖 launch API 错误 message 即可,无需必改
## 关键代码对照
WS 连接当前清零调用(将删除):
```65:75:slot-console/app/service/trial/TrialBalanceExpiryConnectService.php
$clearOutcome = $this->trialBalanceExpiryService->clearExpiredTrialBalanceIfNeeded(
new TrialExpiredBalanceClearCommand(
$uid,
$channelSource,
$currency,
$organization,
$walletSnapshot,
[]
),
TrialRewardEntryConstants::CLEAR_SOURCE_WS_CONNECT
);
```
入口当前过期判定(将修改):
```112:112:slot-console/app/api/logic/TrialRewardEntryLogic.php
'trial_balance_expired' => $walletSnapshot['bonus_amount'] <= 0 && $trialExpiry['expired'],
```
## 验证要点
1. 模拟未充值用户、`wallet.created_at` 超过配置 `validity_hours`WS 连接一次 → user tag 写入、收到弹窗、**bonus 余额不变**、无 `TRIAL_BONUS_EXPIRE` 流水
2. 再次 WS 连接 → 无重复弹窗tag 幂等)
3. 同用户调用 `POST /api/game/launch` → BusinessException不返回 url
4. 同用户 `GET /api/trial-reward/entry` → `trial_balance_expired=true``trial_balance` 仍 > 0
5. 已充值用户超体验窗口 → launch 正常、WS 不弹窗
6. 收尾跑 `slot-backend-completion-report` 与 verify 脚本

View File

@@ -0,0 +1,77 @@
<!-- 214f69a5-3175-4601-b838-a7982eb9d190 -->
---
todos:
- id: "group-model"
content: "WalletFundLotGroupModel 扩展 SOURCE_TYPE_* 覆盖券/会员/活动/直接发奖,并加 createSingleMemberGroup 便捷封装(中文常量注释)"
status: pending
- id: "recharge-group"
content: "RechargeFundLotService纯 Deposit / 纯 Bonus 也建单成员组并写 group_norequired_wager=该 Lot 所需)"
status: pending
- id: "register-group"
content: "RegisterServicefee>0 且 bonus=0 时建单成员 Deposit 组Trial(fee=0) 保持不建"
status: pending
- id: "grant-group"
content: "GrantBonusService注入组模型直接发奖 Bonus 建单成员组并写 group_no"
status: pending
- id: "win-unify"
content: "WinService去掉未分组结算路径统一 finalizeGroup补全 deposit-only/bonus-only 退化组的 Completed/PlayedOut 语义"
status: pending
- id: "bet-cash"
content: "BetService确认/清理 Cash→FIFO progress_group 计入逻辑,移除未分组丢弃相关死分支与注释"
status: pending
- id: "task-display"
content: "PlayerTaskQueryService以组为唯一任务结构未分组分支降为历史兜底或移除"
status: pending
- id: "migration"
content: "新增回填 command将存量进行中未分组 Lot 迁为单成员组(按 uid 分片、幂等)"
status: pending
- id: "doc-update"
content: "更新 docs/requirements/钱包系统V2.md §7/§10/§11人人有组 + Cash 计入 progress_group"
status: pending
- id: "tests"
content: "WalletGroupWageringTest 补单成员组与 Cash 计入打码进度用例"
status: pending
- id: "verify"
content: "收尾跑 verify-slot-backend.sh 门禁 + docker php -l输出检测报告"
status: pending
isProject: false
---
# 钱包打码组统一(路线 A人人有组
## 目标与硬约束
- 真金每笔 `wallet_fund_lot` 都归入一个 `wallet_fund_lot_group`**不再有 `group_no=''` 的真金 Lot**(单 Lot = 单成员退化组)。
- **Cashwithdraw有效投注必须计入当前 FIFO `progress_group`**(修掉「只有未分组 Lot 时 Cash 白打」的缺陷)。
- **Trial 体验金永远独立**:不建 Lot、不建组、不进 `BetService/WinService`(已天然满足,仅需保证不破坏)。
- 复用现有三表,**不改表结构**;仅做数据回填迁移。
## 现状关键事实(已核对)
- 建组只在「Deposit+Bonus 同时存在」时发生:`RechargeFundLotService::resolveRechargeGroupNo``RegisterService::resolveRegisterGroupNo``deposit<=0 || bonus<=0` 返回空串)。
- 纯发奖 Bonus 永远未分组:`GrantBonusService::insertDirectRewardBonusLot``insertFundLot` 不带 `group_no`)。
- Cash 投注归属:`BetService::accumulateGroupWager``withdrawValidBet` 计入 `lockEarliestActiveGroup`**无 Active 组时直接丢弃**(缺陷根因)。
- 结算双路径:`WinService::execute` 同时跑 `finalizeActiveGroups` + `finalizeUngroupedLots`
- 展示双路径:`PlayerTaskQueryService::queryTaskProgress` 返回 `groups` + 未分组 `bonus_tasks/deposit_tasks`
## 设计决定
- 单成员组:`group_no` 复用来源键(`order_no` / 注册 `biz_id` / grant `wallet_request_id``deposit_amount`/`bonus_amount` 一侧为该 Lot 金额、另一侧 0`required_wager = lot.required_wager`
- `WalletFundLotGroupModel` 扩展 `SOURCE_TYPE_*`,覆盖券/会员/活动/直接发奖(现仅 REGISTER=1、RECHARGE=4
- `BetService`:成员投注计入自身 `group_no`、Cash 计入 FIFO 最早 Active 组——逻辑不变但因「人人有组」Cash 不再丢失;`consumeForBet` 仍累加 Lot 级 `current_wager` 供明细展示。
- `WinService`:去掉未分组结算路径,统一走 `finalizeGroup`;补全**单边退化组**deposit-only / bonus-only的 Completed / PlayedOut 语义。
- 迁移期可保留 `finalizeUngroupedLots` 作为历史兜底,回填完成后再删。
## 影响文件
- `slot-wallet/app/model/multi/WalletFundLotGroupModel.php`:加 `SOURCE_TYPE_*`;可加 `createSingleMemberGroup()` 便捷封装。
- `slot-wallet/app/service/wallet/RechargeFundLotService.php`:纯 Deposit、纯 Bonus 也建单成员组并写 `group_no`
- `slot-wallet/app/service/wallet/RegisterService.php``fee>0 & bonus=0` 建单成员 Deposit 组。
- `slot-wallet/app/service/wallet/GrantBonusService.php`:注入组模型,直接发奖 Bonus 建单成员组并写 `group_no`
- `slot-wallet/app/service/wallet/WinService.php`:统一结算路径,处理退化组终态。
- `slot-wallet/app/service/wallet/BetService.php`:清理「未分组 Cash 丢弃」相关分支与注释,确认 Cash→FIFO 组生效。
- `slot-wallet/app/service/wallet/PlayerTaskQueryService.php`:以组为唯一任务结构;未分组分支降为历史兜底或移除。
- 迁移:新增 command参照 `app/command/InitDB.php`)回填存量「进行中」未分组 Lot 为单成员组。
- 文档:`docs/requirements/钱包系统V2.md` §7 / §10 / §11 改为「人人有组 + Cash 计入 progress_group」。
- 测试:`slot-wallet/tests/Feature/WalletGroupWageringTest.php` 补单成员组与 Cash 计入用例。
## 风险 / 待确认
- 退化组 PlayedOutdeposit-only 组资金耗尽未达标时 Deposit 成员状态如何置(现 `markGroupBonusPlayedOut` 只处理 Bonus
- 存量回填在分表上的批量与幂等(按 `uid` 分片,`createGroupIfAbsent` 幂等)。
- 是否一并采纳 V2「整笔只计 progress_group」成员投注也归 FIFO 组而非自身组)——本方案默认**不改**,保留现有「成员计自身组 + Cash 计 FIFO 组」,单组场景下二者等价。
- 是否同时调整扣款顺序为 V2 的「Cash 最后扣」——**默认不在本次范围**。

View File

@@ -0,0 +1,67 @@
<!-- 4459211d-83e4-4bc9-9a52-2fc0b9f1f467 -->
---
todos:
- id: "arch"
content: "修订第 4.2/8/8.1-8.4/21/22/26/27 章app/tenant 改为 webman 多应用 + 继承 TenantController + 注解挂中间件 + 前端 App-Id 说明"
status: pending
- id: "datascope"
content: "修订第 9.1-9.3 章:删除 TenantDataScopeService改复用 TenantController 的 organization/channels/restrictRequestSourceByChannels新增 channels==['all'] 防御"
status: pending
- id: "orders"
content: "修订第 3/4.3/6/13.1/14.1/14.7/22.4/22.5/26/27 章:订单改直连 s_pay + Logic 层 org+source 过滤与字段白名单,补单表无归档限制"
status: pending
- id: "firstpay"
content: "修订第 10.2 章并波及 10.6/11.3/11.4:去掉今日首充金额,处理 frist_pay_cash_amount 引用"
status: pending
- id: "enum"
content: "修订第 14.4 审核状态补 0/1/2/3新增 slot_all.user 与 gm_stat_summary 字段核对说明"
status: pending
- id: "crossrepo"
content: "修订第 12.10/15/16 章DDL/索引/PHPDoc 标注为跨团队协调项,不在 saas6.x 执行"
status: pending
isProject: false
---
# 商户后台文档修订计划
仅修订 [saas6.x/docs/商户后台.md](saas6.x/docs/商户后台.md),不改任何代码。依据已确认的 5 项决策对齐现网实现。
## 已确认决策(贯穿全文)
- 鉴权与数据权限:复用 Saimulti tenant 体系,控制器继承 `plugin\saimulti\basic\TenantController``plugin/saimulti` 一行不改。
- 应用形态:真正的 webman 多应用目录 `server/app/tenant/`(命名空间 `app\tenant\`),默认路由 `/tenant/*`
- 订单数据:复用现网直连 `s_pay``app\model\RechargeOrderModel` / `WithdrawalOrderModel`,在 `app/tenant` Logic 层强制 `organization + source` 过滤并裁敏感字段;不走 slot-sdk、不改 slot-pay。
- 渠道隔离:数据治理 + 代码防御双管(商户配真实渠道、禁 `['all']`;商户后台遇 `channels==['all']` 拒绝/返回空)。
- 首页去掉"今日首充金额",保留首充人数。
## 具体章节改动
### 1. 架构归属(第 4.2、8、8.1-8.4、21、22、26、27 章)
-`server/app/tenant` 明确为 **webman 多应用**`app\tenant\` 命名空间,自动路由 `/tenant/...`),并说明依据:框架 `App::guessControllerAction()` 支持 `app\<app>\controller\...`
- 新增"基类约定"`app\tenant\controller\MerchantBaseController extends plugin\saimulti\basic\TenantController`,基类用 `#[Middleware(CheckTenantLogin::class, CheckTenantAuth::class, TenantLog::class)]` 挂鉴权(与现网 [FundController](saas6.x/server/app/controller/fund/FundController.php) 同构)。
- 说明前端 `tenant-vue``/tenant/*` 需带 `Authorization` + `App-Id``TenantController::checkSite()` 校验)。
- 权限用方法注解 `#[Permission('名称','slug')]`,由 `CheckTenantAuth` 校验。
### 2. 数据权限:删除 TenantDataScopeService第 9.1-9.3 章)
- 删除自建 `TenantDataScopeService`,改为复用 [TenantController](saas6.x/server/plugin/saimulti/basic/TenantController.php) 已有的 `$this->organization``$this->channels``restrictRequestSourceByChannels()`,渠道展开复用 `SystemOrganizationLogic::resolveEffectiveChannelCodes()`
- 新增 `channels==['all']` 防御要求(商户后台拒绝/返回空)。
### 3. 订单改直连 s_pay第 3、4.3、6、13.1、14.1、14.7、22.4、22.5、26、27 章)
- 把"slot-sdk → slot-pay / 禁直连 s_pay"改为"复用现网 `s_pay` 直连模型Logic 层强制 `organization+source` + 字段白名单"。
- 14.7 敏感字段:由"在 slot-pay 端剔除"改为"在 `app/tenant` Logic 层 `field()` 白名单只选可展示字段"。
- 补充限制说明:现网 `RechargeOrderModel`/`WithdrawalOrderModel``s_pay` 单表、不含按月归档表,跨月历史订单查询范围受限。
### 4. 首页首充金额(第 10.2 章,并波及 10.6 / 11.3 / 11.4
- 删除 10.2 "今日首充金额 SUM(frist_pay_cash_amount)"行,保留首充人数 `first_recharge_num`
- 同步标注:凡引用 `frist_pay_cash_amount`(市场明细 11.3、汇总 11.4 的"首充金额")首期一并去除或标注脏值风险,保留首充人数与首充转化率。
### 5. 枚举与口径修正
- 14.4 审核状态补齐为实表 4 值0 未审核 / 1 通过 / 2 拒绝退钱 / 3 拒绝扣钱(对齐 [WithdrawalOrderModel](saas6.x/server/app/model/WithdrawalOrderModel.php))。
- 新增"字段核对"说明:`slot_all.user` 确含 `organization`/`currency`/`first_pay_date`(建表与 alter 已证实);`gm_stat_summary` 确含 `refund_amount`/`recharge_channel_fee`/`withdrawal_channel_fee`Summary 脚本写入)。
### 6. 跨仓库事项标注(第 12.10、15、16 章)
-`gm_stat_summary``uk_source_date`、改 `create_time`/加 `update_time``slot_all.user``recharge_order/withdrawal_order` 加索引、以及相关 Model PHPDoc 补全,统一标注为 **跨团队协调项**(库属 `s_statistics`/`slot_all`/`s_pay`,由 slot-console/slot-user/slot-pay 执行),按工作区规则不在 saas6.x 仓库改。
## 不做
- 不写任何 PHP/前端代码。
- 不改 `plugin/saimulti/**` 及 slot-* 仓库。
- 不执行任何 DDL。

122
plans/Plan-8f4c095a.plan.md Normal file
View File

@@ -0,0 +1,122 @@
<!-- 8f4c095a-8532-442e-9af9-213dc997b6aa -->
# 新增平台后,游戏列表在哪里?
## 直接回答
**新增一个平台后,这个平台自己的游戏列表在 `g_game_platform_mapping`管理端「映射列表」Tab不在 `g_platform` 或 `g_game` 里。**
刚建完平台时,这个列表是**空的**——`g_platform` 只存平台元数据code / name / config**不会自动带出上游游戏**。需要后续:
1. **手动新增映射**(映射列表 → 新增映射 → 选该平台)
2.**跑平台游戏导入/同步**slot-pwa 有 POP 导入命令saas6.x 尚未接入)
所以不是缺 `g_game_platform` 表,而是:**平台实体与平台游戏池是两张逻辑表,且 UI 没有把两者串起来。**
---
## 三张表各自管什么
```mermaid
flowchart LR
subgraph platformMeta [平台主数据]
g_platform["g_platform\n平台代码/配置"]
end
subgraph platformGames [平台游戏池]
mapping["g_game_platform_mapping\n该平台上的上游游戏\n含 game_id=0 待关联"]
end
subgraph lobbyGames [大厅游戏]
g_game["g_game\n大厅展示游戏\nactive_platform_id"]
end
g_platform -->|"platform_id"| mapping
g_game -->|"game_id 绑定后"| mapping
g_platform -->|"active_platform_id"| g_game
```
| 你想看的 | 存在哪 | 管理端入口 | 新建平台后 |
|---|---|---|---|
| **这个平台有哪些上游游戏**(含待绑定) | `g_game_platform_mapping` where `platform_id = X` | 游戏库 → **映射列表** → 筛「平台」 | **空**,需导入或手建 |
| **大厅里哪些游戏走这个平台** | `g_game` where `active_platform_id = X` | 游戏库 → **游戏列表** → 筛「当前生效平台」 | **空**,需先有大厅游戏并指定平台 |
| **平台本身配置** | `g_platform` | 游戏库 → **平台列表** | 只有这一条平台记录 |
### `game_id = 0` 就是「平台游戏池」
`g_game_platform_mapping``game_id = 0` 的记录,表示**上游已导入、尚未绑定大厅游戏**的条目POP 导入后的默认状态)。
因此 **`g_game_platform_mapping` 同时承担「平台游戏目录 + 绑定关系」**,不需要再建 `g_game_platform`
slot-pwa 导入逻辑见 [`PopGameImportService`](../../ray/slot-pwa/app/service/game/PopGameImportService.php)
> 从 pop_provider.php 导入 POP 游戏至 **g_game_platform_mapping**(不自动新建 g_game
---
## 为什么你会觉得「没地方看」
当前 saas6.x 游戏库是 4 个平级 Tab[`gameLibrary/index.vue`](admin-vue/src/views/admin/gameAccess/gameLibrary/index.vue)
- 游戏列表
- 分类列表
- **映射列表** ← 平台游戏实际在这里
- **平台列表** ← 只有平台配置,无游戏子列表、无跳转
缺口是 **UX / 流程**,不是表结构:
1. 平台 Tab 没有「查看该平台游戏」入口
2. 平台 Tab 没有显示映射数量
3. saas6.x **没有**平台游戏导入/同步能力(老项目靠 slot-pwa 命令写入 mapping
4. 「映射列表」命名不直观,不像「平台游戏池」
---
## 推荐改进(若要做)
### 方案 A最小改动只改 UI不动表
在 [`platform-tab.vue`](admin-vue/src/views/admin/gameAccess/gameLibrary/modules/platform-tab.vue)
- 增加列:`映射总数` / `待关联数` / `已绑定数`(聚合 `g_game_platform_mapping`
- 操作列增加 **「查看游戏」**:切到映射 Tab 并带上 `platform_id` 筛选(可用 `usePageTabs` 跨 Tab 传参或 query
后端:在 [`GPlatformController::index`](server/app/controller/game/GPlatformController.php) 列表响应里批量附加统计字段(一次 `GROUP BY platform_id` 查询,避免 N+1
### 方案 B补导入流程与老项目对齐
- 移植或封装 slot-pwa 的 `PopGameImportService` / `PopGameCatalogSyncService`
- 管理端平台行增加 **「同步上游游戏」** 按钮(或独立命令行入口)
- 同步结果写入 `g_game_platform_mapping``game_id` 先为 0运营再在映射里绑定 `g_game`
### 方案 C不推荐 — 新建 `g_game_platform` 表
会把「平台游戏目录」与「绑定关系」拆成两张表,与 slot 全链路admin / pwa / console不一致`g_game_platform_mapping` 已覆盖该职责。除非有**租户级平台开通实例**等新需求,否则不建议。
---
## 运营侧标准流程(现有模型下)
```mermaid
sequenceDiagram
participant Op as 运营
participant Platform as g_platform
participant Mapping as g_game_platform_mapping
participant Game as g_game
Op->>Platform: 1. 新增平台 code/config
Note over Mapping: 此时为空
Op->>Mapping: 2a. 手动新增映射 或 2b. 跑导入同步
Note over Mapping: game_id=0 待关联
Op->>Game: 3. 新建/选择大厅游戏
Op->>Mapping: 4. 编辑映射绑定 game_id
Op->>Game: 5. 设置 active_platform_id
Note over Game: 大厅可走该平台启动
```
---
## 结论
| 问题 | 答案 |
|---|---|
| 新平台的游戏列表在哪? | **`g_game_platform_mapping`**,管理端 **映射列表** 按平台筛选 |
| 为什么新建后是空的? | 平台创建 ≠ 游戏导入;需映射或同步 |
| 需要 `g_game_platform` 吗? | **不需要**mapping 表就是平台游戏池 |
| 真正缺什么? | **平台页与映射页的串联** + **(可选)上游游戏导入** |

162
plans/Plan-95f2e196.plan.md Normal file
View File

@@ -0,0 +1,162 @@
<!-- 95f2e196-ce33-4253-ace0-0a2ba260a0c2 -->
---
todos:
- id: "finalize-article-schema"
content: "course_article 增加 content_json含 nameList/skipWords正文不再依赖 course_article_sentence"
status: pending
- id: "nce-import-command"
content: "NCE_*.json → course + course_article导入时写入 skipWords/nameList人名/地名免敲)"
status: pending
- id: "article-api"
content: "GET 课文 APIcontent_json 直出 TypeWords Article DTOnameList 供默写/听写跳过)"
status: pending
- id: "article-vocab-api"
content: "课文生词 APIcourse_article_vocab词头+ vocab_sense + course_word_sense与正文分离"
status: pending
- id: "content-patch-admin"
content: "低频勘误PATCH content_json 的 text/translate/skipWords或单课重导"
status: pending
isProject: false
---
# 课文内容存储方案建议(修订 v2
## 结论
- **课文正文**`course_article.content_json`(几乎不改,偶发勘误)
- **课文生词**:关系表(`course_article_vocab` + `course_word_sense`
- **人名/地名免敲**:写在 `content_json`**`skipWords`**API 层映射为 TypeWords 的 `nameList`
---
## 人名 / 地名:练习时不用敲
### 需求
默写或听写课文时,**人名、地名**应预先标注,练习流程 **自动跳过**,不要求学生输入(如 `Silbury``Pinhurst``James``Scott`)。
### TypeWords 已有能力(可直接复用)
[`TypingArticle.vue`](TypeWords/packages/core/src/components/article/TypingArticle.vue) 已支持 `article.nameList`
- 命中 `nameList` 的 token 在 `next()`**自动跳过**
- 内置跳过:`Mr` / `Mrs` / `Ms` / `Dr` / `Miss`
- 多词条目 `"James Scott"` 会拆成 `james``scott` 分别匹配
**不需要**在正文里加 `{Silbury}` 这类 inline 标记;**维护一份免敲词表**即可。
### 在 `content_json` 里怎么存
推荐 **两层字段**(存库清晰 + 对接 TypeWords 零改动):
```json
{
"title": "No wrong numbers",
"text": "Mr.James Scott has a garage in Silbury ...",
"textTranslate": "...",
"audioSrc": "/sound/article/nce2-1/No wrong numbers.mp3",
"lrcPosition": [[15.45, 24.87]],
"skipWords": [
{ "token": "James", "type": "person" },
{ "token": "Scott", "type": "person" },
{ "token": "Silbury", "type": "place" },
{ "token": "Pinhurst", "type": "place" }
]
}
```
| 字段 | 用途 |
|------|------|
| `skipWords[]` | collin **权威存储**`type`: `person` / `place`(可扩展 `org` |
| `nameList` | **API 输出** 时由 `skipWords[].token` 去重生成为 TypeWords 字段(可不落库重复存) |
Lesson5 示例映射:
```
skipWords → nameList: ["James", "Scott", "Silbury", "Pinhurst"]
Mr 已由 TypeWords 内置跳过)
```
### 练习端行为
```mermaid
flowchart LR
API["GET 课文 API"]
Map["skipWords → nameList"]
TW["TypeWords TypingArticle"]
Skip["isNameWord 自动 next"]
API --> Map --> TW --> Skip
```
- **默写 / 听写**:同一套 `nameList` 逻辑
- **UI 可选增强**(后续):按 `skipWords.type` 在课文预览里给人名/地名不同样式(不影响是否跳过)
### 导入时谁维护 skipWords
1. **人工标注**(推荐 MVP导入 NCE 时在源 JSON 或 Excel 补一列「免敲词」
2. **半自动**:导入脚本从 `text` 匹配已知的本课 `course_article_vocab` 之外的专名词表(准确度有限)
3. **勘误**:只改 `content_json.skipWords``content_version + 1`
**不要**单独建 `course_article_skip_token` 表 — 与「几乎不改」的 JSON 方案一致,免敲词随课文打包即可。
---
## 课文正文content_json不变
| 层级 | 存储 |
|------|------|
| 正文 | `course_article.content_json` |
| 元数据 | `course_article` 列:`lesson_no`, `title`, `audio_url`, `status` |
| 生词 | 关系表(一词一行 + 义项) |
| 词典 | `word` / `dict` |
`content_json` **不含** `newWords`**含** `skipWords`(人名/地名)。
完整示例:
```json
{
"title": "A private conversation",
"titleTranslate": "私人谈话",
"text": "...",
"textTranslate": "...",
"audioSrc": "...",
"lrcPosition": [],
"skipWords": [
{ "token": "theatre", "type": "place" }
],
"quote": null,
"question": null
}
```
---
## 与 TypeWords 字段对照
| TypeWords `Article` | collin 来源 |
|---------------------|-------------|
| `title`, `text`, `textTranslate`, `lrcPosition`, `audioSrc` | `content_json` |
| `nameList` | API 由 `skipWords[].token` 生成 |
| `sections` | 前端 `genArticleSectionData(text)` |
| 生词 | 独立 API不进 JSON |
---
## 对迁移 / API 的调整
1. `course_article.content_json` 文档化 **`skipWords`** 结构
2. `ArticleApiService`:读 JSON → 填充 `nameList` → 返回 TypeWords DTO
3. NCE 导入 Command支持从源文件 `nameList` 迁移,或从 sidecar 配置读 `skipWords`
4. 废弃 `course_article_sentence`(正文不再用句子表)
---
## 实施优先级
1. 定稿 `content_json` schema`skipWords`
2. NCE 导入 + 首批课文手工标注免敲词
3. GET 课文 API`skipWords``nameList`
4. 生词 API与正文分离
5. 勘误PATCH `text` / `translate` / `skipWords`

240
plans/Plan-b5e97267.plan.md Normal file
View File

@@ -0,0 +1,240 @@
<!-- b5e97267-b1d5-4ef9-9217-ebd91392307c -->
---
todos:
- id: "ddl-mail-tables"
content: "在 slot-notification 新增 mail_configSMTP 直字段)/ mail_template / mail_send_log 表 DDL"
status: pending
- id: "mail-module"
content: "实现 app/mail 模块Model、Entity、Cache、MailService、SmtpMail SDK"
status: pending
- id: "notification-api"
content: "新增 EmailController + MailConfigAdminController 与 route 注册"
status: pending
- id: "sdk-client"
content: "扩展 slot-lib / slot-sdk 的 MailService 客户端方法"
status: pending
- id: "console-migrate"
content: "slot-console CommonController 改调 notification废弃本地 MailService"
status: pending
- id: "admin-ui"
content: "slot-admin 后端 + slot-admin-vue 邮件配置管理页"
status: pending
- id: "data-migration"
content: "编写 mail_config 从 slot-console 配置组到表的幂等迁移脚本"
status: pending
- id: "verify"
content: "跑 verify-slot-backend.sh验证发信/验码/后台 CRUD 链路"
status: pending
isProject: false
---
# slot-notification 邮件配置表化(对齐短信)
## 现状与结论
当前两套通知渠道配置方式不一致:
| 能力 | 短信(已收口) | SMTP 邮件(未收口) |
|---|---|---|
| 配置存储 | [`slot-notification/app/sms/model/SmsConfigModel.php`](slot-notification/app/sms/model/SmsConfigModel.php) → `sms_config` 表 | [`slot-console/app/service/lib/MailService.php`](slot-console/app/service/lib/MailService.php) → `ConfigService::getGroupData('mail_config')` |
| 发送服务 | [`slot-notification/app/sms/services/SmsService.php`](slot-notification/app/sms/services/SmsService.php) | `slot-console` 本地 `MailService` + PHPMailer |
| 验证码 API | `/innerapi/sms/sendVerifyCode` | `slot-console` `CommonController::sendEmailCode` |
| 后台管理 | slot-admin → slot-lib → notification `/innerapi/admin/*` | slot-admin 通用配置页 `email_config`(另一套) |
**结论:应该建表。** 你已选择「完整对齐短信」,建议把 SMTP 邮件作为 slot-notification 的第二条外发渠道,与短信保持同一套「配置表 + 发送日志 + innerapi + 后台管理 + SDK 客户端」模式。
注意slot-notification 里现有 [`MailController`](slot-notification/app/innerapi/controller/MailController.php) / [`EmailService`](slot-notification/app/message/service/EmailService.php) 实际是**站内消息**兼容别名,不是 SMTP。新 SMTP 能力应使用独立命名空间(建议 `app/mail/*`)和独立路由前缀(建议 `/innerapi/email/*`),避免与 `/innerapi/mail/*`(站内消息)冲突。
```mermaid
flowchart LR
subgraph before [当前]
lobby1[Lobby] --> console1[slot-console CommonController]
console1 --> config1["ConfigService mail_config"]
console1 --> mail1[MailService PHPMailer]
end
subgraph after [目标]
lobby2[Lobby] --> console2[slot-console 薄代理]
console2 --> sdk[slot-lib MailService]
sdk --> notif[slot-notification EmailController]
notif --> table["mail_config 表"]
notif --> smtp[SmtpMail PHPMailer]
notif --> log["mail_send_log 表"]
end
```
---
## 表结构设计(邮件用直字段,短信保留 JSON
`s_message`(与 `sms_config` 同库)新增两张表。
### 为什么邮件不用 JSON、短信用 JSON
| 对比 | 短信 `sms_config.config` | 邮件 `mail_config` |
|---|---|---|
| 字段形态 | 各平台差异大Buka 要 appId/senderIdAnt/Chuanglan 字段不同) | SMTP 标准字段固定,截图与代码一致 |
| 后台表单 | 按 `platform` 动态展示不同表单项 | 固定 6~8 个输入框,与现有配置页一一对应 |
| 查询/校验 | 结构不统一,适合 JSON | 可直接列级校验、索引、迁移映射 |
**结论:邮件配置用直接字段更好;不必为了「和短信表长得像」而强行 JSON。**
### 当前邮件配置项(来自截图,仅 SMTP 连接)
截图中的 6 项即为 `mail_config` 配置组现有字段,与 [`MailService`](slot-console/app/service/lib/MailService.php) 读取项一致。**这就是邮件配置表应存的全部内容**
| 配置项 | 列名建议 | 说明 |
|---|---|---|
| smtp host | `smtp_host` | 如 `email-smtp.us-east-1.amazonaws.com` |
| 用户名 | `username` | SMTP 登录账号 |
| 密码 | `password` | SMTP 密码/应用专用密码 |
| 端口 | `port` | 如 `587` |
| 发件人地址 | `from_address` | 原 `from` |
| 发件人名称 | `from_name` | 如 `TOGOO` |
加密方式(`tls`/`ssl`**不单独落库**:发送时按 `port` 推断即可(`587` → STARTTLS`465` → SSL与现网行为一致。
### 邮件模板(独立概念,不属于 mail_config
验证码邮件的标题/正文来自 `email_template` 配置组(`title``binding_email`),属于**发送模板**,不是 SMTP 连接配置。
发送邮件时需要两张表配合:
```mermaid
flowchart LR
sendMail[MailService 发信] --> mailConfig["mail_config\nSMTP 连接"]
sendMail --> mailTemplate["mail_template\n标题/正文模板"]
```
### `mail_config`(仅 SMTP 连接,直字段)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `platform` | varchar(32) | 渠道标识,如 `AwsSes``Zoho` |
| `status` | tinyint | 1 启用 / 2 禁用 |
| `num` | int | 排序,降序优先 |
| `smtp_host` | varchar(255) | SMTP 主机 |
| `username` | varchar(128) | SMTP 用户名 |
| `password` | varchar(512) | SMTP 密码 |
| `port` | int | SMTP 端口 |
| `from_address` | varchar(255) | 发件人邮箱 |
| `from_name` | varchar(128) | 发件人显示名 |
| `create_time` / `update_time` | datetime | 审计字段 |
### `mail_template`(邮件发送模板,独立表)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `template_code` | varchar(64) | 模板编码,如 `bindingEmail``authEmail` |
| `template_name` | varchar(128) | 模板名称 |
| `title` | varchar(255) | 邮件标题模板 |
| `body` | text | 邮件正文模板,支持 `{code}` 等变量 |
| `status` | tinyint | 1 启用 / 2 禁用 |
| `create_time` / `update_time` | datetime | 审计字段 |
首期迁移:把 `email_template` 配置组中的 `title` + `binding_email` 迁入 `mail_template``template_code=authEmail` 或按业务拆分)。
**注意**`message_template` 是站内消息模板,与 SMTP 外发邮件模板是不同业务,不混用。
### `mail_send_log`(发送日志,放 `s_common`
镜像 [`sms_send_log`](slot-notification/app/sms/model/SmsSendLogModel.php)
- `unique_sn`, `email`, `platform`, `subject`, `content`, `status`, `type`, `retry_num`, `create_time`
---
## 后端实现slot-notification
### 1. 数据层
新建 `app/mail/` 模块,参照 `app/sms/`
- `MailConfigModel` / `MailSendLogModel`
- `MailConfigEntity`(后台 CRUD复用短信 Entity 分页模式)
- 在 [`initDB.php`](slot-notification/app/command/initDB.php) 补充 DDL另提供 `db/mail_config.sql` 便于手工执行
### 2. 发送层
- `composer.json` 增加 `phpmailer/phpmailer`
- `MailService`:从 `mail_config` 取 SMTP 连接,从 `mail_template` 取标题/正文 → PHPMailer 发信 → 写 `mail_send_log`
- `app/mail/services/sdk/BaseMail.php` + `SmtpMail.php`(首期只实现 SMTP结构与 `BaseSms` 一致,便于后续扩展 SendGrid 等)
- `MailCache`:验证码 Redis 缓存(镜像 `SmsCache`key 规则与 slot-console [`EmailCodeLibService`](slot-console/app/service/lib/EmailCodeLibService.php) 保持一致,避免迁移期验证码失效
### 3. API 层
新增路由([`config/route.php`](slot-notification/config/route.php)
- `POST /innerapi/email/send-verify-code`
- `POST /innerapi/email/verify-code`
- `POST /innerapi/admin/mail-config/index|save|update|destroy`
- `POST /innerapi/admin/mail-template/index|save|update|destroy`(模板 CRUD与配置分开
新建 `EmailController`(发送/校验)与 `MailConfigAdminController`(配置 CRUD**不要**复用现有站内消息 `MailController`
### 4. 渠道选择策略
首期与短信一致:按 `status=启用` + `num desc` 取第一条;预留后续 failover注释掉的短信轮询逻辑可一并参考
---
## 跨服务改造
### slot-lib / slot-sdk
在 [`slot-lib/src/services/notification/`](slot-lib/src/services/notification/) 新增 `MailService`(或 `EmailService`
- `sendVerifyCode(email, type)`
- `verifyCode(email, code, type)`
- `getMailConfigPageList` / `saveMailConfig` / `updateMailConfig` / `destroyMailConfig`
同步扩展 [`slot-sdk/src/service/notification/NotificationService.php`](slot-sdk/src/service/notification/NotificationService.php)。
### slot-console调用方改造
[`CommonController::sendEmailCode`](slot-console/app/api/controller/CommonController.php) / `verifyEmailCode`
- 改为调用 slot-lib `MailService`,不再直接依赖本地 `MailService` + `ConfigService`
- 本地 [`MailService`](slot-console/app/service/lib/MailService.php) 标记废弃或删除(确认无其他引用后)
限流、参数校验仍留在 slot-console Controller 层(与现有短信 napi 模式一致)。
### slot-admin + slot-admin-vue
镜像短信后台:
- 后端:[`slot-admin/app/game/controller/SmsController.php`](slot-admin/app/game/controller/SmsController.php) → 新建 `MailController`
- 前端:新建 `mail/config/` 页,仅 6 个 SMTP 字段 + platform/status/num另建 `mail/template/` 页管理标题/正文模板
- 菜单/权限:新增 `mailConfig` 路由与按钮权限
---
## 数据迁移
新增一次性迁移脚本(建议 `slot-notification/app/command/MigrateMailConfig.php`
1.`s_config` 读取 `mail_config` 配置组 → 写入 `mail_config`6 个 SMTP 字段)
2.`s_config` 读取 `email_template` 配置组 → 写入 `mail_template` 表(`title` + `binding_email`
3. 幂等插入(已存在则跳过)
迁移完成后,`slot-console``mail_config` / `email_template` 配置组可保留只读一段时间,确认无回退需求后再清理。
---
## 风险与边界
- **命名冲突**`/innerapi/mail/*` 继续保留给站内消息SMTP 统一走 `/innerapi/email/*`
- **验证码兼容**`MailCache` 的 Redis key 必须与 slot-console 现有 `ShareRedisKeyManagerService::emailCode()` 一致
- **模板范围**:本期只迁移验证码邮件模板(`binding_email`/`title`);充值/提现等 MQ 站内邮件模板(`message_template` 配置组)不在本次范围
- **多 SMTP failover**:表结构支持,首期实现「取第一条启用配置」即可
---
## 验收标准
1. slot-admin 可 CRUD `mail_config`,与短信配置页体验一致
2. Lobby 发/验邮箱验证码链路走 slot-notification功能与迁移前一致
3. 发送成功/失败均写入 `mail_send_log`
4. slot-console 不再读取 `ConfigService::getGroupData('mail_config')`
5. 执行 `verify-slot-backend.sh` 通过

197
plans/Plan-feefd0f2.plan.md Normal file
View File

@@ -0,0 +1,197 @@
<!-- feefd0f2-a6ca-459e-a4b4-e16317795bfd -->
---
todos:
- id: "define-rate-semantics"
content: "确定贡献率字段名、取值范围0-100、默认值 100 与 effective_wager 计算公式,写入 MQ 对接文档"
status: pending
- id: "wallet-v2-consume"
content: "slot_walletUpdateWagerTask 解析 MQ 中 wager_contribution_rate仅 version2 使用 effective_wager"
status: pending
- id: "mq-contract-doc"
content: "输出 slot_pwa 对接说明:在现有 wager_task_update MQ 上增加 wager_contribution_rate 字段"
status: pending
- id: "compat-test"
content: "slot_wallet 单测MQ 无 rate 默认 100、version1 不变、version2 部分贡献"
status: pending
isProject: false
---
# slot_wallet 打码贡献率方案(仅 slot_wallet 改动)
## 范围约束
- **本方案只改 [`slot_wallet`](slot_wallet)**,不改动 slot_pwa / slot_sdk 等其它服务代码。
- 已确认:**仅 version2 打码**应用贡献率version1 保持 1:1。
- **打码 MQ 仍由 slot_pwa 发送**现有架构不变wallet 只改 **消费端**
---
## 现网链路(正确理解)
打码触发方一直是 **slot_pwa**,不是 slot_wallet
```mermaid
sequenceDiagram
participant Pwa as slot_pwa
participant Wallet as slot_wallet
participant TxMQ as transaction_log_MQ
participant TxLog as slot_pwa_TransactionLog
participant WagerMQ as wager_task_update_MQ
participant Consumer as slot_wallet_UpdateWagerTask
Pwa->>Wallet: POST /api/wallet/update type=bet 扣款
Pwa->>TxMQ: 写入用户交易流水
TxMQ->>TxLog: 消费 BET 流水
TxLog->>WagerMQ: 发 MQ uid/fee/currency
WagerMQ->>Consumer: UpdateWagerTask::version2
Note over Consumer: current_wager += min(fee, needed)
```
关键代码(**发送方在 pwa消费方在 wallet**
- 发送:[`slot_pwa/app/process/TransactionLog.php`](slot_pwa/app/process/TransactionLog.php) → `updateWagerTask()`,消息体 `{uid, fee, currency}`
- 消费:[`slot_wallet/app/command/UpdateWagerTask.php`](slot_wallet/app/command/UpdateWagerTask.php) → `version2()`,按下注额 **100%** 计入
- wallet 下注:[`slot_wallet/app/api/logic/WalletLogic.php`](slot_wallet/app/api/logic/WalletLogic.php) 只扣款,**不发** 打码 MQ`//todo 更新打码任务` 未实现)
因此:**贡献率应加在 PWA 发出的 MQ 里**slot_wallet 侧只需让 `UpdateWagerTask` 读懂新字段。
---
## 结论:仅改 slot_wallet 时做什么
| 谁改 | 做什么 |
|---|---|
| **slot_wallet本仓库** | `UpdateWagerTask` 消费 MQ 时读取 `wager_contribution_rate`version2 按 rate 计算有效打码额 |
| **slot_pwa他人开发** | 在**现有** `updateWagerTask()` 发出的 MQ 中增加 `wager_contribution_rate`rate 由 pwa 按游戏/厂商配置在下注流程中确定 |
**不需要** wallet 在下注 API 接参、也 **不需要** wallet 改发 MQ——那会与 pwa 现有职责冲突,且可能造成双计。
---
## slot_wallet 改动(仅此一处核心逻辑)
### 文件
[`slot_wallet/app/command/UpdateWagerTask.php`](slot_wallet/app/command/UpdateWagerTask.php)
### 贡献率语义
| 项 | 约定 |
|---|---|
| MQ 字段名 | `wager_contribution_rate` |
| 含义 | 有效打码占比;**100 = 全额计入**(与现网一致) |
| 缺省 | MQ 无此字段 → **100** |
| 校验 | 消费端 clamp 到 `0~100` |
| 计算公式 | `effective_wager = intdiv(fee * rate, 100)`(厘,向下取整) |
| 作用范围 | 仅 `version2()``version1()` 仍用原始 `fee` |
### 伪逻辑
```php
// deal() 解析 MQ body
$betAmount = (int)($body['fee'] ?? 0);
$rate = max(0, min(100, (int)($body['wager_contribution_rate'] ?? 100)));
// updateWagerTasks()
if (ShareConfigService::isVersion2($uid)) {
$effectiveWager = intdiv($betAmount * $rate, 100);
if ($effectiveWager <= 0) return;
$this->version2($uid, $effectiveWager, $currency);
return;
}
$this->version1($uid, $betAmount, $currency); // version1 忽略 rate
```
### 可选增强(非必须)
-`deal()` / `updateWagerTasks()` 补中文 PHPDoc
- 单测覆盖:无 rate、rate=50、rate=0、version1 不受影响
**不改动**`WalletUpdateRequestDTO``WalletLogic` 下注接口(与打码 MQ 链路无关)。
---
## MQ 对接约定(给 slot_pwa 同事wallet 不写 pwa 代码)
### 现有消息today
```json
{
"uid": 123,
"fee": 1000,
"currency": "INR"
}
```
### 目标消息pwa 在 `TransactionLog::updateWagerTask` 增加字段)
```json
{
"uid": 123,
"fee": 1000,
"currency": "INR",
"wager_contribution_rate": 50
}
```
- `fee`:仍为实际下注扣款额(不变)
- `wager_contribution_rate`pwa 按 game_id / 游戏类型 / 厂商配置赋值;未配置游戏传 `100`
- wallet 先上线 consumer兼容无 ratepwa 再发带 rate 的消息即可灰度
### pwa 侧职责(他人实现)
1. 下注时解析该游戏的贡献率配置
2. 在**原有**打码 MQ 中附带 `wager_contribution_rate`
3. **无需**关闭或迁移 MQ 发送点——仍走 `TransactionLog::updateWagerTask`
---
## 目标架构
```mermaid
sequenceDiagram
participant Pwa as slot_pwa
participant Wallet as slot_wallet
participant WagerMQ as wager_task_update_MQ
participant Consumer as UpdateWagerTask
Pwa->>Wallet: bet 扣款
Pwa->>WagerMQ: uid fee currency wager_contribution_rate
WagerMQ->>Consumer: version2 effective_wager
```
---
## 风险与约束
### 兼容
- pwa 未发 `wager_contribution_rate`wallet consumer 默认 100**与现网完全一致**。
- wallet 可先单独上线,不破坏现网。
### 安全
- rate 由 pwa 写入 MQwallet 消费端做范围 clamp**不在 wallet 内校验游戏配置**(本期 scope 不含配置表)。
### 其它
- `total_bet` 仍按 wallet 扣款 `fee` 统计,与有效打码额分离。
- 无打码回滚逻辑;本需求不新增。
---
## slot_wallet 验收要点
1. MQ `{fee:100, wager_contribution_rate:50}` → version2 用户 `current_wager` +50。
2. MQ 无 rate 字段 → version2 仍 +100fee=100
3. version1 用户:即使 MQ 带 rate仍按 fee 全额计入。
4. `effective_wager=0`rate=0→ 不更新打码。
---
## 不在本方案范围
- slot_pwa 改 MQ 发送由他人完成wallet 只提供对接约定)
- wallet 下注 API 增加 `wager_contribution_rate`(与现链路无关,非必须)
- wallet 改发打码 MQ
- 游戏贡献率配置表 / 后台
- version1 贡献率、打码回滚

106
plans/Plan-ffd1b7b6.plan.md Normal file
View File

@@ -0,0 +1,106 @@
<!-- ffd1b7b6-16e7-4f41-9f8b-a22ab65c6a00 -->
---
todos:
- id: "ddl"
content: "patch_wallet_phase_game_stat.sql + InitDB 分片建表"
status: pending
- id: "model-service"
content: "WalletPhaseGameStatModel + WalletPhaseGameStatServiceinc + 首充标记)"
status: pending
- id: "wire-logic"
content: "WalletLogic bet/win/首充同事务挂载写入"
status: pending
isProject: false
---
# 充值前后游戏统计表(精简版)
## 目标
一张表回答四个数:**充值前下注、充值前派奖、充值后下注、充值后派奖**。RTP 读时算,不存库。钱包怎么通知 activity **后面再说**
---
## 表结构
**表名**`wallet_phase_game_stat`
**分片**:与 `wallet_stat` 一致,`wallet_{db}.wallet_phase_game_stat_{table}`
**主键**`(uid, currency)`
```sql
CREATE TABLE `wallet_phase_game_stat_{t}` (
`uid` BIGINT NOT NULL,
`currency` CHAR(5) NOT NULL DEFAULT 'rp',
`first_recharge_at` DATETIME NULL COMMENT '首充时间NULL=尚未充值',
`pre_bet_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '充值前累计下注,*1000',
`pre_win_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '充值前累计派奖,*1000',
`post_bet_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '充值后累计下注,*1000',
`post_win_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '充值后累计派奖,*1000',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`uid`, `currency`)
) COMMENT='用户充值前后游戏统计';
```
**刻意不做的字段**(需要时再加):
- 笔数 `*_count`
- `post_valid_bet_amount` / FS 分项
- `first_recharge_order_no`
- `last_bet_at` 等时间戳
---
## 阶段判定
**边界 = 是否已首充**,看 `first_recharge_at``RechargeExchangeService::isRecharge(uid)`
- 未首充 → 累加 `pre_*`
- 已首充 → 累加 `post_*`
---
## 写入口径
与 [`WalletLogic`](slot-wallet/app/api/logic/WalletLogic.php) 现有路径对齐,**仅在 biz_id 幂等通过、新写入流水时**同事务累加:
| 动作 | 未首充 | 已首充 |
|------|--------|--------|
| 试玩下注 `executeTrialBet` | `pre_bet += fee` | — |
| 试玩派奖 `executeTrialWin` | `pre_win += fee` | — |
| 真金下注 `BetService` / FS代付 | — | `post_bet += fee` |
| 真金派奖 `WinService``is_end=1` | — | `post_win += fee` |
| 首充 commit | 写 `first_recharge_at` | — |
中间派奖(`is_end=0`)不写。
---
## RTP
读时计算,不入库:
```
pre_rtp = pre_win_amount / pre_bet_amount (bet=0 → null)
post_rtp = post_win_amount / post_bet_amount
```
---
## 代码落点
| 文件 | 内容 |
|------|------|
| `slot-wallet/db/patch_wallet_phase_game_stat.sql` | DDL |
| `slot-wallet/app/model/multi/WalletPhaseGameStatModel.php` | 分片 + `inc` |
| `slot-wallet/app/service/wallet/WalletPhaseGameStatService.php` | 判定阶段、累加、首充标记 |
| `WalletLogic::bet()` / `win()` / 首充 | 同事务调用 |
**本轮不改**`TrialValidBetNotifyService`、activity、MQ。
---
## 验收
1. 首充前只涨 `pre_*`,首充后只涨 `post_*`
2. 幂等重入不重计
3. 四个金额 + 两个 RTP 可查询

138
plans/RTP-ffd1b7b6.plan.md Normal file
View File

@@ -0,0 +1,138 @@
<!-- ffd1b7b6-16e7-4f41-9f8b-a22ab65c6a00 -->
---
todos:
- id: "ddl"
content: "新增 slot-pwa/db/game_user_recharge_rtp_stat.sql用户 DDL 原样)"
status: pending
- id: "service-model"
content: "GameUserRechargeRtpStatModel + GameUserRechargeRtpStatService + UserRechargeStatusService"
status: pending
- id: "wire-wallet-service"
content: "WalletService bet/win 成功后挂载累计与 RTP 重算"
status: pending
- id: "redis-key"
content: "RedisKeyManagerService 补充 recharge:exchange key"
status: pending
isProject: false
---
# game_user_recharge_rtp_statslot-pwa
## 变更范围
- **表**:按你提供的 DDL不做额外字段
- **服务**[`slot-pwa`](slot-pwa) 统一处理(游戏回调 → 调 wallet 的入口)
- **不做**wallet 侧改表、移除 `TrialValidBetNotifyService`、activity 同步(后续再定)
---
## 表结构(原样采用)
库:`s_common`(与 [`game_launch_session`](slot-pwa/db/game_launch_session.sql) 同库)
```sql
CREATE TABLE `game_user_recharge_rtp_stat` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`uid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户ID',
`before_recharge_bet_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '充值前累计下注金额金额扩大1000倍',
`before_recharge_payout_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '充值前累计派奖金额金额扩大1000倍',
`before_recharge_rtp_rate` DECIMAL(10, 6) NOT NULL DEFAULT 0.000000 COMMENT '充值前RTPpayout / bet',
`after_recharge_bet_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '充值后累计下注金额金额扩大1000倍',
`after_recharge_payout_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '充值后累计派奖金额金额扩大1000倍',
`after_recharge_rtp_rate` DECIMAL(10, 6) NOT NULL DEFAULT 0.000000 COMMENT '充值后RTPpayout / bet',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_uid` (`uid`),
KEY `idx_before_rtp_bet` (`before_recharge_rtp_rate`, `before_recharge_bet_amount`),
KEY `idx_after_rtp_bet` (`after_recharge_rtp_rate`, `after_recharge_bet_amount`),
KEY `idx_before_bet` (`before_recharge_bet_amount`),
KEY `idx_after_bet` (`after_recharge_bet_amount`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='用户充值前后下注派奖RTP累计统计表';
```
DDL 文件:[`slot-pwa/db/game_user_recharge_rtp_stat.sql`](slot-pwa/db/game_user_recharge_rtp_stat.sql)
---
## 为什么在 slot-pwa
所有厂商下注/派奖最终都走 [`WalletService::bet()`](slot-pwa/app/service/WalletService.php) / `win()`pop / quick / jsgame / jdb / self / gasea / bbgt 等),与现有 [`UserProfitService`](slot-pwa/app/service/user/UserProfitService.php) 挂载点一致,**一处写入、全覆盖**。
```mermaid
flowchart LR
Provider[厂商回调] --> PwaCash[pop/quick/... CashLogic]
PwaCash --> WalletSvc[WalletService bet/win]
WalletSvc --> WalletApi[slot-wallet API]
WalletSvc --> RtpStat[GameUserRechargeRtpStatService]
RtpStat --> Db[(game_user_recharge_rtp_stat)]
```
---
## 阶段判定(充值前 / 充值后)
读 wallet 同款 Redis 充值标记([`RechargeExchangeService::isRecharge`](slot-wallet/app/service/wallet/RechargeExchangeService.php)
- Key`recharge:exchange:{uid % 10}`field = `uid``recharge > 0`**充值后**
- 否则 → **充值前**
在 slot-pwa 新增薄封装 `UserRechargeStatusService`(读 default Rediskey 规则与 wallet 一致),**不新增表字段**。
---
## 写入口径
挂载点:[`WalletService::bet()`](slot-pwa/app/service/WalletService.php) / `win()`,在 **`updateWallet()` 成功返回非 null 之后** 写统计(比现有 `UserProfitService` 更早调用更安全,避免 wallet 失败仍累加)。
| 动作 | 条件 | 更新字段 |
|------|------|----------|
| bet 成功 | 未充值 | `before_recharge_bet_amount += fee` |
| bet 成功 | 已充值 | `after_recharge_bet_amount += fee` |
| win 成功 | `is_end === 1` 且未充值 | `before_recharge_payout_amount += fee` |
| win 成功 | `is_end === 1` 且已充值 | `after_recharge_payout_amount += fee` |
**派奖仅计 `is_end=1`**:与 wallet 实际入账一致(中间派奖 `is_end=0` 只写 Redis pending不落库
**RTP 重算**(同事务内 SQL 更新):
```
before_recharge_rtp_rate = before_bet > 0
? ROUND(before_payout / before_bet, 6) : 0
after_recharge_rtp_rate = after_bet > 0
? ROUND(after_payout / after_bet, 6) : 0
```
**幂等**:依赖 slot-pwa 已有 `provider_tx` 幂等(同一 biz 不会重复调 wallet不在本表再存 biz_id。
**fee=0**跳过累加zero win 等场景)。
---
## 代码落点
| 文件 | 职责 |
|------|------|
| `slot-pwa/db/game_user_recharge_rtp_stat.sql` | 建表 |
| `slot-pwa/app/model/game/GameUserRechargeRtpStatModel.php` | ThinkORM Model + 完整 PHPDoc |
| `slot-pwa/app/service/game/GameUserRechargeRtpStatService.php` | `accumulateBet` / `accumulatePayout`、RTP 重算、upsert by uid |
| `slot-pwa/app/service/user/UserRechargeStatusService.php` | 读 Redis 判断是否已充值 |
| `slot-pwa/app/service/WalletService.php` | bet/win 成功后调用 Service |
| `slot-pwa/app/service/RedisKeyManagerService.php` | 补充 `getRechargeExchangeKey`(与 wallet 一致) |
---
## 与 wallet / activity 的关系(后续)
- 本表成为 **充值前后 bet/payout/RTP 权威数据源**
- activity 的 `valid_bet_amount` / 4→5 解锁、移除 `trial_valid_bet_accumulate` MQ → **下一阶段**,本计划不改动
---
## 验收
1. 未充值用户游戏:只涨 `before_*`RTP 随 bet/win 更新
2. 首充后:只涨 `after_*`
3. `is_end=0` 的中间 win 不计入 payout
4. wallet 调用失败时不写表
5. 风控可按 `idx_before_rtp_bet` / `idx_after_rtp_bet` 扫异常 RTP

View File

@@ -0,0 +1,115 @@
<!-- 2a311123-9045-4246-b265-7e37ecfbf924 -->
---
todos:
- id: "ddl"
content: "编写 reward_pool / reward_pool_item 建表 DDLAUTO_INCREMENT 主键、status 1/2、可选审计字段"
status: pending
- id: "constants"
content: "新建 RewardPoolConstants / RewardPoolRewardType钱包类复用 foundation 数值,新增 COUPON=3"
status: pending
- id: "models"
content: "新建 RewardPoolModel、RewardPoolItemModel完整类 PHPDoc + @property + 查询方法)"
status: pending
- id: "draw-shared"
content: "下沉加权随机算法为可复用方法,供奖池与 TrialPrizeDrawService 共用"
status: pending
- id: "entity"
content: "新建 RewardPoolDrawResultEntity 及列表/汇总 Entity禁止 Logic 返回 array"
status: pending
- id: "draw-logic"
content: "实现 app/innerapi/logic/RewardPoolDrawLogic::drawByPoolCode只读、无副作用"
status: pending
- id: "admin-crud"
content: "slot-activity app/adminRewardPool(Item)Controller/Logic/Validatesaithink 范式)+ admin 路由"
status: pending
- id: "validate"
content: "奖池/奖项校验reward_type 约束 + 优惠券模板存在性 + 启用校验 + pool_code 不可改"
status: pending
- id: "sdk"
content: "ActivityService 增 admin/reward-pool(-item)/* SDK 方法adminXxx 同套路)"
status: pending
- id: "admin"
content: "saas6.x/server operation RewardPoolController + RewardPoolGatewayService 代理(#[Permission]"
status: pending
- id: "admin-vue"
content: "admin-vue 增奖池管理页面与菜单(奖池列表/编辑/启停 + 奖项配置页)"
status: pending
- id: "verify"
content: "运行 slot-backend-completion-report 门禁并粘贴检测结果"
status: pending
isProject: false
---
# 通用奖池 V1 实施方案
落点服务:`slot-activity`(核心 + 数据)、`slot-sdk`(跨服务客户端)、`saas6.x/server`(运营后台代理)+ `saas6.x/admin-vue`(后台前端)。沿用现有「活动管理」后台范式(`operation` 控制器 + `*GatewayService` 封装 slotsdk
> 注意:后台代码不在 `slot-admin`,而在独立仓库 `~/Documents/project/www/tenant/saas6.x`(用户明确指定)。
> 子需求文档SSOT 拆分):[docs/requirements/generic_reward_pool/README.md](docs/requirements/generic_reward_pool/README.md)01 表结构 / 02 抽奖 / 03 后台)。
## 关键决策(已确认 + 需注意的派生项)
- 奖励类型按《奖池.md V1》原口径`reward_type` = `1 可提现释放 / 2 Bonus / 3 充值优惠券`,券用 `coupon_template_id`(不对齐 foundation 数值、不引入 FS
- 新建 `app/constants/RewardPoolRewardType``WITHDRAWABLE_RELEASE=1` / `BONUS=2` / `COUPON=3`)。
- 业务方按抽奖结果发奖时,由**业务侧**显式把 `reward_type` 映射到钱包/券接口(如 `slot-foundation\RewardGrantRewardType` 或 user_coupon奖池本身不做发放。
- 表名冲突风险:现有 `docs/requirements/reward_pool/`(大转盘超集设计)也用 `reward_pool` / `reward_pool_item`。实施前需决策本 V1 是否改用独立表名或与之合并(见子需求 README「重要前提」
- 抽奖算法复用:把 [`TrialPrizeDrawService::pickWeightedRandom()`](slot-activity/app/service/trial/TrialPrizeDrawService.php) 的加权累加逻辑下沉为可复用方法(`random_int(1,total)` + `sort ASC,id ASC` 累加),奖池抽奖与转盘共用,不再写第二份。
- 运营后台(`saas6.x/server`)经 `slot_sdk` 调 slot-activity `innerapi`/`admin`,参照 `saas6.x/server/app/controller/operation/ActivityController.php` + `app/service/activity/ActivityGatewayService.php``proxyActivity()` + `*GatewayService` 范式,不直连 activity 库。
## 一、数据库reward_pool / reward_pool_item
按文档 §6 建表,修订:
- 主键改 `BIGINT UNSIGNED NOT NULL AUTO_INCREMENT`(与现有 Model "自增主键" 一致)。
- `status` 统一为 `1启用 2停用`(文档已是;与 prize_pool 的 enabled 1/0 不强行统一,但 Model 注释写清)。
- `reward_pool_item.pool_code` 为快照;约定 `pool_code` 创建后不可改(编辑接口禁止改 code保证 `(pool_id,item_code)` 快照一致。
- 可选增 `created_by`/`updated_by`(后台操作审计)。
## 二、slot-activity 核心(数据 + 抽奖)
- Model`app/model/RewardPoolModel.php``app/model/RewardPoolItemModel.php`
- 完整类 PHPDoc + `@property`(对齐 DDL COMMENT状态常量带中文注释。
- 查询方法:`findEnabledByPoolCode()``listEnabledItemsByPoolId()``sumEnabledWeight()``existsItemCode()` 等(命名表达意图,禁止 getData/getList
- 常量:`app/constants/RewardPoolConstants.php`status、reward_type 复用 foundation + COUPON
- 抽奖业务逻辑(供本服务/业务内部调用,第一期不强制开 HTTP`app/innerapi/logic/RewardPoolDrawLogic.php::drawByPoolCode(string $poolCode): RewardPoolDrawResultEntity`,编排文档 §8.1 十步:查池→校验启用→取启用且 `weight>0` 奖项→`total_weight`→加权随机→返回快照。只读、无副作用、无幂等幂等由业务方负责PHPDoc 写明)。
- Entity`app/entity/rewardpool/RewardPoolDrawResultEntity.php`抽中快照extends `BaseEntity`),列表用 `RewardPoolListEntity` / `RewardPoolItemListEntity` 包装(禁止 Logic 返回 array / `Xxx[]`)。
## 三、slot-activity 后台app/admin非 innerapi
沿用 `app/admin` saithink 范式(参照 `app/admin/controller/ActivityController.php` + `app/admin/logic/ActivityLogic.php` + `app/admin/validate/ActivityValidate.php`,基类 `app/admin/base/AdminController``$this->success()` 返回):
- Controller`app/admin/controller/RewardPoolController.php`(奖池 index/read/save/changeStatus`app/admin/controller/RewardPoolItemController.php`(奖项 index/save/changeStatus/destroy/sort
- Logic`app/admin/logic/RewardPoolLogic.php``app/admin/logic/RewardPoolItemLogic.php`search/getList + 保存/启停/排序;多表写入走 activity 库事务)。
- Validate`app/admin/validate/RewardPoolValidate.php``RewardPoolItemValidate.php`,覆盖文档 §11.1~§11.4reward_type∈{1,2,3};非 COUPON 时 `reward_amount>0 && coupon_template_id=0`COUPON 时 `reward_amount=0 && coupon_template_id>0` 且校验模板存在;启用奖池需至少 1 个启用奖项且 `weight` 合计 > 0`pool_code` 创建后不可改)。
- 路由:按现有 admin 路由约定接入 `admin/reward-pool/*``admin/reward-pool-item/*`(与 `admin/activity/*` 同套路)。
## 四、slot-sdk
- [slot-sdk/src/service/activity/ActivityService.php](slot-sdk/src/service/activity/ActivityService.php) 增方法POST/GET 到 `admin/reward-pool/*``admin/reward-pool-item/*`(与现有 `adminActivityIndex/adminSave/adminUpdate/adminChangeStatus/adminDestroy` 命名同套路):`rewardPoolIndex/Read/Save/ChangeStatus``rewardPoolItemIndex/Save/ChangeStatus/Destroy/Sort`
## 五、saas6.x 运营后台
后端 `saas6.x/server`
- `app/controller/operation/RewardPoolController.php`extends `OperationController`,方法加 `#[Permission('奖池...','saimulti:operation:rewardPool:xxx')]``proxyActivity()` 统一封装)。
- `app/service/activity/RewardPoolGatewayService.php`(封装 slotsdk `ActivityClient`host 取 `ShareConfigService::get('activityApiHost')`,与 `ActivityGatewayService` 同构)。
- 入参轻校验可放控制器或 `app/validate/`,复杂判断仍在 slot-activity 侧。
前端 `saas6.x/admin-vue`
- 运营管理 → 奖池管理页(列表/新增/编辑/启停/进入奖项配置)+ 奖项配置页(新增/编辑/启停/排序)。
- 新增对应 `api/` 请求模块与菜单/权限项(与活动页同套路)。
## 六、收尾自检(强制)
改完 `app/**/*.php` 后按 `agent-completion-gate` 执行 `~/.cursor/skills/slot-backend-completion-report/scripts/report.sh`(含 verify 门禁 + docker `php -l`),最终回复粘贴"检测结果"章节。
## 调用关系
```mermaid
flowchart LR
Vue[saas6.x admin-vue] --> Server[saas6.x server operation]
Server -->|slot_sdk ActivityClient| AdminApi[slot-activity app/admin reward-pool]
AdminApi --> AdminLogic[admin RewardPoolLogic / ItemLogic]
AdminLogic --> Model[(reward_pool / reward_pool_item)]
Biz[业务活动 Logic] -->|drawByPoolCode| Draw[RewardPoolDrawLogic]
Draw --> Model
Biz -->|按 reward_type 发放| Wallet[slot-wallet / user_coupon]
```

View File

@@ -0,0 +1,121 @@
<!-- 23416728-def1-424f-ba2f-16a110d1f084 -->
---
todos:
- id: "internal-layer"
content: "新建 internal DeviceController + 迁移 logic/dto/validateDTO 含 user_id"
status: pending
- id: "cleanup-api"
content: "从 api DeviceController 移除绑定三接口并删除 api 侧绑定文件"
status: pending
- id: "remove-user-auth"
content: "删除 UserAuth、UserRequestContext 及无引用错误码"
status: pending
- id: "docs-update"
content: "更新 device.md §10、03-user-binding.md、06-internal-api.md 路径与调用方说明"
status: pending
- id: "verify"
content: "docker php -l + slot-backend-completion-report"
status: pending
isProject: false
---
# SR-3 设备绑定迁入 internal 应用
## 架构判断(同意你的方向)
与 [device.md](device/docs/requirements/device.md) 中已有分层一致:
| 应用 | 调用方 | 鉴权 | 职责 |
|------|--------|------|------|
| **api** | 物理设备 | `device_sn` + 签名 | 激活、心跳、OTA 入口 |
| **admin** | 后台 BFF / admin-service | `Authorization`(骨架) | 产品/型号/设备档案管理 |
| **internal** | user-service、message-service、ota-service 等 | `X-Internal-Token` | 跨服务读写设备主数据 |
绑定关系是 **device-service 的领域数据**,但 **用户登录态不应在本服务校验**——应由 user-service或 App BFF完成 session/JWT 校验后,再调用 device-service 的 internal 接口写入绑定。这与 §4.3「后台 → BFF → device-service」模式同构。
```mermaid
sequenceDiagram
participant User as 用户App
participant UserSvc as user_service_BFF
participant Device as device_service_internal
User->>UserSvc: 扫码绑定 device_sn
Note over UserSvc: 校验登录态,解析 user_id
UserSvc->>Device: POST /internal/device/bind
Note over Device: X-Internal-Token + user_id + device_sn
Device-->>UserSvc: code=0
UserSvc-->>User: 绑定成功
```
**结论**SR-3 应落在 **internal**,而不是 api当前 [DeviceController.php](device/app/api/controller/DeviceController.php) 上的 `#[Middleware(UserAuth)]` + `UserRequestContext` 应移除。
---
## 目标接口默认路由id 走 body/query
| 方法 | 路径 | 入参 |
|------|------|------|
| POST | `/internal/device/bind` | `user_id`, `device_sn` |
| POST | `/internal/device/unbind` | `user_id`, `device_id` |
| GET | `/internal/device/list` | `user_id`query |
鉴权:应用级 [InternalTokenAuth](device/app/middleware/InternalTokenAuth.php)(已挂在 `internal`**不再**使用 [UserAuth](device/app/middleware/UserAuth.php)。
---
## 代码迁移步骤
### 1. 新建 internal 分层(从 api 平移)
- 新增 [app/internal/controller/DeviceController.php](device/app/internal/controller/DeviceController.php)`extends InternalController`
- `bind` / `unbind` / `list` 三个 action
- 将以下文件 **改 namespace 为 `app\internal\*`**(逻辑基本不变):
- [app/api/logic/DeviceBindLogic.php](device/app/api/logic/DeviceBindLogic.php) → `app/internal/logic/DeviceBindLogic.php`
- [app/api/dto/DeviceBindDTO.php](device/app/api/dto/DeviceBindDTO.php) → 增加 `userId` 字段
- [app/api/dto/DeviceUnbindDTO.php](device/app/api/dto/DeviceUnbindDTO.php) → 增加 `userId` 字段
- [app/api/validate/DeviceBindValidate.php](device/app/api/validate/DeviceBindValidate.php) → 增加 `user_id` 规则(`require|integer|gt:0`)及 validation 文案
Controller 从 DTO 读取 `userId`**不再**调用 `UserRequestContext::getUserId()`
### 2. 清理 api 应用
- 从 [app/api/controller/DeviceController.php](device/app/api/controller/DeviceController.php) 删除 `bind` / `unbind` / `list``DeviceBindLogic``DeviceBindValidate``UserAuth` 相关 use/构造注入
- 删除 api 侧已迁移的 logic/dto/validate 文件
### 3. 删除用户鉴权骨架(若无其它引用)
- 删除 [app/middleware/UserAuth.php](device/app/middleware/UserAuth.php)
- 删除 [app/support/UserRequestContext.php](device/app/support/UserRequestContext.php)
- `OtaErrorCode::USER_AUTH_FAILED (1004)` 可保留翻译条目(无害),或一并移除——以「无引用」为准
### 4. Logic 行为
[DeviceBindLogic](device/app/api/logic/DeviceBindLogic.php) 业务规则 **不变**(幂等、唯一约束、状态校验);仅入参来源从「上下文 user_id」改为「请求体/Query 显式 `user_id`」。
**安全说明**(写入 SR-3/SR-6 文档internal 信任「调用方已在网关/BFF 完成用户鉴权」;`user_id` 由调用方传入device-service 只负责绑定域规则。与 SR-6 §5「服务间 token」一致。
### 5. 需求文档同步
- [03-user-binding.md](device/docs/requirements/device/03-user-binding.md):路径改为 `/internal/device/*`,注明调用方为 user-service/BFF
- [device.md](device/docs/requirements/device.md) §10同上§10 标题可改为「用户绑定(内部接口,由 user-service 暴露给 App
- [06-internal-api.md](device/docs/requirements/device/06-internal-api.md):补充 bind/unbind/list 三条(扩展 SR-6 范围或交叉引用 SR-3
### 6. 调用方 SDK可选本仓库外
按 [cross-service-sdk](.cursor/rules/cross-service-sdk.mdc),若 user-service 在 monorepo 内,后续在 `slot_sdk` 增加 `DeviceClient`(或 `DeviceBindService`)方法指向上述三条 internal 路径;**本次 device 仓库内可不实现**,仅在计划中标注为调用方跟进项。
---
## 不涉及
- `DeviceBindModel`、错误码 `4208`/`4209`、翻译文案:已存在,无需改业务码
- SR-6 只读查询(单设备档案/批量/在线状态):可另 PR 实现,与本次迁移独立
- 「查询设备绑定用户」admin 详情已只读;若 internal 需要可加 `GET /internal/device/bind-owner?device_id=`**非本次必须**,除非你要求一并做
---
## 验收
1. `api` 仅保留 `activate``heartbeat`(及后续 OTA
2. `internal` 三条绑定接口带 `X-Internal-Token` 可通,缺 token 返回 `1001` + HTTP 403
3. 绑定/解绑/列表业务行为与现 SR-3 验收要点一致
4. `docker php -l` + completion-report 通过

View File

@@ -0,0 +1,136 @@
<!-- b36a4d72-3fa9-424d-8f16-d5d3a861c06d -->
---
todos:
- id: "wallet-strip-mq"
content: "wallet bet/win/FreeSpinBetService 移除 trial MQ 发布bet 响应增加 is_fs_paid_bet"
status: pending
- id: "pwa-mq-publisher"
content: "PWA 新增 TrialRewardMqPublisher + MQKeyManager 常量(对齐 TrialRewardMQ"
status: pending
- id: "pwa-notify-service"
content: "PWA 新增 TrialRewardNotifyServicevalid_bet / fs_consume / task_completed + 共享配置与 Redis 去重)"
status: pending
- id: "pwa-hook-wallet"
content: "WalletService::bet/win 成功后调用 NotifyServiceWalletEntity 补充 is_fs_paid_bet / wallet_created_at"
status: pending
- id: "verify-flow"
content: "docker 内验证四条验收场景;跑 slot-backend 门禁"
status: pending
isProject: false
---
# Trial MQ 从 wallet 迁到 PWA
## 你的三点反馈(代码核对结论)
### 1. 下注金额 PWA 知道,代付留 wallet —— 同意
- PWA 所有厂商下注均经 [`WalletService::bet`](slot-pwa/app/service/WalletService.php),请求里已有 `fee``biz_id``round_id``game_category``vendor_game_id`
- FS 代付判定([`TrialFreeSpinRedisService`](slot-wallet/app/service/trial/TrialFreeSpinRedisService.php))和 `executeFreeSpinPaidBet` **继续留 wallet**
- PWA 只需 wallet 在 bet 响应里多回一个**是否 FS 代付**标记(如 `is_fs_paid_bet: true`),即可决定:
- FS 代付 → `valid_bet_amount = fee` 发 MQ
- 常规下注且非试玩期 → `valid_bet_amount = fee` 发 MQPWA 侧用 fee不再依赖 wallet 内部 withdraw/deposit 拆分)
- 试玩期纯体验金下注 → 不发 MQ
> 说明wallet 内部 `valid_bet` 原口径是 `withdraw + deposit`(不含 bonus 部分)。若业务接受「充值后用 fee 计入解锁流水」PWA 可直接用 fee**无需** wallet 扩展拆分字段。若以后要精确排除 bonus 扣款,再让 wallet 回 `valid_bet_amount` 即可。
### 2. grantTrial 的 task_completed 留 wallet —— 可接受,但当前无调用
全仓库 **没有任何服务**`type=trial_grant` / `grantTrial()`
`TrialTaskCompletedMqService` 在 wallet 里实际只有 **一条活路径**
- [`WalletLogic::win()`](slot-wallet/app/api/logic/WalletLogic.php) 试玩期派奖成功后 → `notifyIfBalanceReached`
`grantTrial()` 里的 MQ 是**预留死代码**(需求文档里的绑手机/绑邮箱补发体验金,尚未接入)。
### 3. 注册流程不走 grantTrial —— 你说得对
注册实际路径:
```mermaid
sequenceDiagram
participant Console as slot_console
participant Wallet as slot_wallet
Console->>Wallet: POST /api/wallet/update type=register
Wallet->>Wallet: WalletLogic.register + RegisterService
Note over Wallet: 写 frozen_bonus不发 trial MQ
```
[`UserRegisterEventService::reward()`](slot-console/app/service/user/UserRegisterEventService.php) 只发 `type=register`**不**调 `grantTrial`
`register()` 也**不**发 `task_completed` MQ。
因此:**充值前 $50 门槛任务**,目前是靠 **试玩期 win 派奖** 后 wallet 发 MQ 触发的,不是注册时触发。
---
## 目标架构
```mermaid
flowchart LR
Provider --> PWA
PWA -->|"HTTP bet/win fee+game_context"| Wallet
Wallet -->|"余额 + is_fs_paid_bet"| PWA
PWA -->|"trial_valid_bet / fs_consume / task_completed"| ActivityMQ
ActivityMQ --> Activity
```
**wallet 移除(游戏路径)**
- [`TrialValidBetNotifyService`](slot-wallet/app/service/trial/TrialValidBetNotifyService.php) 在 `bet()` 的调用
- [`TrialFreeSpinBetService`](slot-wallet/app/service/trial/TrialFreeSpinBetService.php) 内的 `TYPE_FS_CONSUME` MQ 发布
- [`TrialTaskCompletedMqService`](slot-wallet/app/service/trial/TrialTaskCompletedMqService.php) 在 `win()` 试玩期的调用
**wallet 保留**
- FS Redis 代付判定与 `executeFreeSpinPaidBet`
- `grantTrial()` 内 MQ死代码待绑手机/绑邮箱接入时生效)
**PWA 新增**
- `TrialRewardMqPublisher`exchange/queue 对齐 [`slot\foundation\Constants\MQ\TrialRewardMQ`](slot-foundation/src/Constants/MQ/TrialRewardMQ.php)
- `TrialRewardNotifyService`:在 [`WalletService::bet/win`](slot-pwa/app/service/WalletService.php) 钱包调用成功后投递 MQ
- bet + `!isTrialPhase`PWA 用 [`UserRechargeStatusService`](slot-pwa/app/service/user/UserRechargeStatusService.php) 判断)→ `trial_valid_bet_accumulate`
- bet + `is_fs_paid_bet`wallet 响应)→ `trial_valid_bet_accumulate`(全额 fee+ `trial_fs_consume`
- win + 试玩期 + 余额达标 → `trial_task_completed`Redis 48h 去重逻辑从 wallet 迁过来,读共享 Redis 配置)
**activity 不变**
- [`TrialRewardEventRouter`](slot-activity/app/service/mq/TrialRewardEventRouter.php) 消费格式不变
---
## 关键改动文件
| 仓库 | 文件 | 动作 |
|------|------|------|
| slot-wallet | `app/api/logic/WalletLogic.php` | bet/win 删 MQbet 响应加 `is_fs_paid_bet` |
| slot-wallet | `app/service/trial/TrialFreeSpinBetService.php` | 删 fs_consume MQ保留 Redis consume |
| slot-wallet | `app/entity/WalletEntity.php` | 可选加 `is_fs_paid_bet` 字段 |
| slot-pwa | `app/service/MQKeyManagerService.php` | 加 `EXCHANGE_TRIAL_REWARD` / `QUEUE_TRIAL_REWARD` |
| slot-pwa | `app/service/mq/TrialRewardMqPublisher.php` | 新建 |
| slot-pwa | `app/service/trial/TrialRewardNotifyService.php` | 新建valid_bet / fs_consume / task_completed |
| slot-pwa | `app/service/trial/TrialRewardShareConfigService.php` | 从 wallet 迁共享 Redis 配置读取 |
| slot-pwa | `app/service/WalletService.php` | bet/win 成功后调 NotifyService |
---
## 试玩期判定PWA 侧)
与 wallet 对齐,不依赖 `isTrialPhase` HTTP 字段:
- `!UserRechargeStatusService::hasRecharged(uid)`
- 且 wallet 返回的 `wallet_created_at``validity_hours` 内(需 PWA `WalletEntity` 增加该字段或 bet/win 时顺带读 wallet 接口)
---
## 本阶段明确不做
- 不改 `grantTrial` / 绑手机绑邮箱(无调用方)
- 不改 `slot-pay``first_deposit` / `deposit_accumulate` MQ
- 不迁 FS Redis 逻辑到 PWA
- 不在 wallet 侧继续发游戏相关 trial MQ
---
## 验收
1. 试玩期下注wallet 扣 frozen_bonus**无** trial MQ
2. 充值后常规下注PWA 发 `trial_valid_bet_accumulate`activity `valid_bet_amount` 累加
3. FS 代付下注wallet 不扣余额PWA 发 `fs_consume` + `valid_bet_accumulate`
4. 试玩期 win 余额达 $50PWA 发 `trial_task_completed`48h 去重)
5. 注册 `type=register`:行为不变,仍无 trial MQ

View File

@@ -0,0 +1,175 @@
<!-- d451269e-1f41-4d6f-ad1f-e7ac88a4925f -->
---
todos:
- id: "constants"
content: "TrialRewardConstantsACTIVITY_CODE + ext 键;移除 center CONFIG_KEY_*"
status: pending
- id: "model"
content: "ActivityModel::findActiveByCode(activity_code, source, now)"
status: pending
- id: "resolver"
content: "新增 TrialRewardActivityResolver从 activity.ext 解析 threshold/cap"
status: pending
- id: "logic"
content: "TrialFirstDepositLogic / queryRecord 改用 resolver去掉 ShareConfig trialReward"
status: pending
- id: "center-cleanup"
content: "删除 slot-center share_config activity.trialReward 块"
status: pending
- id: "seed-sql"
content: "新增 activity 表 patchtrial_reward + ext JSON"
status: pending
- id: "test-e2e"
content: "补 resolver 单测 + 联调验证(不依赖 center 业务配置)"
status: pending
isProject: false
---
# 试玩活动配置center 迁出,按 activity_code 解析
## 结论(对你问题的回应)
**我同意你的方向**,且比当前联调实现更贴合项目既有模式:
- **center 不应承载** `deposit_threshold``trial_withdrawal_cap``activity_id` 等业务参数;它只适合 `walletApiHost` 等基础设施。
- **活动服决定业务规则**:阈值、上限、开关、时间窗应来自 [`activity`](slot-activity/app/model/ActivityModel.php) 表(运营在 slot-admin 维护),与 [`DepositBonusLogic`](slot-activity/app/innerapi/logic/DepositBonusLogic.php) / [`SignInLogic`](slot-activity/app/api/logic/SignInLogic.php) 读 `ext` 的方式一致。
- **按 `activity_code` 定位**:代码里固定 `trial_reward`(你已选 PHP 常量),各环境 DB 里 `activity_id` 可以不同,**发版/切环境不依赖 center 改数字 ID**。
当前临时方案的问题:
```74:82:slot-center/config/share_config.php
'activity' => [
'trialReward' => [
'activity_id' => 9001,
'activity_code' => 'trial_reward',
'deposit_threshold' => 50,
'trial_withdrawal_cap' => 500,
],
],
```
[`TrialFirstDepositLogic`](slot-activity/app/innerapi/logic/TrialFirstDepositLogic.php) 通过 `ShareConfigService` 读上述键,与「活动服自治」冲突。
---
## 目标架构
```mermaid
sequenceDiagram
participant Pay as slot_pay
participant Act as slot_activity
participant DB as activity_table
participant Wal as slot_wallet
Pay->>Act: first_deposit(uid, source, order_no, ...)
Act->>DB: findActiveByCode(trial_reward, source, now)
DB-->>Act: activity_id + ext(deposit_threshold, cap, ...)
Act->>Wal: clearTrialBonusForPool
Act->>Act: persist trial_reward_record(uid, activity_id)
```
| 配置项 | 现位置 | 目标位置 |
|---|---|---|
| activity 身份 | center `trialReward.activity_id` | `activity.activity_code = trial_reward` → 得到 `activity_id` |
| deposit_threshold | center | `activity.ext.deposit_threshold`(美元,入库 *1000 |
| trial_withdrawal_cap | center | `activity.ext.trial_withdrawal_cap` |
| activity_code 常量 | center | [`TrialRewardConstants::ACTIVITY_CODE`](slot-activity/app/constants/TrialRewardConstants.php) |
center 保留:`walletApiHost`[`TrialWalletGatewayService`](slot-activity/app/service/wallet/TrialWalletGatewayService.php) 仍需要)。
---
## 实现步骤
### 1. 定义活动编码与 ext 键slot-activity
改 [`TrialRewardConstants.php`](slot-activity/app/constants/TrialRewardConstants.php)
- 新增 `ACTIVITY_CODE = 'trial_reward'`PHP 常量,跨环境稳定)
- 新增 ext 字段键名常量:`EXT_DEPOSIT_THRESHOLD`、`EXT_TRIAL_WITHDRAWAL_CAP` 等
- **删除** 指向 center 的 `CONFIG_KEY_ACTIVITY_ID / CODE / DEPOSIT_THRESHOLD / WITHDRAWAL_CAP`
- 保留 `DEFAULT_DEPOSIT_THRESHOLD`、`DEFAULT_WITHDRAWAL_CAP` 作为 **ext 缺省兜底**(与 SignIn 读 ext 缺字段时给默认值一致)
### 2. ActivityModel 增加按 code 查询
在 [`ActivityModel.php`](slot-activity/app/model/ActivityModel.php) 新增(对齐现有 `findMatchedSignInBySource` / `findActiveByIdForDeposit` 风格):
```php
public static function findActiveByCode(string $activityCode, string $source, string $now): ?self
```
条件:`activity_code` 精确匹配 + `status=1` + 时间窗 + `source_list` 命中(`all` 或 `$source`)。
可选后续:在 `slot-foundation` 增加 `ActivityType::TRIAL_WITHDRAWAL`admin 筛选用;**本期不强制**code 已足够唯一标识。
### 3. 抽取活动配置解析(避免 Logic 堆 ext 解析)
新增小类,例如 [`TrialRewardActivityResolver.php`](slot-activity/app/service/trial/TrialRewardActivityResolver.php)
- 输入:`source`(来自 [`TrialFirstDepositDto`](slot-activity/app/innerapi/dto/TrialFirstDepositDto.php)
- 输出:`activity_id`、`activity_code`(快照用)、`deposit_threshold`*1000、`trial_withdrawal_cap`*1000
- 活动不存在/未启用/未命中渠道 → 抛业务异常或返回明确 reason如 `activity_not_found`**禁止静默用 center 默认值**
解析规则:
- `activity_id` / `activity_code` 来自 DB 行
- 阈值从 `ext` 读,缺省用 `TrialRewardConstants::DEFAULT_*`,再 `MoneyTool::bigUnitToSmallUnit`
### 4. 改造 TrialFirstDepositLogic
改 [`TrialFirstDepositLogic.php`](slot-activity/app/innerapi/logic/TrialFirstDepositLogic.php)
- `handleFirstDeposit()`:先 `resolveActivity($dto->source)`,再用返回的 `activity_id` 查/建 `trial_reward_record`
- `persistPool()``activity_code`、阈值、cap 均来自 resolver**移除所有 `ShareConfigService::get(trialReward.*)`**
- `queryRecord()`:同样按 `ACTIVITY_CODE` resolve 后再 `findByUidActivity`
- `resolveThresholdAmount()` / `resolveWithdrawalCap()`:改为接收已解析的 Activity 或 config DTO不再读 center
失败语义建议:
| reason | 含义 |
|---|---|
| `activity_not_found` | 无生效的 trial_reward 活动 |
| `wallet_clear_failed` | wallet 清零失败(保持现有) |
| `duplicate` / `success` | 不变 |
### 5. 移除 center 试玩业务配置
改 [`slot-center/config/share_config.php`](slot-center/config/share_config.php)
- **删除整个 `activity.trialReward` 块**(不是只删 threshold
- 重启 center本地 dev 已习惯 `php webman restart -d`
### 6. 联调/上线数据activity 表种子
新增 SQL 补丁,例如 [`slot-activity/db/patch_trial_reward_activity.sql`](slot-activity/db/patch_trial_reward_activity.sql)(插入/更新 `activity` 行,`activity_code=trial_reward`
```json
{
"deposit_threshold": 50,
"trial_withdrawal_cap": 500,
"trial_bonus_amount": 20
}
```
字段建议:`status=1`、`new_user_only=1`、`first_deposit_only=0`(首充事件另管 Pool、`source_list=["all"]`、合理 `start_time/end_time`。`activity_id` 自增即可,**各环境可以不同**。
### 7. 测试调整
- 单测 [`TrialFirstDepositStatusTest`](slot-activity/tests/Unit/Logic/TrialFirstDepositStatusTest.php):纯状态机不变
- 新增 resolver 单测mock `ActivityModel` 或测 ext 解析私有方法ext 有/无字段、美元→*1000
- 联调:在 **slotMysql.s_activity** 执行 activity 种子后,重跑首充 happy-path不再依赖 center trialReward
---
## 不变更范围(刻意不做)
- **slot-pay**:仍只调 `/innerapi/trial-reward/first-deposit`,不传 `activity_id`符合「pay 只报事件activity 解析活动」)
- **slot-wallet / slot-sdk**:无改动
- **slot-admin 后台表单**:本期可先用 SQL 种子 + ext JSON完整 07 子需求的可视化配置可后续单独做
---
## 风险与注意
1. **必须先有 activity 行**:删掉 center 配置后,若 DB 无 `trial_reward` 活动,首充会 `activity_not_found`(比误用 9001 更安全)。
2. **历史联调数据**:若 `trial_reward_record` 里是 `activity_id=9001`,新环境 activity 自增 ID 不同;新用户无影响,老测试 uid 需清数据或对齐 activity_id。
3. **ShareConfigService 仍保留**:仅用于 `walletApiHost` 等,与试玩业务解耦。

View File

@@ -0,0 +1,222 @@
<!-- 95f2e196-ce33-4253-ace0-0a2ba260a0c2 -->
---
todos:
- id: "backend-catalog-api"
content: "collinCourseSeries/Course 列表 API + CORS 中间件"
status: pending
- id: "scaffold-collin-web"
content: "新建 collin-webVite + Vue3 + Element Plus + Router + axios"
status: pending
- id: "catalog-pages"
content: "实现系列 / 册别 / 课文列表三页Element Plus"
status: pending
- id: "integrate-typing-core"
content: "alias 引入 @typewords/coreArticle adapter + PracticeView + TypingArticle"
status: pending
- id: "static-audio-proxy"
content: "配置 /sound 静态资源与 VITE_MEDIA_BASE 联调"
status: pending
- id: "e2e-nce-lesson5"
content: "端到端验收 nce-2 第 5 课听写/默写"
status: pending
isProject: false
---
# TypeWords → Element Plus + collin 迁移计划MVP课文练习
## 目标与边界
**第一版做:**
- 课程系列 / 册别选择
- 课文列表(按 `lesson_no` 排序)
- 课文听写、默写(复用 TypeWords 练习引擎)
**第一版不做:**
- 单词独立练习、FSRS、登录、云同步、词本、查词 UI
- 仍用 TypeWords 本地 IndexedDB 存**练习进度/设置**(可选,与 collin 无关)
```mermaid
flowchart LR
subgraph ui [collin-web Element Plus]
SeriesPage --> CoursePage --> LessonList --> PracticePage
end
subgraph core [TypeWords packages/core]
TypingArticle --> genArticleSectionData
end
subgraph api [collin Webman]
SeriesAPI --> CourseAPI --> ArticleAPI
end
PracticePage --> ArticleAPI
PracticePage --> TypingArticle
ArticleAPI --> genArticleSectionData
```
---
## 推荐架构
| 层 | 技术 | 说明 |
|----|------|------|
| 新前端 | Vue 3 + Vite + **Element Plus** + Pinia + Vue Router | 新建 [`english/collin-web/`](english/collin-web/),与 [`collin/`](english/collin/) 分离 |
| 练习内核 | 复用 [`TypeWords/packages/core`](TypeWords/packages/core) | 只引课文相关:`TypingArticle.vue``hooks/article.ts``hooks/sound.ts`、必要 stores |
| 壳层 UI | Element Plus | 布局、表格、导航、设置抽屉;**练习区不用 EP 重写** |
| 后端 | 现有 [`CourseArticleController`](collin/app/controller/api/CourseArticleController.php) + 少量新接口 | Webman 默认路由,不加 `route.php` 业务路由 |
**为何不全量迁 Nuxt 应用:** [`TypeWords/apps/nuxt`](TypeWords/apps/nuxt) 绑定 Nuxt4、14 语言、用户/VIP/SupabaseMVP 只需 [`apps/vscode-web`](TypeWords/apps/vscode-web) 那种「Vite SPA + 引用 core」模式更贴合 Element Plus。
---
## 后端MVP 需补的 3 块
现有课文 API 已够用([`database/README.md`](collin/database/README.md)
- `GET /api/course-article/index?courseId=`
- `GET /api/course-article/show-by-lesson?courseId=&lessonNo=`
- `GET /api/course-article/show?articleId=`
**还需新增P0**
1. **课程系列列表**`CourseSeriesController::index`
- 路由:`GET /api/course-series/index`
- 返回 `course_series``status=1`,按 `sort`
- 新建:`CourseSeriesLogic` + `CourseSeriesListEntity`
2. **课程列表**`CourseController::index`
- 路由:`GET /api/course/index?seriesCode=nce`
- 返回该系列下所有 `course``pep-9-1``nce-2` 等)
- 新建:`CourseLogic` + `CourseListEntity`
3. **CORS 中间件** — [`config/middleware.php`](collin/config/middleware.php) 注册
- 开发:`http://localhost:5173`
- 允许 `GET/PATCH/OPTIONS`,响应头与 collin `ApiResponse` 一致
**可选 P1MVP 可跳过):** `GET /api/course-article/vocab` 已在后端,第一版练习不展示生词侧栏即可。
---
## 前端colin-web 结构
```
collin-web/
├── package.json # vue, element-plus, pinia, vue-router, axios
├── vite.config.ts # alias @typewords/core → ../TypeWords/packages/core
├── src/
│ ├── api/collin.ts # axios 封装baseURL 指向 collin
│ ├── adapters/article.ts # TypeWordsArticleEntity → Article
│ ├── router/index.ts
│ ├── stores/setting.ts # 精简版(音效、快捷键),可抄 core/setting 子集
│ ├── views/
│ │ ├── SeriesView.vue # el-row / el-card 选系列
│ │ ├── CourseView.vue # 选册nce-2 / pep-9-1
│ │ ├── LessonListView.vue # el-tablelessonNo、title、contentVersion
│ │ └── PracticeView.vue # 嵌入 TypingArticle
│ └── App.vue # el-container 侧边栏
```
### Article 适配(关键一步)
collin 返回 [`TypeWordsArticleEntity`](collin/app/entity/article/TypeWordsArticleEntity.php) 已含 `text``nameList``lrcPosition` 等;练习前客户端补全 TypeWords 运行时字段:
```ts
// adapters/article.ts 思路
import { genArticleSectionData } from '@typewords/core/hooks/article'
import type { Article } from '@typewords/core/types'
export function toPracticeArticle(dto): Article {
const article: Article = {
...dto,
sections: [],
newWords: [], // MVP 空数组即可
audioFileId: '',
questions: dto.question ? [dto.question] : [],
}
genArticleSectionData(article) // text → sectionsTypeWords 原文逻辑)
return article
}
```
参考:[`TypeWords/packages/core/src/hooks/article.ts`](TypeWords/packages/core/src/hooks/article.ts) 中 `genArticleSectionData`
### 复用 core 的最小依赖集
从 [`packages/core`](TypeWords/packages/core) 引入(通过 Vite alias参照 [`apps/vscode-web`](TypeWords/apps/vscode-web)
- 组件:`components/article/TypingArticle.vue``TypingWord.vue``ArticleAudio.vue`
- 依赖 stores`runtime``setting`(练习态 + 音效)
- hooks`sound.ts``article.ts`
- 类型:`types/types.ts``types/enum.ts`
**不要**引入Supabase、`useInit` 全量同步、dict 词书、FSRS。
若 Pinia store 耦合过重,可在 `PracticeView` 里只 mount `TypingArticle` 并注入精简 storevscode-web 已验证可行)。
---
## 静态资源(音频)
NCE 音频路径在 DB 中为 `/sound/article/nce2-1/...`(见 [`nce2-skip-words`](collin/database/data/nce2-skip-words.json) 与导入 JSON
- **开发:** Vite `server.proxy``public/sound` 软链到 [`english/sound/`](english/sound/)
- **生产:** Nginx 同一域名挂载 `/sound/`,或与 API 分 CDN
TypeWords 侧用 `resolveMediaUrl()`[`packages/core/src/config/env.ts`](TypeWords/packages/core/src/config/env.ts))— 在 collin-web 设 `VITE_MEDIA_BASE=/` 即可。
---
## 页面流Element Plus
| 页面 | EP 组件 | 数据 |
|------|---------|------|
| 选系列 | `el-card` / `el-menu` | `course-series/index` |
| 选册 | `el-table` | `course/index?seriesCode=` |
| 课文列表 | `el-table` + `lessonNo` 列 | `course-article/index?courseId=` |
| 练习 | `el-page-header` + 全屏 `TypingArticle` | `show-by-lesson` → adapter |
路由示例:
- `/` → 系列
- `/courses/:seriesCode` → 册别
- `/courses/:seriesCode/lessons/:courseId` → 课文列表
- `/practice/:courseId/:lessonNo` → 练习
---
## 实施顺序
### Phase 1 — collin API 补全12 天)
- `CourseSeriesController` / `CourseController` + Logic + Entity
- CORS 中间件
- 更新 [`database/README.md`](collin/database/README.md) API 表
### Phase 2 — collin-web 脚手架1 天)
- Vite + Vue3 + Element Plus + Router + Pinia
- axios + 统一 `{ code, msg, data }` 处理
- 系列 / 册别 / 课文列表三页(仅 EP无练习
### Phase 3 — 接入练习内核23 天)
- Vite alias 接 `@typewords/core`
- `toPracticeArticle` + `PracticeView`
- 音效、键盘、nameList 跳过逻辑验证Lesson 5 有 skipWords
### Phase 4 — 联调与部署1 天)
- dockercollin Webman + 前端 `pnpm build` 静态资源或独立 dev
- curl / 浏览器验证:`nce-2` 第 5 课
---
## 风险与对策
| 风险 | 对策 |
|------|------|
| core 依赖 Nuxt 宏 / 大量 auto-import | 参照 vscode-web 的 polyfill仅 import 明确路径 |
| core store 与 collin 数据模型不一致 | MVP 课文数据只从 API 进 PracticeView不写入 baseStore.dict |
| 无登录 | MVP 匿名使用;进度仍放 IndexedDB与 TypeWords 相同) |
| 后续扩展单词/词本 | DB 已有 `user_wordbook*``dict`;第二版再加 API + EP 页面 |
---
## 验收标准MVP
1. 浏览器打开 collin-web选「新概念英语 → nce-2 → 第 5 课」
2. 进入练习,`nameList` 中人名/地名自动跳过
3. 听写/默写键盘与音效与 TypeWords 一致(同一 `TypingArticle`
4. 数据全部来自 collin API不再 fetch CDN 上的 `NCE_2.json`

View File

@@ -0,0 +1,48 @@
<!-- 3895e6da-dd34-4ce4-b39b-e1e4ccf1c60c -->
---
todos:
- id: "sec2-4"
content: "改 §2 差异 bullet 与 §4.1deposit_balance/frozen_bonus 语义、不变量拆两条、移除差异 note、展示口径三桶"
status: pending
- id: "sec5-6"
content: "改 §5 对应关系补聚合桶映射§6.1 入账改为 deposit_balance += 本金、frozen_bonus += bonus"
status: pending
- id: "sec10-11"
content: "改 §10.4 扣款分桶、§11.3 派奖按命中侧落 deposit_balance / frozen_bonus"
status: pending
- id: "sec12-13"
content: "改 §12 整组解锁拆 Deposit/Bonus 分桶转 Cash§13.1 PlayedOut 表述"
status: pending
- id: "sec15-19"
content: "改 §15 Account 更新规则分桶、§16 不变量、§19 总结条款 2/3"
status: pending
isProject: false
---
# 钱包系统V2.md恢复 frozen_bonus 分桶记账
仅修订 [docs/requirements/钱包系统V2.md](docs/requirements/钱包系统V2.md) 中与"聚合余额落桶"相关的章节。打码组推进、消耗顺序、派奖命中归属、Completed/PlayedOut、`fund_detail` JSON、Redis 命中摘要等核心逻辑**保持不变**。
## 修订内容
- 三处不变量/语义:`deposit_balance = Σ 进行中 Deposit Lot.remaining``frozen_bonus ⊇ Σ 进行中组内 Bonus Lot.remaining`(含 Trial/其它发奖,超集)。
- 派奖落桶:命中 Deposit → `deposit_balance += payout`;命中 Bonus → `frozen_bonus += payout`
- 整组解锁:`group_remaining` 拆 Deposit 侧(扣 `deposit_balance`+ Bonus 侧(扣 `frozen_bonus`),合并进 `withdraw_balance`(对应现网 `moveDepositToWithdraw` + `moveBonusToWithdraw`)。
- 前端"锁定可玩/Locked Play"如需整体展示,须从 Lot/Group 聚合算组内 Bonus不能直接取 `frozen_bonus`(默认口径:展示为 Locked Deposit + Bonus Credits 两项,可玩 = 三桶和)。
## 待改小节(逐节)
- 第 42 行 §2 差异 bullet删除"充值 Bonus 进 deposit_balance"这条差异(改回后与现网一致)。
- §4.1`deposit_balance` 语义改为"仅 Deposit 本金 + Deposit 侧派奖"`frozen_bonus` 语义改为"组内 Bonus + Trial 体验金 + 其它发奖";不变量拆两条;移除第 120-121 行"与现网差异"note展示口径调整为三桶。
- §5 对应关系补注聚合桶映射Deposit 侧→`deposit_balance`、Bonus 侧→`frozen_bonus`)。
- §6.1:改为 `deposit_balance += 100``frozen_bonus += 50`;删除"不更新 frozen_bonus"与差异 note。
- §10.4:扣款分桶改为 扣 Deposit Lot→`deposit_balance--`、扣 Bonus Lot→`frozen_bonus--`、扣 Cash→`withdraw_balance--`
- §11.3:命中 Deposit→`deposit_balance += payout`、命中 Bonus→`frozen_bonus += payout`(各自累加对应 Lot remaining
- §12 打码完成:`group_remaining` 拆 Deposit/Bonus 分桶扣减后合并进 `withdraw_balance`
- §13.1 PlayedOutBonus Lot 置 PlayedOut 时 `frozen_bonus` 对应部分已为 0无需额外扣。
- §15 Account 更新规则15.1 / 15.2 / 15.4 / 15.6 改为分桶表述。
- §16 不变量、§19 总结条款 2、3同步为分桶口径。
## 不改动
- §9 局生命周期、§10.1-10.3 进度归属与扣款顺序、§11.1-11.2 命中归属与终态降级、§4.4 `fund_detail` JSON、§18 回滚不做。
- Q4「下线 win 状态路由与比例拆分」结论不变(命中 Bonus 记 `frozen_bonus` 不复活比例拆分)。

View File

@@ -0,0 +1,80 @@
<!-- 740abbc4-5a59-41be-9318-5927550e6875 -->
---
todos:
- id: "sql"
content: "新建 slot_console/db/migrate_vip_bet_bonus_rate.sqlALTER 加列 bet_bonus_rate decimal(5,2) + UPDATE 写入 VIP1-15 初始值"
status: pending
- id: "console-logic"
content: "改 UserGameStatistics::switchBonus() 按 VIP 等级取 bet_bonus_rateVIP0/空配置不转换,补日志上下文"
status: pending
- id: "console-model"
content: "slot_console UserVipConfigModel 补 @property bet_bonus_rate"
status: pending
- id: "admin-model-validate"
content: "slot_admin UserVipConfigModel @property + UserVipConfigValidate rule/message/scene 增加 bet_bonus_rate"
status: pending
- id: "vue"
content: "slot_admin_vue edit.vue 与 index.vue 增加解锁流速字段与列"
status: pending
- id: "verify"
content: "按门禁跑 verify-slot-backend.sh 与 docker php -l输出检测报告"
status: pending
isProject: false
---
## 背景
`slot_console` 每日统计在用户亏损时把冻结金额转为可领取金额,当前用全局固定费率:
```147:148:slot_console/app/command/UserGameStatistics.php
$gameBaseInfo = ConfigService::getInstance()->getGroupData('game_base');
$bonus = intval($gameBaseInfo['bet_bonus_rate'] * $lose/100);
```
需求:解锁流速按 VIP 等级阶梯VIP1=0.5% … VIP15=2.0%),后台可配置。`user_vip_config` 现无此字段(已有的 `lose_rate` 是「亏损奖励」另一活动,不复用)。
## 决策(已确认)
- 全栈实现DB 列 + slot_admin/vue 后台可配 + slot_console 按等级取值。
- 新列 `bet_bonus_rate``decimal(5,2)`,单位 %。
- VIP0 比例为 0不再转换冻结金额
## 改动点
### 1. DB 迁移(新建 SQL
新建 [slot_console/db/migrate_vip_bet_bonus_rate.sql](slot_console/db/migrate_vip_bet_bonus_rate.sql)
- `ALTER TABLE s_common.user_vip_config ADD COLUMN bet_bonus_rate decimal(5,2) NOT NULL DEFAULT 0 COMMENT '冻结金额解锁流速(%)按VIP等级' AFTER lose_rate;`
- 按需求 `UPDATE` 写入 VIP1-15 初始值0.5/0.6/.../1.8/2.0VIP15=2.0)。
### 2. slot_console 取值逻辑
改 `switchBonus()`[slot_console/app/command/UserGameStatistics.php](slot_console/app/command/UserGameStatistics.php)
- 用 `VipService::getVipLevelConfig($userInfoEntity->vip_level)` 取 VIP 配置(已有 Redis 缓存)。
- VIP0 / 配置为空 / 费率<=0 直接 return不转换
- `$bonus = intval($vipConfig->bet_bonus_rate * $lose / 100);` 取代 game_base 读取。
- 日志补 `vip_level`、`bet_bonus_rate`。
模型 PHPDoc 加 `@property` 字段:[slot_console/app/model/UserVipConfigModel.php](slot_console/app/model/UserVipConfigModel.php)。
### 3. slot_admin 后台
- [model/UserVipConfigModel.php](backend/slot_admin/app/model/UserVipConfigModel.php)`@property` 增加 `bet_bonus_rate`。
- [validate/UserVipConfigValidate.php](backend/slot_admin/app/game/validate/UserVipConfigValidate.php)`rule`/`message`/`scene(save,update)` 增加 `bet_bonus_rate => require`。
- 该字段为百分比小数,直接存储,不走 `setNumberFormat`(控制器 `format()` 无需改)。
### 4. slot_admin_vue 前端
- [edit.vue](backend/slot_admin_vue/src/views/game/userVipConfig/edit.vue)`formData` + `rules` + 新增「解锁流速(%)」`a-form-item field="bet_bonus_rate"`。
- [index.vue](backend/slot_admin_vue/src/views/game/userVipConfig/index.vue)`columns` 增加「解锁流速」列。
## 数据流
```mermaid
flowchart LR
admin[slot_admin 配置bet_bonus_rate] --> db[(s_common.user_vip_config)]
admin --> cache[(redis vip:config:level:*)]
console[slot_console switchBonus] --> vipsvc[VipService.getVipLevelConfig]
vipsvc --> cache
vipsvc --> db
console --> wallet[switchBonus 转可领取]
```
## 注意
- `game_base.bet_bonus_rate` 配置改为不再被 `switchBonus` 使用(保留配置项,不删除)。
- Redis 缓存存 `model->toArray()`新列自动随后台保存刷新TTL 600s
- 完成前按门禁跑 `verify-slot-backend.sh` 与 docker `php -l`,并对照 php-clean-code 输出检测报告。

View File

@@ -0,0 +1,253 @@
<!-- c1c00f01-351c-4b81-9a07-dec92aaf4ae2 -->
---
todos:
- id: "phase0-prep"
content: "Phase 0确认 minus() 死代码、补 PHPDoc、更新 README 入口索引"
status: pending
- id: "phase1-base-query-register"
content: "Phase 1新建 AbstractWalletMutationLogic + WalletQueryLogic + WalletRegisterLogicWalletLogic 委托 register/query"
status: pending
- id: "phase2-bet-win"
content: "Phase 2新建 WalletBetWinLogic迁移 bet/win 及 Redis round 辅助逻辑"
status: pending
- id: "phase3-recharge"
content: "Phase 3新建 WalletRechargeLogic迁移 inc/recharge/reward 与 Trial Pool 充值链"
status: pending
- id: "phase4-withdraw-trial"
content: "Phase 4新建 WalletWithdrawLogic + WalletTrialLogic修复 switchBonus 路由(如需要)"
status: pending
isProject: false
---
# WalletLogic 分阶段拆分方案
## 现状诊断
[`slot-wallet/app/api/logic/WalletLogic.php`](slot-wallet/app/api/logic/WalletLogic.php) 当前 **1885 行**,承担了钱包服务几乎全部资金用例编排:
| 域 | 主要方法 | 约行数 | 已有下沉 |
|---|---|---|---|
| 路由门面 | `run`, `runRechargeOrder`, `ACTION_METHOD_MAP` | ~80 | — |
| 注册 | `register`, `initWalletWithoutMoney` | ~120 | [`RegisterService`](slot-wallet/app/service/wallet/RegisterService.php) |
| 下注/派奖 | `bet`, `win`, pending win Redis, bet split | ~450 | [`BetService`](slot-wallet/app/service/wallet/BetService.php), [`WinService`](slot-wallet/app/service/wallet/WinService.php) |
| 入账/充值 | `inc`, `recharge`, `rechargeSign`, `reward`, Trial Pool | ~400 | [`RechargeFundLotService`](slot-wallet/app/service/wallet/RechargeFundLotService.php) |
| 提现/Bonus 转换 | `withdraw*`, `rollbackWithdraw`, `bonusToDeposit`, `switchBonus` | ~350 | [`WithdrawTaskService`](slot-wallet/app/service/wallet/WithdrawTaskService.php) |
| Trial 体验金 | `grantTrial`, `expireTrial` | ~250 | — |
| 查询 | `query`, `queryBizLog` (static) | ~70 | — |
| 公共能力 | `addLog`, `maxRetry`, `sendConsoleBus`, `getBalance` 等 | ~200 | — |
**问题**:单文件混合 6+ 业务域事务边界、幂等、Redis 辅助状态交织Review 和改动风险都高。
**已有良好先例**:同目录 [`GrantLogic`](slot-wallet/app/api/logic/GrantLogic.php)、[`PlayerTaskLogic`](slot-wallet/app/api/logic/PlayerTaskLogic.php) 已是「一用例一 Logic」核心扣款/派奖细节已在 Service 层Logic 层再拆是合理下一步。
**额外发现**(非本次必做):`minus()` 未出现在 `ACTION_METHOD_MAP` 且无调用方,疑似历史遗留,可在 Phase 0 确认后删除。
---
## 目标架构
```mermaid
flowchart TB
subgraph controller [Controller 不变]
WC[WalletController]
end
subgraph facade [门面层]
WL[WalletLogic]
end
subgraph domain [子 Logic 按域]
WQ[WalletQueryLogic]
WRg[WalletRegisterLogic]
WBw[WalletBetWinLogic]
WRe[WalletRechargeLogic]
WWd[WalletWithdrawLogic]
WTr[WalletTrialLogic]
end
subgraph shared [共享]
Base[AbstractWalletMutationLogic]
end
subgraph service [已有 Service 不动]
BS[BetService]
WS[WinService]
RS[RegisterService]
end
WC --> WL
WL --> WRg & WBw & WRe & WWd & WTr
GrantLogic --> WQ
WL --> WQ
WRg & WBw & WRe & WWd & WTr --> Base
WBw --> BS & WS
WRg --> RS
```
**原则**(对齐 [`backend-layering`](.cursor/rules/backend-layering.mdc)
- **Logic 拆用例、Service 保公共能力**:不再新建「只做 Model 转发」的 Service。
- **WalletLogic 只做分发 + 缓存清理**:保留 `run()` / `runRechargeOrder()` 对外签名;`ACTION_METHOD_MAP` 改为指向子 Logic 方法。
- **Controller 不改**:继续只注入 `WalletLogic`(你已确认)。
- **共享状态收敛**:子 Logic 继承抽象基类,统一持有 `WalletUpdateRequestDTO``rechargeOrderDto` 仅留在 `WalletRechargeLogic`
---
## 共享基类设计
新建 [`slot-wallet/app/api/logic/AbstractWalletMutationLogic.php`](slot-wallet/app/api/logic/AbstractWalletMutationLogic.php)
```php
abstract class AbstractWalletMutationLogic
{
protected WalletUpdateRequestDTO $requestDTO;
/** 绑定本次 mutation 请求上下文 */
protected function bindRequest(WalletUpdateRequestDTO $requestDTO): void { ... }
/** 写 wallet_log、PWA 流水、乐观锁重试、Console Bus 等 */
protected function addLog(...): int { ... }
protected function addTransactionRecord(...): void { ... }
protected function getBalance(WalletAccountModel $model): int { ... }
protected function maxRetry(callable $callback, int $retryCount = 3): WalletEntity { ... }
protected function sendConsoleBus(string $type, int $amount): void { ... }
protected function initWalletWithoutMoney(): ?WalletAccountModel { ... }
}
```
- 从现有 `WalletLogic` **原样迁移**上述 protected/private 方法,避免行为变化。
- 子 Logic 方法签名改为显式接收 DTO 或在门面 `bindRequest()` 后调用,**不再依赖 `$this->requestDTO` 隐式全局状态跨文件散落**。
---
## 子 Logic 划分
| 新类 | 职责 | 从 WalletLogic 迁出的方法 |
|---|---|---|
| `WalletQueryLogic` | 只读查询 | `query`, `queryBizLog` |
| `WalletRegisterLogic` | 注册赠金 | `register` |
| `WalletBetWinLogic` | 游戏下注/派奖编排 | `bet`, `win`, `executeTrialBet/Win`, `executeFreeSpinPaidBet`, pending win / bet split Redis, `getWinAllocationByRound`, `buildBetSplitRemark` |
| `WalletRechargeLogic` | 入账/充值 | `inc`, `executeIncWithTransaction`, `recharge`, `rechargeSign`, `reward`, Trial Pool 扣减链, `resolveRechargeOrderDtoForLots`, `insertRechargeFundLotsIfNeeded` |
| `WalletWithdrawLogic` | 提现与 Bonus 桶转换 | `withdraw`, `withdrawFrozen`, `rollbackWithdraw`, `doneWithdraw`, `bonusToDeposit`, `switchBonus` |
| `WalletTrialLogic` | Trial 体验金生命周期 | `grantTrial`, `expireTrial`, `resolveTrialAmount`, `makeTrialPoolBizId`, `buildTrialPoolRemark`, `deductTrialPoolInRechargeTransaction`(若仍被 Recharge 调用则保留在 Recharge 或抽 `TrialPoolSupport` trait |
**Trial Pool 边界**`planTrialPoolDeductionForRecharge` / `finalizeTrialPoolAfterRechargeInc` 与充值事务强耦合,建议 **Phase 3 随 RechargeLogic 一起迁**Trial 发放/过期独立进 `WalletTrialLogic`
---
## 门面 WalletLogic拆分后 ~150 行)
```php
class WalletLogic
{
public function __construct(
private WalletRegisterLogic $registerLogic,
private WalletBetWinLogic $betWinLogic,
private WalletRechargeLogic $rechargeLogic,
private WalletWithdrawLogic $withdrawLogic,
private WalletTrialLogic $trialLogic,
) {}
public function run(WalletUpdateRequestDTO $requestDTO) { /* 分发 + deleteWalletCache */ }
public function runRechargeOrder(WalletRechargeRequestDTO $dto) { /* 委托 rechargeLogic */ }
// 兼容 GrantLogic / 外部 static 调用
public static function query(...) { return WalletQueryLogic::query(...); }
public static function queryBizLog(...) { return WalletQueryLogic::queryBizLog(...); }
}
```
`ACTION_METHOD_MAP` 改为 **Logic 实例 + 方法名****callable 数组**,例如:
```php
WalletLogModel::BIZ_TYPE_BET => [$this->betWinLogic, 'bet'],
```
Webman DI 需在 [`config/`](slot-wallet/config/) 或现有容器绑定中注册子 Logic若项目无自动注入门面构造函数内 `new` 亦可,与当前 `GrantLogic` 风格一致)。
---
## 分阶段实施(你已选 phased + facade
### Phase 0准备低风险
-`WalletLogic` 现有 public 方法补全/核对中文 PHPDoc拆分时会移动代码趁此对齐 [`php-clean-code`](.cursor/rules/php-clean-code.mdc))。
- 确认 `minus()` 无调用后删除(或加 `@deprecated` 注释待下一版删)。
- 更新 [`README.md`](slot-wallet/README.md)「实现入口」一节,列出子 Logic 索引。
### Phase 1Query + Register + 基类
- 新建 `AbstractWalletMutationLogic``WalletQueryLogic``WalletRegisterLogic`
- `WalletLogic::query/queryBizLog` 改为委托;[`GrantLogic`](slot-wallet/app/api/logic/GrantLogic.php) 可继续 `WalletLogic::query()` 不动。
- `register` 从 WalletLogic 迁出;`run()` 分发到 `WalletRegisterLogic`
- **验收**注册接口、余额查询、Grant 通知链路回归。
### Phase 2BetWin最高复杂度
- 新建 `WalletBetWinLogic`,迁移 `bet`/`win` 及 Redis round 状态方法。
- `WalletController``bet`/`win` 专用入口仍走 `walletLogic->run()`,行为不变。
- **注意**`win()` 内对 `$requestDTO->fee` 的临时修改必须在 BetWinLogic 内用 `try/finally` 保持,避免泄漏到其他域。
- **验收**:下注/派奖、Trial 阶段 bet/win、FreeSpin 代付、中间派奖 `is_end=0`、幂等 hit。
### Phase 3Recharge / Inc
- 新建 `WalletRechargeLogic`,迁移 `inc` 全链路 + `recharge`/`rechargeSign`/`reward`
- `runRechargeOrder` 委托给 RechargeLogic`rechargeOrderDto` 生命周期)。
- **验收**:充值主链路、首充 Trial Pool 扣减、Fund Lot 落库、签到充值 `sign`
### Phase 4Withdraw + Trial
- 新建 `WalletWithdrawLogic``WalletTrialLogic`
- 补齐 `ACTION_METHOD_MAP` 中缺失的 `switchBonus` 映射(若 slot-sdk 仍在调 `type=switchBonus`,当前可能未路由到方法——拆分时可一并修复)。
- **验收**:提现冻结/成功/失败回滚、Bonus 转 Deposit、Trial 发放/过期。
---
## 不建议的做法
- **不要把子用例再拆成 Logic 型 Service**(如 `WithdrawLogicService`),违反分层红线。
- **不要一次性改 Controller 注入多个 Logic**,你已选 facade保持对外稳定。
- **不要把 BetService/WinService 的业务编排再搬回 Logic 后又复制一份**——子 Logic 只做事务、幂等、流水、MQ核心算法继续调用现有 Service。
---
## 预期收益
- 单文件从 ~1900 行降至 **门面 ~150 行 + 各子 Logic 200500 行**
- 改动 bet 不再误触 recharge 事务代码Review 范围清晰。
-`GrantLogic` 风格统一,新人可按域定位代码。
- 对外 API、`WalletLogic::run()` 签名、SDK 调用路径 **零破坏**
---
## 风险与缓解
| 风险 | 缓解 |
|---|---|
| 拆分引入行为回归 | 每 Phase 独立 PR优先复制代码再删旧代码不做顺手重构 |
| `$requestDTO` 可变状态 | 基类 `bindRequest()` + 子 Logic 不跨用例复用实例 |
| Webman 构造注入 | 若无 DIPhase 1 先在 WalletLogic 内 `new` 子 Logic后续再接线 |
| Trial Pool 跨 Recharge/Trial | Phase 3 完成 Recharge 后再动 Trial必要时抽 `TrialPoolDeductionSupport` trait 供 Recharge 专用 |
---
## 涉及文件
**新建**(均在 `slot-wallet/app/api/logic/`
- `AbstractWalletMutationLogic.php`
- `WalletQueryLogic.php`
- `WalletRegisterLogic.php`
- `WalletBetWinLogic.php`
- `WalletRechargeLogic.php`
- `WalletWithdrawLogic.php`
- `WalletTrialLogic.php`
**修改**
- [`WalletLogic.php`](slot-wallet/app/api/logic/WalletLogic.php) — 瘦身为门面
- [`README.md`](slot-wallet/README.md) — 更新入口索引(可选 `.cursor/rules/slot-wallet-prd-and-money.mdc` 一行指向)
**不改**
- [`WalletController.php`](slot-wallet/app/api/controller/WalletController.php)
- 现有 `app/service/wallet/*Service.php`

View File

@@ -0,0 +1,170 @@
---
name: Agent 自检报告机制
overview: 通过 **Rule强制收尾+ Skill报告模板与步骤+ Hook/脚本(可自动化的硬检查)** 三层配合,让 Agent 写完 PHP 后端代码后自动跑检测并输出结构化报告;不是单靠 Skill 或单靠 Hook 就能完全覆盖。
todos:
- id: create-skill
content: 新建 ~/.cursor/skills/slot-backend-completion-report/SKILL.md步骤 + 报告模板php -l 按工作区/git 仓库动态映射容器 -w不写死 slot-xxx
status: completed
- id: update-gate-rule
content: 更新 agent-completion-gate.mdc收尾必须执行 Skill 并输出检测报告章节
status: completed
- id: extend-verify-script
content: 可选verify-slot-backend.sh 增加 const 注释检查 + 修复 grep pipefail
status: completed
- id: project-copy
content: (可选)复制 Skill 到 www/ray/.cursor/skills/ 供仓库共享
status: completed
isProject: false
---
# Agent 写完代码后自动自检出报告:怎么配
## 你现在已经有什么
你其实**已经有一半机制**,只是还没有「结构化报告」这一层:
| 机制 | 文件 | 现在做什么 | 缺什么 |
|------|------|------------|--------|
| **RulealwaysApply** | [`~/.cursor/rules/agent-completion-gate.mdc`](/Users/ray/.cursor/rules/agent-completion-gate.mdc) | 要求收尾前跑 `verify-slot-backend.sh`、回复里贴 `PASS/FAIL`、写 `PHPDoc: checked` | 没规定报告格式(表格、分层检查、语法检查) |
| **Rule** | [`~/.cursor/rules/php-clean-code.mdc`](/Users/ray/.cursor/rules/php-clean-code.mdc) | §8 自查清单常量注释、Logic 编排等) | 靠 Agent **自觉对照**,脚本不检查 |
| **Hookstop** | [`~/.cursor/hooks.json`](/Users/ray/.cursor/hooks.json) → `verify-slot-backend.sh` | Agent **结束时会自动跑**门禁脚本;`FAIL` 会注入 followup 要求继续修 | 只输出 `PASS/FAIL`,不会生成你看到的 Markdown 报告 |
| **脚本** | [`~/.cursor/hooks/verify-slot-backend.sh`](/Users/ray/.cursor/hooks/verify-slot-backend.sh) | 扫 git diffbanlist、BaseController、RuntimeException 等 | **不检查** PHPDoc、常量注释、Vue 改动 |
所以你上面看到的那种报告,主要是 Agent **按 Rule 手动执行 + 人工对照规范**,不是某个 Skill 或 Hook 自动生成的。
```mermaid
flowchart TB
subgraph now [当前]
WriteCode[Agent 写代码]
RuleGate[agent-completion-gate Rule]
HookStop[stop Hook 跑 verify 脚本]
ManualReport[Agent 自觉出 Markdown 报告]
WriteCode --> RuleGate
WriteCode --> HookStop
RuleGate --> ManualReport
end
subgraph target [目标]
WriteCode2[Agent 写代码]
SkillReport[completion-report Skill]
ScriptHard[verify 脚本硬检查]
RuleMust[Rule 强制用 Skill 收尾]
WriteCode2 --> SkillReport
SkillReport --> ScriptHard
RuleMust --> SkillReport
SkillReport --> StructuredReport[固定格式检测报告]
end
```
---
## 三种方式分别适合什么
### 1. Rule —— 适合「必须做,否则不能说完成」
- **作用**每次对话都生效Agent 不能跳过
- **适合写**:「收尾前必须跑 X」「最终回复必须含 Y 格式的报告」
- **不适合**:很长的操作步骤(会占 context、难维护
你已有 [`agent-completion-gate.mdc`](/Users/ray/.cursor/rules/agent-completion-gate.mdc),只需**加一条**:收尾章节必须按 `slot-backend-completion-report` Skill 输出。
### 2. Skill —— 适合「怎么做 + 报告长什么样」
- **作用**Agent 在收尾阶段读取,按步骤跑命令、填模板
- **适合写**
- 执行顺序verify → docker php -l → 对照 §8 自查)
- **固定报告模板**(就是你上面看到的:门禁脚本 / 语法 / PHPDoc / 分层 / 结论)
- 按改动类型分支(只改 Vue / 只改 Logic / 改 Model 等)
- **路径建议**`~/.cursor/skills/slot-backend-completion-report/SKILL.md`(个人全局)或 `www/ray/.cursor/skills/...`(项目共享)
Skill **不会自动执行**,需要 Rule 或用户说「按规范检测」触发。
### 3. Hook + 脚本 —— 适合「能机器判定的硬规则」
- **作用**Agent 结束时自动跑(你已有 `stop` hook
- **适合写**git diff 里新增 `const` 无上一行 `/**`、新增 public 方法无 PHPDoc 等
- **不适合**Logic 是否编排清晰、命名是否达意(需 Agent 读代码判断)
Hook 只能 **FAIL 并追问**,不能像 Skill 那样输出完整 Markdown 报告。
---
## 推荐方案(三层,不重复造轮子)
### 层 1新建 Skill报告模板 + 步骤)
创建 [`~/.cursor/skills/slot-backend-completion-report/SKILL.md`](~/.cursor/skills/slot-backend-completion-report/SKILL.md),内容包括:
1. **触发**:修改 `app/**/*.php` 或用户说「按规范检测 / 检测代码」
2. **必跑命令**(路径均**动态解析**,禁止写死 `slot-xxx``/app/www/ray/...`
- **门禁脚本**`SLOT_ROOT="${SLOT_ROOT:-$WORKSPACE_ROOT}" ~/.cursor/hooks/verify-slot-backend.sh`
- `SLOT_ROOT` 默认为**当前 Cursor 工作区根目录**(用户可能在 `www/ray``www/slot` 等不同 monorepo 根下工作)
- **PHP 语法检查**(对每个 git diff 中的改动 `.php`
1. 取该文件所在 git 仓库根:`repo=$(git -C "$(dirname "$f")" rev-parse --show-toplevel)`
2. 宿主机项目挂载根 → 容器根(见 `dev-environment`):默认 `SLOT_DOCKER_HOST_ROOT=/Users/ray/Documents/project``SLOT_DOCKER_CONTAINER_ROOT=/app`
3. 容器内工作目录:`container_wd="${SLOT_DOCKER_CONTAINER_ROOT}${repo#$SLOT_DOCKER_HOST_ROOT}"`
4. 执行:`docker exec -w "$container_wd" php82 php -l "${f#$repo/}"`
- 示例(工作区在 `www/ray`、改 `slot-admin` 时):`-w /app/www/ray/slot-admin`;工作区在 `www/slot`、改 `slot_wallet` 时:`-w /app/www/slot/slot_wallet`——**由当前仓库路径推导,不手写服务名**。
3. **必做人工对照**(写进报告表格):
- `php-clean-code` §3 常量规则、§8 自查
- 改 Logic 时§3 参数与日志、聚合 DTO
- `backend-layering` 分层
4. **固定输出模板**(与你看到的报告一致):
- 门禁脚本完整输出
- PHPDoc / 常量 / 分层 / 结论
- 末行 `PHPDoc: checked`
可选Skill 内引用 [`scripts/report.sh`](/Users/ray/.cursor/skills/slot-backend-completion-report/scripts/report.sh) 统一跑 verify + php -l。脚本职责
- 读取 `WORKSPACE_ROOT` / `SLOT_ROOT`(当前工作区)
- 从 git diff 收集改动 PHP按上文规则计算 `container_wd`
- 输出结构化片段供 Agent 粘贴进报告
**用户级 Skill 约束**:不得假设固定 monorepo 路径(如 `www/ray`);所有宿主机/容器路径通过「工作区 + git 仓库根 + Docker 挂载映射」推导;挂载根可在 Skill 中 documented 为可配置 env默认对齐 [`dev-environment.mdc`](/Users/ray/.cursor/rules/dev-environment.mdc)。
### 层 2改 Rule强制收尾用 Skill
在 [`agent-completion-gate.mdc`](/Users/ray/.cursor/rules/agent-completion-gate.mdc) 增加:
- 改动 PHP 后,收尾前 **必须读取并执行** `slot-backend-completion-report` Skill
- 最终回复 **必须包含** Skill 规定的「检测结果」章节,不得只写「已完成」
### 层 3增强 verify 脚本(可选,提高自动化比例)
在 [`verify-slot-backend.sh`](/Users/ray/.cursor/hooks/verify-slot-backend.sh) 追加(仅扫 diff 新增行):
- 新增 `const` 前一行不是 `/** ... */` → FAIL
- 修复已知 bug`diff_added_lines` 在纯删除 diff 时 `grep` 退出导致脚本 silent fail你之前遇到过
这样 Hook 的 `stop` 能拦住**常量无注释**PHPDoc 方法级仍靠 Skill + Agent 报告。
---
## 不是 Skill alone也不是 Hook alone
| 你想要的效果 | 用什么 |
|--------------|--------|
| 每次写完必跑 verify | 已有 Rule + stop Hook |
| 固定格式的 Markdown 报告 | **Skill + Rule 强制引用** |
| 常量/PHPDoc 等硬规则自动 FAIL | **扩展 verify 脚本** |
| Logic 可读性、分层是否合理 | Skill 里的 §8 自查 + Agent 表格 |
---
## 你怎么用
配置完成后:
1. **默认**Agent 改 PHP 后收尾会自动读 Skill、跑脚本、出报告Rule 驱动)
2. **手动**:任意对话里说「按规范检测代码」→ Agent 读同一 Skill 即可
3. **Hook 兜底**:即使 Agent 忘了说完成,`stop` 仍会跑 verifyFAIL 会再追问一轮
---
## 实施顺序(若你确认要做)
1. 新建 `slot-backend-completion-report` Skill模板 + 命令 + 自查表)
2. 更新 `agent-completion-gate.mdc` 指向该 Skill
3. (可选)扩展 `verify-slot-backend.sh`:常量注释 + 修复 pipefail
4. (可选)项目级复制 Skill 到 `www/ray/.cursor/skills/` 便于团队共享
**工作量**Skill + Rule 约 30 分钟;脚本增强约 12 小时。

View File

@@ -0,0 +1,58 @@
<!-- 0ecc7d51-c1e6-4993-8d68-d7293c40524d -->
---
todos:
- id: "const"
content: "GameServerModel 增加 WAGERING_MODEL_TRANSPARENT=2 常量(中文注释)"
status: pending
- id: "logic-switch"
content: "GameServerLogic 增加 batchSwitchToTransparent() 批量刷透明并返回改动 source 列表"
status: pending
- id: "logic-reset"
content: "GameServerLogic 增加 resetUsersWagerTaskBySource() 按渠道分批跑用户 offTask 整理"
status: pending
- id: "command"
content: "新建 ChannelTransparent 命令,支持 --source/--dry-run编排上面两步"
status: pending
- id: "register"
content: "确认/登记 config/command.php 命令注册"
status: pending
- id: "gate"
content: "跑后端门禁脚本并附检测结果"
status: pending
isProject: false
---
# 渠道自动转透明 + 用户数据整理
## 背景结论
- 透明模式判定全系统统一为 `extend_data.wagering_model == 2``1` 或未设置 = 非透明(见 [ShareConfigService.php](slot_wallet/app/service/ShareConfigService.php) L113-118、[GameServerService.php](slot_console/app/service/GameServerService.php) L46-52
- 渠道数据在 `s_common.game_server`,由 [GameServerLogic](backend/slot_admin/app/game/logic/GameServerLogic.php) 维护,保存后 `sync()` 刷新缓存。
- 原数据整理slot_admin [Test.php](backend/slot_admin/app/command/Test.php) L39-66 按 `source` 分批捞 [UserModel](backend/slot_admin/app/model/UserModel.php),逐用户 `WalletService::offTask($uid,$currency)` → slot_wallet [SiteController::offTask](slot_wallet/app/api/controller/SiteController.php) L44-129 重算提现要求/失效历史打码任务/补建恢复任务。
## 方案(按用户确认:新建 CLI 命令;改完渠道后只对本次切换的渠道用户跑整理)
### 1. 渠道侧常量
在 [GameServerModel.php](backend/slot_admin/app/model/GameServerModel.php) 增加:
- `/** 打码模式-透明(version2) */ const WAGERING_MODEL_TRANSPARENT = 2;`
### 2. GameServerLogic 编排(两个新 public 方法,含中文 PHPDoc
- `batchSwitchToTransparent(): array`:查 `status > 0` 的全部渠道,遍历 decode `extend_data`,对 `wagering_model != 2` 的设为 2、`json_encode` 回写、调用 `sync($source)` 刷缓存;返回本次实际改动的 `source` 列表(已是 2 的跳过,天然幂等)。
- `resetUsersWagerTaskBySource(string $source): int`:复用 Test::offTask 的分批逻辑——按 `source``id > $lastId` 游标分页(1000/批)捞 `UserModel(id,currency,source)`,逐个 `WalletService::offTask($uid,$currency)`,返回处理用户数;关键节点写日志。
### 3. 新建命令 [backend/slot_admin/app/command/ChannelTransparent.php]
- `protected static $defaultName = 'channelTransparent';`
- 可选参数 `--source`(只处理指定渠道,便于灰度/测试)、`--dry-run`(只列出将变更的渠道,不写库不整理)。
- execute 流程:调用 `batchSwitchToTransparent()` 拿到改动 source 列表 → 输出列表 → 对每个 source 调 `resetUsersWagerTaskBySource()` → 输出每渠道处理用户数与总计。
- 命令为薄编排,业务逻辑全部落在 GameServerLogic。
### 4. 命令注册
确认 `config/command.php` 是否需手动登记(参照现有 `test` 命令注册方式),若需要则追加 `ChannelTransparent::class`
## 执行方式
```bash
docker exec -w /app/www/slot/backend/slot_admin php82 php webman channelTransparent --dry-run # 先预览
docker exec -w /app/www/slot/backend/slot_admin php82 php webman channelTransparent # 正式执行
```
## 注意
- 数据整理对 wallet 是逐用户同步 HTTP渠道用户多时耗时较长与现有 Test 脚本一致);保留 `--source` 可分渠道执行。
- 收尾前按门禁跑 `~/.cursor/skills/slot-backend-completion-report/scripts/report.sh` 并附检测结果。

View File

@@ -0,0 +1,118 @@
---
name: Console innerapi 架构说明
overview: 说明 slot_console 在本项目中的双重角色C 端 BFF + 活动域服务slot_sdk ConsoleClient 仅封装 `/innerapi/*` 供后端互调;若要坚持「纯 BFF 不被后端调用」,需拆分活动域或改异步回调。
todos: []
isProject: false
---
# slot_console 为何可被 slot_sdk 调用?
## 结论(先答你的疑问)
**可以给别人调,但调的不是「给 App 用的 BFF 接口」,而是同一进程里的 `/innerapi/*` 内部契约。**
在本仓库里,`slot_console` **不是纯 BFF**,而是:
| 路由前缀 | 中间件 | 谁调用 | 职责 |
| --- | --- | --- | --- |
| `/api/*` | `AuthMiddleware`JWT | **C 端 App/PWA** | BFF聚合 user/wallet/agent暴露 Lucky Rewards 等 C 端 API |
| `/innerapi/*` | `InnerAuthMiddleware` | **其它后端**agent、admin、pwa… | 活动域/内部能力:统计、邮件、游戏历史、**邀请回调** |
| `/napi/*` | `NAuthMiddleware` | 大厅等 | 非 C 端 JWT 的另一套入口 |
[`slot_sdk` 的 `ConsoleClient`](slot_sdk/src/service/console/ConsoleClient.php) 注释已写明:**「slot_console 内部 API 客户端」**,封装的是 `innerapi/...`,不是 `api/lucky-reward/spin` 这类 C 端路径。
```mermaid
flowchart LR
subgraph clients [调用方]
App[C端App]
Agent[slot_agent]
Admin[slot_admin]
end
subgraph console [slot_console]
ApiLayer["/api/* BFF"]
InnerLayer["/innerapi/* 域服务"]
Logic[LuckyRewardInviteLogic等]
end
App --> ApiLayer
Agent -->|"ConsoleClient via slot_sdk"| InnerLayer
Admin --> InnerLayer
ApiLayer --> Logic
InnerLayer --> Logic
```
---
## slot_sdk 里 console 接口是干什么的?
当前 [`ConsoleService`](slot_sdk/src/service/console/ConsoleService.php) 只有三类 **innerapi**
1. `innerapi/free-credits/statistics|list`**slot_admin** 后台统计代理
2. `innerapi/lucky-reward/invite-bind-callback`**slot_agent** 邀请绑定后回调转盘助力
**实际调用方**(仓库内):
- [`LuckyRewardInviteCallbackGatewayService`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php) → agent 调 console
- [`ConsoleAPIService`](backend/slot_admin/app/service/api/ConsoleAPIService.php) / Free Credits 统计 → admin 调 console
- [`slot_pwa` ConsoleService](slot_pwa/app/service/console/ConsoleService.php) → `innerapi/game/update-history`
C 端 **不会****不应该** 通过 slot_sdk 调 consoleC 端直接打 `consoleApiHost``/api/*`
---
## 为什么 agent 要调 console而不是反过来
这是 Lucky Rewards 需求里**刻意**定的边界(见 [01 文档](docs/requirements/lucky_rewards/01_slot_agent邀请回调对接方案.md)、[00 §1](docs/requirements/lucky_rewards/00_整体技术方案.md)
- **agent**:只管邀请关系、代理树、绑码奖励(通用裂变)
- **console**:拥有转盘业务状态(`lucky_reward_player`、Spin、helper、弹窗
- 邀请绑定成功后agent 广播「关系已建立」→ console **裁决**是否算转盘有效助力并发 Spin
所以不是「BFF 被乱调」,而是 **活动域挂在 console 上**agent 通过 innerapi 触发 console 内的 [`LuckyRewardInviteLogic::handleInviteBind()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)。
`/api``/innerapi` 可以共用同一套 Logicinvite 回调与 C 端 invite-link 都走 `LuckyRewardInviteLogic`),只是入口和鉴权不同。
---
## 「console 是 BFF」和现状如何对齐
更准确的说法:
- **对 C 端**console 的 `/api/*` 承担 BFF薄 Controller + Logic 编排 + 调 user/wallet/agent SDK
- **对活动数据**console 同时是 **活动域 owner**(表在 `s_common`,逻辑在 console历史上 Free Credits、签到、红包等同构
- **innerapi** = 把「活动域能力」暴露给 **trusted backend**,不是给浏览器/App
因此 slot_sdk 出现 `ConsoleClient` 与「console 有 BFF 职责」**不矛盾**——SDK 封装的是 **服务间** 调用,符合 [cross-service-sdk 规则](.cursor/rules/cross-service-sdk.mdc)(调用方用 slot_sdk被调方实现 innerapi Controller/Logic
---
## 若你认为「BFF 绝不能被后端调」——可选演进(非现状)
只有当你要把 console **严格降级为纯 BFF** 时,才需要改架构;代价是拆服务或改通信方式:
| 方案 | 做法 | 优缺点 |
| --- | --- | --- |
| **A. 拆 slot_activity** | 转盘域独立服务console 只转发 C 端agent 调 activity 而非 console | 边界最清晰;迁移成本高 |
| **B. MQ 替代 HTTP 回调** | agent 发 `lucky_reward_invite_bind` MQconsole 消费 | 解耦、可重试;需 MQ 契约与消费幂等 |
| **C. 维持现状** | console = BFF + 活动域innerapi 仅 trusted 服务 | **当前实现**;与现网 Free Credits innerapi 一致 |
**建议**:除非全站统一「活动全部拆独立服务」,否则 **C 方案是本项目既定模式**,不必因 slot_sdk 有 ConsoleClient 而视为设计错误。
---
## 安全与边界(现状)
- innerapi 走 [`InnerAuthMiddleware`](slot_console/app/middleware/InnerAuthMiddleware.php)(内网 + 日志;生产通常靠网络隔离 + `server-name` 头标识调用方)
- agent 回调带 `server-name: agent`(见 [deploy 文档](slot_console/doc/lucky_reward_deploy.md)
- **禁止**:在 agent/user 等业务代码里手写 `consoleApiHost + path` 字符串;应走 slot_sdk与 wallet/user 一致)
---
## 无需改代码时的认知对齐
1. **slot_sdk ConsoleClient ≠ C 端调 console**
2. **console `/api` = BFF`/innerapi` = 活动域对后端开放**
3. **agent → console** 是转盘需求文档明确的服务边界,不是临时 hack
若你后续希望 **agent 不再 HTTP 调 console**,再单独立项做方案 BMQ或方案 A拆服务当前代码与文档是一致的。

View File

@@ -0,0 +1,118 @@
---
name: Console innerapi 架构说明
overview: 说明 slot_console 在本项目中的双重角色C 端 BFF + 活动域服务slot_sdk ConsoleClient 仅封装 `/innerapi/*` 供后端互调;若要坚持「纯 BFF 不被后端调用」,需拆分活动域或改异步回调。
todos: []
isProject: false
---
# slot_console 为何可被 slot_sdk 调用?
## 结论(先答你的疑问)
**可以给别人调,但调的不是「给 App 用的 BFF 接口」,而是同一进程里的 `/innerapi/*` 内部契约。**
在本仓库里,`slot_console` **不是纯 BFF**,而是:
| 路由前缀 | 中间件 | 谁调用 | 职责 |
| --- | --- | --- | --- |
| `/api/*` | `AuthMiddleware`JWT | **C 端 App/PWA** | BFF聚合 user/wallet/agent暴露 Lucky Rewards 等 C 端 API |
| `/innerapi/*` | `InnerAuthMiddleware` | **其它后端**agent、admin、pwa… | 活动域/内部能力:统计、邮件、游戏历史、**邀请回调** |
| `/napi/*` | `NAuthMiddleware` | 大厅等 | 非 C 端 JWT 的另一套入口 |
[`slot_sdk` 的 `ConsoleClient`](slot_sdk/src/service/console/ConsoleClient.php) 注释已写明:**「slot_console 内部 API 客户端」**,封装的是 `innerapi/...`,不是 `api/lucky-reward/spin` 这类 C 端路径。
```mermaid
flowchart LR
subgraph clients [调用方]
App[C端App]
Agent[slot_agent]
Admin[slot_admin]
end
subgraph console [slot_console]
ApiLayer["/api/* BFF"]
InnerLayer["/innerapi/* 域服务"]
Logic[LuckyRewardInviteLogic等]
end
App --> ApiLayer
Agent -->|"ConsoleClient via slot_sdk"| InnerLayer
Admin --> InnerLayer
ApiLayer --> Logic
InnerLayer --> Logic
```
---
## slot_sdk 里 console 接口是干什么的?
当前 [`ConsoleService`](slot_sdk/src/service/console/ConsoleService.php) 只有三类 **innerapi**
1. `innerapi/free-credits/statistics|list`**slot_admin** 后台统计代理
2. `innerapi/lucky-reward/invite-bind-callback`**slot_agent** 邀请绑定后回调转盘助力
**实际调用方**(仓库内):
- [`LuckyRewardInviteCallbackGatewayService`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php) → agent 调 console
- [`ConsoleAPIService`](backend/slot_admin/app/service/api/ConsoleAPIService.php) / Free Credits 统计 → admin 调 console
- [`slot_pwa` ConsoleService](slot_pwa/app/service/console/ConsoleService.php) → `innerapi/game/update-history`
C 端 **不会****不应该** 通过 slot_sdk 调 consoleC 端直接打 `consoleApiHost``/api/*`
---
## 为什么 agent 要调 console而不是反过来
这是 Lucky Rewards 需求里**刻意**定的边界(见 [01 文档](docs/requirements/lucky_rewards/01_slot_agent邀请回调对接方案.md)、[00 §1](docs/requirements/lucky_rewards/00_整体技术方案.md)
- **agent**:只管邀请关系、代理树、绑码奖励(通用裂变)
- **console**:拥有转盘业务状态(`lucky_reward_player`、Spin、helper、弹窗
- 邀请绑定成功后agent 广播「关系已建立」→ console **裁决**是否算转盘有效助力并发 Spin
所以不是「BFF 被乱调」,而是 **活动域挂在 console 上**agent 通过 innerapi 触发 console 内的 [`LuckyRewardInviteLogic::handleInviteBind()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)。
`/api``/innerapi` 可以共用同一套 Logicinvite 回调与 C 端 invite-link 都走 `LuckyRewardInviteLogic`),只是入口和鉴权不同。
---
## 「console 是 BFF」和现状如何对齐
更准确的说法:
- **对 C 端**console 的 `/api/*` 承担 BFF薄 Controller + Logic 编排 + 调 user/wallet/agent SDK
- **对活动数据**console 同时是 **活动域 owner**(表在 `s_common`,逻辑在 console历史上 Free Credits、签到、红包等同构
- **innerapi** = 把「活动域能力」暴露给 **trusted backend**,不是给浏览器/App
因此 slot_sdk 出现 `ConsoleClient` 与「console 有 BFF 职责」**不矛盾**——SDK 封装的是 **服务间** 调用,符合 [cross-service-sdk 规则](.cursor/rules/cross-service-sdk.mdc)(调用方用 slot_sdk被调方实现 innerapi Controller/Logic
---
## 若你认为「BFF 绝不能被后端调」——可选演进(非现状)
只有当你要把 console **严格降级为纯 BFF** 时,才需要改架构;代价是拆服务或改通信方式:
| 方案 | 做法 | 优缺点 |
| --- | --- | --- |
| **A. 拆 slot_activity** | 转盘域独立服务console 只转发 C 端agent 调 activity 而非 console | 边界最清晰;迁移成本高 |
| **B. MQ 替代 HTTP 回调** | agent 发 `lucky_reward_invite_bind` MQconsole 消费 | 解耦、可重试;需 MQ 契约与消费幂等 |
| **C. 维持现状** | console = BFF + 活动域innerapi 仅 trusted 服务 | **当前实现**;与现网 Free Credits innerapi 一致 |
**建议**:除非全站统一「活动全部拆独立服务」,否则 **C 方案是本项目既定模式**,不必因 slot_sdk 有 ConsoleClient 而视为设计错误。
---
## 安全与边界(现状)
- innerapi 走 [`InnerAuthMiddleware`](slot_console/app/middleware/InnerAuthMiddleware.php)(内网 + 日志;生产通常靠网络隔离 + `server-name` 头标识调用方)
- agent 回调带 `server-name: agent`(见 [deploy 文档](slot_console/doc/lucky_reward_deploy.md)
- **禁止**:在 agent/user 等业务代码里手写 `consoleApiHost + path` 字符串;应走 slot_sdk与 wallet/user 一致)
---
## 无需改代码时的认知对齐
1. **slot_sdk ConsoleClient ≠ C 端调 console**
2. **console `/api` = BFF`/innerapi` = 活动域对后端开放**
3. **agent → console** 是转盘需求文档明确的服务边界,不是临时 hack
若你后续希望 **agent 不再 HTTP 调 console**,再单独立项做方案 BMQ或方案 A拆服务当前代码与文档是一致的。

View File

@@ -0,0 +1,107 @@
---
name: Console Redis 在线推送
overview: 不建议在 slot_game 新增「用户是否在线」接口;邀请成功弹窗的在线判断应放在 slot_console复用 EventBus 已维护的 Redis 在线集合,在 PendingPopupService 入队后「在线才 WS 即时推送、离线靠 HomeEvent 补弹」。
todos:
- id: user-online-service
content: 新增 UserOnlineService::isUserOnline读 Redis getUserOnlineKey+ 单测
status: completed
- id: pending-popup-online-gate
content: PendingPopupService::enqueue 在线才 notifyClientPOP离线仅持久化
status: completed
- id: docs-verify-online
content: 更新 07 文档 enqueue 说明;跑 verify + 相关单测
status: in_progress
isProject: false
---
# 在线判断方案:不建议 slot_game采用 slot_console Redis
## 结论(对你问题的直接回答)
**不建议在 [`slot_game`](slot_game) 新增控制器做在线判断。**
| 服务 | 职责 | 是否适合判断「大厅弹窗在线」 |
|------|------|------------------------------|
| **slot_game** | 游戏 WebSocket 网关spin/bet/进游戏) | 否 — 用户在大厅连的是 **slot_hub**,不一定连 slot_game |
| **slot_hub** | 大厅 WS + `Gateway::isUidOnline($uid)`(见 [`BaseLogic`](slot_hub/plugin/slot/hub/logic/BaseLogic.php) | 最准,但需跨服务 HTTP/RPC07 文档一期未接 |
| **slot_console** | 弹窗入队、EventBus 维护 Redis 在线集合 | **合适** — 数据已有,无跨服务调用 |
当前邀请成功链路(已实现):
```mermaid
sequenceDiagram
participant Logic as LuckyRewardInviteLogic
participant Popup as PendingPopupService
participant Redis as Redis_online_set
participant WS as WsService_MQ
participant Home as HomeEvent
Logic->>Popup: enqueue 邀请人弹窗
Popup->>Popup: 写 user_pending_popup
Popup->>WS: notifyClientPOP当前始终发送
Note over Home: 离线/游戏中靠 home flush 补弹
```
你已选择 **slot_console + Redis** 方案:与 [07 待弹窗方案](docs/requirements/lucky_rewards/07_用户定向弹窗通知机制方案.md) 一致 — **持久化必做WS 仅 best-effort**
---
## 推荐实现(小改动,不改 slot_game
### 1. 封装在线查询slot_console
在 [`RedisKeyManagerService`](slot_console/app/service/RedisKeyManagerService.php) 或新建 `UserOnlineService`(公共能力,非 Logic 型业务编排):
```php
public static function isUserOnline(int $uid): bool
{
if ($uid <= 0) {
return false;
}
return (bool) Redis::sIsMember(self::getUserOnlineKey(), (string) $uid);
}
```
数据来源:[`EventBus::websocketConnectEvent`](slot_console/app/command/EventBus.php) / `websocketUnconnectEvent` 已对 `getUserOnlineKey()``sAdd` / `sRem`
**语义说明**Redis 在线 = **有 WebSocket 连接**,不等于「一定在大厅」;游戏内是否弹仍由**客户端忽略 WS** + **HomeEvent 仅非 enter_game 时 flush** 保证PRD 4.2)。
### 2. 调整 [`PendingPopupService::enqueue`](slot_console/app/service/PendingPopupService.php)
- **不变**:幂等写 `user_pending_popup`(离线也必须写,否则丢通知)
- **变更**:仅当 `isUserOnline($uid)` 为 true 时调用 `WsService::notifyClientPOP`
- 离线:跳过 WS等 [`HomeEvent::appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php) 补弹
```php
if (UserOnlineService::isUserOnline($enqueueDto->uid)) {
WsService::notifyClientPOP(...);
}
```
此改动作用于**所有**走 `PendingPopupService` 的定向弹窗(含邀请成功 `lucky_reward_invite_success`),符合 F 通用机制。
### 3. 测试
- 单元测:`isUserOnline` 对 Redis mock 或集成测 sAdd/sIsMember
- 可选:集成测 invite_bind 成功后 pending 记录存在;在线/离线 WS 行为(若难 mock MQ至少单测 PendingPopupService 分支)
### 4. 文档
- [07 用户定向弹窗通知机制方案](docs/requirements/lucky_rewards/07_用户定向弹窗通知机制方案.md) §4 `enqueue` 说明补一句:**即时 WS 仅在 Redis 在线集合命中时发送**
- [`LuckyRewardInviteLogic`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 的 `notifyInviterInviteSuccess` PHPDoc 注明依赖 PendingPopupService 在线 gateLogic 内**不再**单独查在线
---
## 若将来需要 HTTP「查在线」给其他服务
- 优先 **slot_console `innerapi`**(如 `GET /innerapi/user/is-online?uid=`),读同一 Redis 集合
-**slot_hub innerapi**`Gateway::isUidOnline`(需 GatewayClient 配置,跨进程)
- **仍不建议 slot_game** — 除非明确要查「是否在游戏 WS 连接中」(与大厅弹窗无关)
---
## 不在本次范围
- slot_game 新 Controller
- slot_hub Gateway 跨服务封装(除非 Redis 与 Gateway 长期不一致再评估)
- 区分「大厅 vs 游戏中」的服务端精确状态(仍靠客户端 + HomeEvent `from`

View File

@@ -0,0 +1,192 @@
---
name: Free Credits 部署文档
overview: 为「后续档瀑布拆分 + 每档新增累计充值解锁」编写上线部署文档,覆盖 DB 迁移、服务发布顺序、存量兼容、验收与回滚。
todos:
- id: write-deploy-md
content: 确认后将本文档写入 slot_console/doc/FreeCredits瀑布分档部署说明.md
status: completed
- id: commit-and-pr
content: slot_console / slot_admin / slot_admin_vue 提交并提 PR merge
status: pending
- id: run-db-migrate
content: 各环境先执行 migrate_free_credits_release_tiers.sql 再发 console
status: pending
- id: post-deploy-verify
content: 按 §6 验收新定格 + 存量 grandfather + 后台配置
status: pending
isProject: false
---
# Free Credits 瀑布分档 — 部署文档
## 1. 变更摘要
| 项 | 说明 |
|---|---|
| 需求 | 首充定格后,剩余金额按 10/20/30/50/100 瀑布拆分;后续档按「每档新增累计充值」解锁 |
| 影响服务 | **slot_console**(核心)、**slot_admin** + **slot_admin_vue**(后台配置文案/校验) |
| 不影响 | slot_wallet定格/冻结/Claim、slot_pay第一档提现、第一档 $20 / 累计 $50 解锁规则 |
| 分支 | `fix/upActivity`(待 merge |
---
## 2. 发布前检查
- [ ] 代码已 merge 到目标分支并通过 CI
- [ ] 单测已通过:
```bash
docker exec -w /app/www/slot/slot_console php82 \
./vendor/bin/phpunit tests/Unit/FreeCreditsLogicAmountTest.php \
tests/Unit/FreeCreditsAdvanceUnlockTest.php
```
- [ ] 确认线上是否存在**在途 Free Credits 用户**(已定格、未完成全部档位)
- [ ] 与产品确认:存量用户走 grandfather`unlock_recharge_qf=0` 仍用旧单笔解锁规则)
---
## 3. 数据库迁移(必须先于代码)
**库**`s_common`
**脚本**[`slot_console/db/migrate_free_credits_release_tiers.sql`](slot_console/db/migrate_free_credits_release_tiers.sql)
```sql
ALTER TABLE s_common.free_credits_package
ADD COLUMN unlock_recharge_qf bigint unsigned NOT NULL DEFAULT 0
COMMENT '解锁本档所需新增累计真实充值(千分位),自上一档解锁后起算'
AFTER amount_qf;
ALTER TABLE s_common.free_credits_player
ADD COLUMN recharge_baseline_qf bigint unsigned NOT NULL DEFAULT 0
COMMENT '后续档计数起点wallet.totalRecharge 快照(千分位)'
AFTER first_cash_amount_qf;
```
### 3.1 执行方式
```bash
# 示例:容器内 MySQL
docker exec -i goMysql mysql -uroot -p<password> < migrate_free_credits_release_tiers.sql
```
### 3.2 迁移后校验
```sql
-- 列存在且默认 0
SHOW COLUMNS FROM s_common.free_credits_package LIKE 'unlock_recharge_qf';
SHOW COLUMNS FROM s_common.free_credits_player LIKE 'recharge_baseline_qf';
-- 存量数据应为 0旧规则兼容
SELECT COUNT(*) FROM s_common.free_credits_package WHERE unlock_recharge_qf != 0;
-- 上线前应为 0新定格用户上线后才会有非 0 值
```
### 3.3 注意事项
- **先 DDL、后发代码**:新代码会读写 `unlock_recharge_qf` / `recharge_baseline_qf`;缺列会导致定格/解锁失败
- 新装环境直接用 [`slot_console/db/install.sql`](slot_console/db/install.sql)(已含两列)
- 迁移**幂等**:若列已存在会报错,勿重复执行;可用 `information_schema` 先查再执行
---
## 4. 代码发布顺序
```mermaid
flowchart LR
db[1_DB迁移]
console[2_slot_console]
admin[3_slot_admin]
vue[4_slot_admin_vue]
db --> console --> admin --> vue
```
| 顺序 | 仓库 | 变更要点 |
|---|---|---|
| 1 | DB | 执行 §3 迁移 |
| 2 | **slot_console** | `FreeCreditsLogic` 瀑布拆分 + 增量解锁Model`install.sql` |
| 3 | **slot_admin** | `ActivityValidate::checkFreeCreditsExt` — `package_amount` / `subsequent_min_recharge` 改为可选 |
| 4 | **slot_admin_vue** | 活动编辑页废弃提示列表页展示「10/20/30/50/100 瀑布」 |
**无需重启/发布**slot_wallet、slot_pay、slot_pwa本次无接口字段变更C 端 `unlock_recharge_amount` 为可选增强,未做)
---
## 5. 存量用户兼容Grandfather
| 用户类型 | 拆分规则 | 解锁规则 |
|---|---|---|
| **上线前已定格** | 保持原档位行不变 | `unlock_recharge_qf=0` → 仍按 `subsequent_min_recharge` **单笔**解锁 |
| **上线后新定格** | `buildReleasePackages()` 瀑布拆分 | 按 `unlock_recharge_qf` + `recharge_baseline_qf` 增量累计解锁 |
无需对存量数据做 backfill。
若运营希望存量也切新规则,需单独产品决策 + 数据修复脚本(**不在本次范围**)。
---
## 6. 上线后验收
### 6.1 新用户定格(核心)
1. 测试账号:注册 → 赢取门槛 → 首充定格(如定格 $78.50,第一档 $20
2. 查库:
```sql
SELECT package_no, amount_qf, unlock_recharge_qf, status
FROM s_common.free_credits_package
WHERE player_id = ?
ORDER BY package_no;
```
3. 期望 release 档:`10000×3, 20000, 8500`unlock`9000, 9000, 9000, 19000, 8000`
4. 查 player`recharge_baseline_qf` = 定格时 wallet 累计充值
### 6.2 解锁链路
1. 累计充满 $50 → 第一档变 ready规则未变
2. 第一档解锁后 `recharge_baseline_qf` 更新为当前 totalR
3. 再新增累计 $9 → 第一个 $10 release 档变 readybaseline 再次前移
4. 尾档:面额 $8.50 时 unlock 为 $88000 qf
### 6.3 后台
- 新建/编辑 Free Credits 活动:可不填「解锁拆分金额」「后续解锁最小充值」
- 活动列表显示「分档规则: 10/20/30/50/100 瀑布(新定格)」
### 6.4 回归
- 存量用户(`unlock_recharge_qf=0`)单笔充值解锁仍正常
- Claim 入账、第一档提现、金额守恒 `sum(release) = frozen - first_cash` 不变
---
## 7. 回滚方案
| 场景 | 操作 |
|---|---|
| **代码有问题、DB 已迁移** | 回滚 slot_console 到上一版本;**保留 DB 新列**(默认 0旧代码忽略即可 |
| **仅后台有问题** | 单独回滚 slot_admin / slot_admin_vue |
| **误发新代码但未迁移** | 立即补跑 §3 迁移,或回滚代码 |
**不建议** DROP 新列(除非确认无新定格用户且已回滚代码)。
---
## 8. 监控与告警
上线后关注:
- slot_console 日志Free Credits 定格失败、解锁异常
- 业务指标:新定格用户的 package 行数分布(应与瀑布规则一致,不再全是 $10 均分)
- 客诉:「再充多少解锁下一档」— 当前 C 端未返回 `unlock_recharge_amount`,需 help 文案说明
---
## 9. 相关文档
- 需求:[`docs/requirements/首充前免费余额定格与分档释放需求文档.md`](docs/requirements/首充前免费余额定格与分档释放需求文档.md) §5.7、§5.8
- 实现计划:[首充剩余分档规则](/Users/ray/.cursor/plans/首充剩余分档规则_509262bd.plan.md)
---
## 10. 落盘路径
[`slot_console/doc/FreeCredits瀑布分档部署说明.md`](slot_console/doc/FreeCredits瀑布分档部署说明.md)
同目录已有 [`slot_console/doc/首充前免费余额定格与分档释放需求部署说明.md`](slot_console/doc/首充前免费余额定格与分档释放需求部署说明.md)(首版上线 checklist本文档专述**瀑布分档 + 增量解锁**增量变更,二者并存。

View File

@@ -0,0 +1,160 @@
---
name: InviteBind 规范与弹窗
overview: 重构 `LuckyRewardInviteLogic::handleInviteBind` 以符合 php-clean-code 分层写法并在有效助力成功时可靠入队邀请人「Claim Now」弹窗在线 WS 即时推送 + 离线/游戏中 HomeEvent 补弹),对齐 PRD 4.2 与子需求 F/D 文档。
todos:
- id: invite-bind-context-dto
content: 新增 InviteBindProcessContextDTOfetchUserProfile 改为返回 LuckyRewardUserProfileEntity
status: completed
- id: refactor-handle-invite-bind
content: 拆分 handleInviteBind 为编排 + private 步骤(校验/上下文/事务/弹窗/日志)
status: completed
- id: inviter-popup-prd-42
content: notifyInviterInviteSuccess成功必入队补全 PRD 4.2 payload去掉 spin_reward>0 早退
status: completed
- id: tests-docs-verify
content: 增强 InviteBind 集成测 payload 断言;更新 05 文档;跑 verify + phpunit
status: completed
isProject: false
---
# LuckyRewardInviteLogic handleInviteBind 重构与邀请人弹窗
## 现状与问题
[`handleInviteBind`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 约 160 行,单方法内堆叠:参数校验、活动/轮次解析、幂等判断、事务写 helper/grant/player、弹窗入队、日志。
**编码规范偏差(对照 `php-clean-code`**
| 问题 | 现状 |
|------|------|
| 方法过长 | `handleInviteBind` 远超 50 行建议 |
| 编排不清晰 | 校验 / 发奖 / 弹窗混在一个方法 |
| 返回值不规范 | `fetchUserProfile()` 返回 `array{nickname,avatar}`,同模块已有 [`LuckyRewardUserProfileEntity`](slot_console/app/entity/luckyReward/LuckyRewardUserProfileEntity.php)[`LuckyRewardLogic`](slot_console/app/api/logic/LuckyRewardLogic.php) 已使用) |
| 弹窗条件偏窄 | [`enqueueInviterSuccessPopup`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 在 `inviterSpinReward <= 0` 时直接 return有效助力成功但配置为 0 时不入队 |
**弹窗能力已存在但未在编排层显式表达:**
- 成功路径已调用 `enqueueInviterSuccessPopup` → [`PendingPopupService::enqueue`](slot_console/app/service/PendingPopupService.php) → 写 `user_pending_popup` + [`WsService::notifyClientPOP`](slot_console/app/service/WsService.php)(在线即时推送)
- 离线/游戏中:`HomeEvent::appendLuckyRewardHomePopups` 从待弹队列补弹([`05 邀请方案 §5`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md)、[`07 待弹窗中心`](docs/requirements/lucky_rewards/07_用户定向弹窗通知机制方案.md)
- 集成测已断言 pending 记录存在([`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php)),但未覆盖「成功必入队 + payload 结构」
PRD [4.2 邀请成功](docs/requirements/转盘活动-功能需求文档.md):被邀请人注册成功后,**邀请人**收到 LUCKY YOU / 1 Free Spin / Claim Now仅回大厅弹、游戏内禁弹由客户端忽略 WS + HomeEvent flush 保证)。
```mermaid
sequenceDiagram
participant Agent as slot_agent_MQ
participant Logic as handleInviteBind
participant DB as s_common
participant Popup as PendingPopupService
participant WS as WsService
participant Home as HomeEvent
Agent->>Logic: invite_bind callback
Logic->>DB: helper + grant + player
Logic->>Popup: enqueue inviter popup
Popup->>DB: user_pending_popup
Popup->>WS: notifyClientPOP
Note over WS: 在线大厅即时弹
Home->>Popup: fetchPending on home
Popup->>WS: 补弹离线/回大厅
```
---
## 目标结构
### `handleInviteBind` 编排public读流程
```php
public function handleInviteBind(InviteBindCallbackDTO $callbackDto): InviteBindResultEntity
{
$earlyResult = $this->resolveInviteBindSkipResult($callbackDto);
if ($earlyResult !== null) {
return $earlyResult;
}
$processContext = $this->resolveInviteBindProcessContext($callbackDto);
if ($processContext instanceof InviteBindResultEntity) {
return $processContext; // activity closed 等
}
if ($this->isInviteHelperAlreadyProcessed($processContext->cycleId, $callbackDto->inviteeUid)) {
return $this->buildInviteBindResult(STATUS_ALREADY_PROCESSED, ...);
}
$this->persistInviteHelperInTransaction($callbackDto, $processContext);
$this->notifyInviterInviteSuccess($callbackDto, $processContext);
$this->logInviteBindSuccess($callbackDto, $processContext);
return $this->buildInviteBindResult(STATUS_SUCCESS, ...);
}
```
### 新增编排上下文 DTO
[`InviteBindProcessContextDTO`](slot_console/app/dto/luckyReward/InviteBindProcessContextDTO.php)readonly承载本用例多步共享字段避免 private 方法重复挂标量:
- `resolvedSource`, `cycleId`, `inviterSpinReward`, `inviteeSpinReward`
- `inviterBizId`, `inviteeBizId`, `popupBizId`
### 拆分 private 方法(均有中文 PHPDoc
| 方法 | 职责 |
|------|------|
| `resolveInviteBindSkipResult` | 参数非法 / 自邀 / 老用户 / 非 wheel 来源 → early return Entity |
| `resolveInviteBindProcessContext` | 解析活动配置与活跃轮次;失败返回 `InviteBindResultEntity` |
| `isInviteHelperAlreadyProcessed` | 封装 `LuckyRewardHelperModel::findByCycleAndInvitee` |
| `persistInviteHelperInTransaction` | 事务:写 helper、发 inviter/invitee grant、更新 player |
| `createInviteHelperRecord` | 写 helper 行 |
| `grantInviterSpinForHelper` | inviter grant + player spin/valid_invite_count |
| `grantInviteeSpinForHelper` | invitee grant + player spin |
| `notifyInviterInviteSuccess` | **PRD 4.2**:有效助力成功后入队 + WS委托 `PendingPopupService` |
| `logInviteBindSuccess` | 成功业务日志 |
### 弹窗行为调整PRD 4.2
- **成功即入队**`notifyInviterInviteSuccess``STATUS_SUCCESS` 路径**始终**调用(去掉 `inviterSpinReward <= 0` 早退);`payload.spin_reward` 仍传配置值(可为 0
- **payload 对齐前端**(在现有字段基础上补全,不破坏已对接字段):
- 保留:`invitee_uid`, `invitee_nickname`, `invitee_avatar`, `spin_reward`, `cycle_id`
- 新增PRD 文案,便于客户端少写死):`title`=`LUCKY YOU!`, `content`=`You've received 1 Free Spin`, `button_text`=`Claim Now`, `priority`
- **在线推送**:继续复用 `PendingPopupService::enqueue`(内部已 WSLogic 不重复调 `WsService`
- **离线/游戏中**:依赖已有 `user_pending_popup` + `HomeEvent::appendLuckyRewardHomePopups`,无需 Logic 判断在线
### 用户资料返回 Entity
- `fetchUserProfile(int $uid): LuckyRewardUserProfileEntity` 替换 array 返回
- `enqueueInviterSuccessPopup` / `buildSupportInvitePopup` 同步改用 Entity 字段(后者可顺带改,范围小)
---
## 测试
| 用例 | 文件 |
|------|------|
| 现有 early-return 单测 | [`LuckyRewardInviteLogicUnitTest`](slot_console/tests/Unit/LuckyRewardInviteLogicUnitTest.php) 保持 |
| 集成:成功写 helper + pending popup | 已有;**增强**断言 `popup_type``payload``title/content/spin_reward` |
| 集成:第二次回调幂等不入队新 popup | 已有 `already_processed`;确认 popup 仍仅 1 条 |
运行:`RUN_DB_TESTS=1``LuckyRewardInviteBindIntegrationTest` + 现有 Unit 测。
---
## 文档
- 更新 [`docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §5 payload 示例(若新增 title/content 字段)
- 无需改 plan 文件
---
## 不在本次范围
- `buildSupportInvitePopup` 改返回 Entity可选后续本次仅改其用到的 `fetchUserProfile`
- 服务端精确判断「是否在大厅/是否在线」07 文档一期不做,依赖 WS + home flush
- slot_hub / 客户端弹窗 UI 实现
---
## 自检
- `verify-slot-backend.sh` + docker `php -l`
- 对照 Logic §3/§8方法长度、中文 PHPDoc、无 Logic 返回 array、编排 DTO 不双份拷贝

View File

@@ -0,0 +1,279 @@
<!-- 09c08dbd-5e66-4d08-b29e-090a18ddb10d -->
---
todos:
- id: "user-is-new-user"
content: "slot-userAbstractRegisterService 增加 isNewUser 标志Mobile/Imei/Name RegisterService 赋值UserController::register 返回 is_new_user"
status: pending
- id: "console-ggame-query"
content: "slot-consoleGGame 新增按 game_code 查可展示单条游戏方法"
status: pending
- id: "console-recommend-logic"
content: "slot-consoleOnboardingRecommendLogiccenter 取池 → 随机 → g_game 组装 → 降级)"
status: pending
- id: "console-recommend-api"
content: "slot-consoleOnboardingController + GET /api/onboarding/recommend-game"
status: pending
- id: "update-docs"
content: "更新 register_popup_game_entry 文档:前端 is_new_user 条件请求 recommend-game 链路"
status: pending
- id: "lobby-auth-types"
content: "lobbyAuthResponse 增加 is_new_userauth.service 注册后按标志决定是否拉 recommend-game"
status: pending
- id: "lobby-onboarding-api"
content: "lobby新增 api/console/onboarding.api.ts 调用 GET /slot-console/api/onboarding/recommend-game"
status: pending
- id: "lobby-onboarding-popup"
content: "lobby新用户注册成功后展示 OnboardingGamePopup倒计时 + LET'S PLAY + 关闭)"
status: pending
isProject: false
---
# is_new_user + console recommend-game 方案
## 方案评价
该方案**推荐采用**,理由:
- 符合 SSOT [`register_popup_game_entry.md`](docs/requirements/register_popup_game_entry.md) 服务边界user 管身份console 管展示聚合
- 前端仍直连 `slot-user` 注册,无需改注册入口
- `MobileRegisterService` / `ImeiRegisterService` 已有「账号存在则走登录」逻辑,正好需要 `is_new_user` 区分
- 老用户误走 register 接口时不会触发 onboarding 弹窗
- 注册主链路不受 center/console 降级影响(推荐游戏失败只影响弹窗)
```mermaid
sequenceDiagram
participant FE as lobby_PWA
participant User as slot_user
participant Console as slot_console
participant Center as slot_center
participant GGame as g_game
FE->>User: POST register
alt 新用户创建
User-->>FE: token + uid + is_new_user=true
FE->>Console: GET recommend-game (Bearer token)
Console->>Center: innerapi onboarding/channel-game
Center-->>Console: game_codes
Console->>GGame: 按 game_code 查可展示游戏
Console-->>FE: onboarding payload
else 老用户登录回退
User-->>FE: token + uid + is_new_user=false
Note over FE: 不请求 recommend-game
end
```
---
## Part 1slot-user 增加 `is_new_user`
**涉及仓库**[`/Users/ray/Documents/project/www/slot/slot_user`](/Users/ray/Documents/project/www/slot/slot_user)
### 现状
[`MobileRegisterService.php`](/Users/ray/Documents/project/www/slot/slot_user/app/service/register/MobileRegisterService.php) 与 [`ImeiRegisterService.php`](/Users/ray/Documents/project/www/slot/slot_user/app/service/register/ImeiRegisterService.php) 在 `check()` 返回 false 时会走 `MobileLoginService` / `ImeiLoginService`,但 Controller 无法区分「新建」与「登录回退」。
[`NameRegisterService.php`](/Users/ray/Documents/project/www/slot/slot_user/app/service/register/NameRegisterService.php) 账号已存在直接返回 null报错成功则一定是新用户。
### 实现方式(最小改动)
在 [`AbstractRegisterService.php`](/Users/ray/Documents/project/www/slot/slot_user/app/service/register/AbstractRegisterService.php) 增加:
```php
/** 本次 register 请求是否真正创建了新用户false 表示走了登录回退) */
public bool $isNewUser = false;
```
各 RegisterService 在对应分支赋值:
| 类型 | 新用户创建 | 登录回退 |
|------|-----------|---------|
| mobile | `isNewUser = true` | `isNewUser = false` |
| imei | `isNewUser = true` | `isNewUser = false` |
| name | `isNewUser = true` | N/A失败返回 null |
在 [`UserController::register()`](`/Users/ray/Documents/project/www/slot/slot_user/app/api/controller/UserController.php) 响应中追加:
```php
$data['is_new_user'] = $registerService->isNewUser;
```
**login 接口不改**(老用户走 login 本就不弹窗)。
### 注册响应示例
```json
{
"token": "...",
"uid": 1387459,
"expires_in": 1234567890,
"is_new_user": true
}
```
---
## Part 2slot-console 实现 recommend-game BFF03 子需求)
**涉及仓库**[`/Users/ray/Documents/project/www/ray/slot-console`](/Users/ray/Documents/project/www/ray/slot-console)
### 路由
沿用 Webman 自动路由(参照 [`RewardGrantController`](slot-console/app/api/controller/RewardGrantController.php)
```text
GET /api/onboarding/recommend-game
```
需登录JWT`$request->userEntity->uid` / `$request->userEntity->source` 取用户上下文。
### 核心 Logic新建
`app/api/logic/OnboardingRecommendLogic.php` 编排步骤(对齐需求 §5.1
1.`uid``source`source 为空按 `DEFAULT` 交给 center 回退)
2.`CenterClient``onboardingChannelGame($source)`SDK 已有:[`CenterService.php`](slot_sdk/src/service/center/CenterService.php)
3. `enabled=false``game_codes` 为空 → 返回降级 payload
4.`game_codes` **简单随机**选一个;查 [`GGame`](slot-console/app/model/common/GGame.php) 可展示游戏
5. 不可用则剔除后继续随机;全部不可用 → `enabled=false`
6. 组装返回(字段见需求 §6.1
### GGame 扩展
在 [`GGame.php`](slot-console/app/model/common/GGame.php) 新增按 `game_code` 查单条可展示游戏方法(复用 `applyLobbyListableJoinsAndWhere`,返回 `id/name/icon_url/provider_code/game_code/category`)。
可参照 slot-admin [`OnboardingChannelGameLogic::resolveGamesByGameCodes`](slot-admin/app/game/logic/OnboardingChannelGameLogic.php) 的字段映射,但用户侧只返回需求文档定义的 `game` 结构。
### GatewayService可选薄封装
`app/service/center/CenterGatewayService.php`:封装 `CenterClient` 初始化host 来自 `ShareConfigService::get('centerApiHost')`header `server-name`Logic 不直接拼 HTTP。
### Controller新建
`app/api/controller/OnboardingController.php`
- `recommendGame(Request $request)` → 调 Logic → `success($payload)`
- center 调用失败 / 异常:记录日志,返回 `enabled=false`**不抛错阻断**
### 展示字段来源
| 字段 | 来源 |
|------|------|
| `uid` / `player_number` | JWT uid |
| `player_number_display` | `number_format($uid)` |
| `platform_name` | `ShareConfigService::sourceInfo($source)` 或 center 渠道配置(首版可用 `config`/常量,与运营名对齐) |
| `trial_bonus_amount` | 活动配置常量 `20000`$20 × 1000对齐 [`03_wallet_fund_flow.md`](docs/requirements/trial_withdrawal/03_wallet_fund_flow.md) |
| `countdown_seconds` | 有游戏时 `3`,无游戏时 `0` |
| `game` | GGame 查询结果 |
---
## Part 3lobby 前端接入(游戏大厅 PWA
**涉及仓库**[`/Users/ray/Documents/project/www/ray/lobby`](/Users/ray/Documents/project/www/ray/lobby)
这就是你的前端应用:**Vue 3 + Vite + Vant**,网关前缀通过 `VITE_APP_API_BASE_URL` 代理到各 slot 服务。
### 现有注册链路(已确认)
| 项 | 位置 |
| --- | --- |
| 注册 API | [`lobby/src/api/user/auth.api.ts`](lobby/src/api/user/auth.api.ts) → `POST /slot-user/api/user/register` |
| 注册编排 | [`lobby/src/services/user/auth.service.ts`](lobby/src/services/user/auth.service.ts) → `setAuth(result)` 存 token |
| 类型定义 | [`lobby/src/types/user/auth.d.ts`](lobby/src/types/user/auth.d.ts)(目前只有 token 字段,**无** `is_new_user` |
| Console API 范例 | [`lobby/src/api/console/console.ts`](lobby/src/api/console/console.ts) → `/slot-console/api/...` |
| 注册入口(需统一改) | [`telregisterbox.vue`](lobby/src/pages/sign-in/components/telregisterbox.vue)、[`emailregisterbox.vue`](lobby/src/pages/sign-in/components/emailregisterbox.vue)、[`login/index.vue`](lobby/src/pages/login/index.vue)、[`auth/telegram-callback/index.vue`](lobby/src/pages/auth/telegram-callback/index.vue)、[`register/index.vue`](lobby/src/pages/register/index.vue) |
典型流程(以手机注册为例):
```text
userAuthService.register(...) → setAuth → initializeUserSession() → router.push('/')
```
弹窗现有模式可参考 WS `Notify.popup`[`notifyPopupPayload.ts`](lobby/src/utils/notifyPopupPayload.ts) + `DailySignInBox.vue`),但 onboarding **不走 WS**,注册成功后 **HTTP 主动拉取**
### lobby 改动点
**1. 类型与 API**
- `AuthResponse` 增加 `is_new_user?: boolean``uid?: number`
- 新建 `lobby/src/types/console/onboarding.d.ts`(对齐需求 §6.1 payload
- 新建 `lobby/src/api/console/onboarding.api.ts`
```ts
GET /slot-console/api/onboarding/recommend-game
```
**2. 注册编排(集中在一处,避免各页面重复)**
扩展 [`auth.service.ts`](lobby/src/services/user/auth.service.ts)
```text
register(data):
1. result = registerAPI(data)
2. setAuth(result)
3. if result.is_new_user === true:
onboarding = await fetchRecommendGame() // 已 setAuthrequest 拦截器会自动带 Authorization
return { ...result, onboarding }
4. return result
```
recommend-game 失败时 **不阻断注册**`onboarding.enabled = false`,正常进首页。
**3. 弹窗 UI**
新建组件如 `lobby/src/components/OnboardingGamePopup.vue`(或 `pages/home/components/`
- `enabled=true` 时展示:玩家编号、`trial_bonus_amount`、游戏封面、`countdown_seconds` 倒计时
- LET'S PLAY → 用 `game.id` 走现有进游戏链路([`game.api.ts`](lobby/src/api/game/game.api.ts) / 首页 launch 逻辑)
- 关闭 → 仅关弹窗,不调后端
**4. 触发时机**
在注册成功且 `is_new_user` 的分支里,**跳转首页前或首页 onMounted** 展示弹窗(推荐:注册页拿到 onboarding 后 `router.push('/')` + 通过 pinia/commonStore 或 route state 传给首页展示,避免路由切换丢状态)。
各注册入口最终都应走 `userAuthService.register`,无需每个页面单独写 recommend 逻辑。
### 前端调用链路lobby
```text
1. lobby → POST /slot-user/api/user/register
2. is_new_user === true
lobby → GET /slot-console/api/onboarding/recommend-game
enabled=true → OnboardingGamePopup
3. is_new_user === false → 正常登录,无弹窗
4. initializeUserSession() → 进大厅首页 /
```
---
## Part 4文档更新
更新 [`03_console_recommend_game_bff.md`](docs/requirements/register_popup_game_entry/03_console_recommend_game_bff.md) 与父文档 §3.1:明确前端为 **lobby PWA**,注册后条件请求 recommend-game。
**注意**:后端不记录弹窗状态;刷新后是否再弹由 lobby 本地逻辑决定(需求 §8.3)。
---
## Part 5不在本次范围
- user 不调 center / console innerapi按你确认的方案
- 不新增 `user_onboarding_popup`
- 不实现 `mark_shown` / `mark_entered`
- login 接口不加 `is_new_user`(非必须)
---
## 验收要点
**后端**
1. 新手机号/新 IMEI 注册 → `is_new_user=true`;同账号再次 register → `is_new_user=false`
2. `is_new_user=true` 时 recommend-game 返回渠道游戏池随机一款(或 DEFAULT 回退)
3. center 无配置 / 调用失败 → `enabled=false`,注册仍成功
4. `game_codes` 含下架游戏 → 跳过,从剩余随机
**lobby 前端**
5. 老用户走 register 登录回退(`is_new_user=false`)→ 不请求 recommend-game无弹窗
6. 新用户注册成功 → 自动请求 recommend-game`enabled=true` 时展示 onboarding 弹窗
7. 点击 LET'S PLAY → 进入推荐游戏;点击关闭 → 留在大厅,不影响 Trial Balance
8. 各注册入口(手机/邮箱/Telegram 等)行为一致(均经 `userAuthService.register`

View File

@@ -0,0 +1,64 @@
<!-- bc83d927-425a-451f-a178-e2ee0317eb13 -->
---
todos:
- id: "create-04"
content: "新增 docs/requirements/register_popup_game_entry/04_lobby_welcome_popup.md覆盖 10 点需求、uid 起始值 1257832、launch_url 预取、接口/字段/异常/测试/代码对照"
status: pending
- id: "update-parent"
content: "更新父文档 register_popup_game_entry.mdgame 增加 launch_url 字段、点击行为补充、uid 起始值说明、索引与版本号"
status: pending
- id: "update-readme"
content: "更新 register_popup_game_entry/README.md新增 04 子需求行与代码对照表条目"
status: pending
isProject: false
---
# 新用户大厅欢迎弹窗 — 需求文档(子需求 04
## 背景与现状对照
需求 10 点中,多数已在现有代码落地,仅 2 点为新增能力。文档需如实标注「已落地 / 待做」。
- 已落地(前端 `[lobby/src/components/OnboardingGamePopup.vue](lobby/src/components/OnboardingGamePopup.vue)` + 后端 `[slot-console/app/api/logic/OnboardingRecommendLogic.php](slot-console/app/api/logic/OnboardingRecommendLogic.php)`
- 注册成功进入大厅弹欢迎窗(点 1、欢迎信息点 2、平台第几位玩家 `player_number`(点 3`player_number = uid`(点 4、推荐游戏图片 `cover_url`(点 6、3 秒倒计时自动进入 `countdown_seconds`(点 7、点击 LET'S PLAY 立即进入(点 8、关闭按钮关闭且停倒计时 `closePopup()``stopCountdown()`(点 9
- 待做(本需求核心):
- 点 5真实注册 uid 发号起始值 = `1257832``slot-user`)。
- 点 10`recommend-game` 接口提前返回 `launch_url`,前端进入时直接使用(`slot-console` + `lobby`)。
## 交付物(仅 Markdown 文档)
### 1. 新增 `docs/requirements/register_popup_game_entry/04_lobby_welcome_popup.md`
子需求 04结构对齐既有 01/02/03表头元信息 + 目标/范围 + 链路 + 接口 + 规则 + 异常 + 测试 + 代码对照)。核心章节:
- 元信息表:版本 V1.0、序号 04、涉及仓库 `lobby``slot-console``slot-user``slot-pwa`launch
- 需求点 → 实现映射表:把 10 点逐条映射到「位置 / 状态(已落地/待做)」。
- 弹窗交互规格(点 1-4、6-9触发时机注册响应 `is_new_user === true`)、文案、`player_number_display` 千分位、倒计时、确认/关闭/遮罩点击行为;引用现有 `OnboardingGamePopup.vue`
- 点 5 uid 起始值 = 1257832
- 现状:`[slot-user/.../AbstractRegisterService.php](slot-user/app/service/register/AbstractRegisterService.php)` `generateUid()` = `USER_UUID_POOL`(来自 `config/txt/uuid.txt` 洗牌) `+ (repeat+1)*1000000`;池由 `[slot-user/app/command/Uuid.php](slot-user/app/command/Uuid.php)` 载入。
- 需求:首个发放 uid = `1257832`,后续按发号规则递增、保持唯一。
- 实现方案(文档给出建议,标注需研发确认):将发号改为以 `1257832` 为基准的顺序自增计数(如新增 `USER_UUID_BASE` 常量 + incr 计数 key或重建 `uuid.txt`/池基准使首个映射值落在 1257832二选一避免与现有洗牌池产生小于基准的 uid。
- 影响与校验:`player_number = uid` 自动满足点 3/4新环境从 1257832 起,已上线环境只对增量生效(写明灰度/存量说明)。
- 点 10 launch_url 提前返回:
- 接口 `GET /slot-console/api/onboarding/recommend-game``game` 节点新增字段 `launch_url`
- 后端:`OnboardingRecommendLogic::buildRecommendPayload()` 选中游戏后,复用现有 launch 链路(`[slot-console/app/api/logic/GameLogic.php](slot-console/app/api/logic/GameLogic.php)``SlotPlatformService::launch($uid,$game_code,$ip)` → pwa预取 `url` 填入 `game.launch_url`;失败降级为 `launch_url=""` 不阻断弹窗。
- 前端:`OnboardingGamePopup.vue` 进入游戏时,若 `game.launch_url` 非空则直接写 game store 跳转(跳过再次 `/api/game/launch`),为空时回退现有 `launchByCode(game_code)`
- 注意点launch 走 pwa Session 链路(`[slot-pwa/.../GameLaunchSessionLogic.php](slot-pwa/app/api/logic/GameLaunchSessionLogic.php)` 有短窗口 dedup预取与点击进入复用同一 session文档说明幂等/有效期与不阻断注册首页的降级。
- 返回示例(含 `launch_url`)、字段说明表、异常与降级表、测试用例、与已实现代码对照表。
### 2. 更新父文档 `docs/requirements/register_popup_game_entry.md`
- §6.1 console 返回示例与字段说明:`game` 增加 `launch_url` 字段。
- §8.2 点击行为:补充「优先使用预取 launch_url」。
- 新增小节或在 §1.3 In Scope 标注uid 发号起始值 = 1257832指向 04
- 文首子需求索引行追加 04 链接,版本号自增(如 V1.5,附变更说明)。
### 3. 更新 `docs/requirements/register_popup_game_entry/README.md`
- 子需求列表新增 04 行lobby 大厅欢迎弹窗 + uid 起始值 + launch_url 预取)。
- 「与已实现代码对照」表追加弹窗组件已落地、uid 起始值待做、launch_url 预取待做。
## 不在本次范围
- 不实际改 PHP/Vue 代码(本次只交付需求文档)。
- 不改 center 渠道配置与 admin 后台(见 01/02
- Trial Balance 发放仍属父活动(钱包)。

View File

@@ -0,0 +1,285 @@
---
name: Lucky Reward 双弹窗分析
overview: 被邀请人 Support Invite 应从 HomeEvent 迁到 lucky_reward_invite_bind 成功时 WS 即时推送只弹一次HomeEvent 仅保留邀请人 Claim Nowpending_spin_count可重复补弹。这样同一 uid 在 HomeEvent 不再同时命中两种 Lucky Rewards 弹窗。
todos:
- id: diagnose-no-helper
content: 按日志/SQL 排查 invite_bind 全链路,定位 helper 未落库的具体 status/reason
status: pending
- id: move-support-invite-to-bind
content: invite_bind success 后ensureInviteeAutoOpenBox + buildSupportInvitePopup + WsService::notifyClientPOPHomeEvent 移除被邀请人逻辑
status: pending
- id: update-tests-docs-yapi
content: 更新集成测、05 方案文档、YApi #109 WS 弹窗示例Claim Now 仍走 HomeEvent
status: pending
- id: confirm-product-rule
content: 确认产品规则:双角色用户允许两种弹窗,还是同一 flush 只保留一种(及优先级)
status: cancelled
- id: implement-mutex-if-needed
content: 若需互斥:在 LuckyRewardInviteLogic 抽 resolveHomePopupsHomeEvent 只 merge 一种;补互邀集成测
status: cancelled
- id: support-invite-once
content: (可选)若 Support Invite 应只弹一次:增加 consume/已读状态,与互斥分开评估
status: cancelled
isProject: false
---
# Lucky Rewards 同一人两种弹窗 — 根因与可选改法
## 现象对应代码
[`HomeEvent::appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php) 对**同一个** `$uid` 顺序做两件事,**互不排斥**
```437:455:slot_console/app/command/event/HomeEvent.php
protected function appendLuckyRewardHomePopups(array &$popList, int $uid, UserInfoEntity $userInfoEntity): void
{
// ...
$inviteLogic->ensureInviteeAutoOpenBox($uid, $source);
$supportInvitePop = $inviteLogic->buildSupportInvitePopup($uid, $source);
if ($supportInvitePop !== false) {
$luckyRewardPopups[] = $supportInvitePop;
}
$inviterClaimNowPop = $inviteLogic->buildInviterClaimNowPopup($uid, $source);
if ($inviterClaimNowPop !== null) {
$luckyRewardPopups[] = $inviterClaimNowPop->toHomePopListItem();
}
$popList = array_merge($luckyRewardPopups, $popList);
}
```
两种弹窗判定维度不同:
| 弹窗 | type | 判定方法 | 核心条件(同一 uid |
| --- | --- | --- | --- |
| Support Invite | `lucky_reward_support_invite` | [`buildSupportInvitePopup`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) | `lucky_reward_helper` 存在 **invitee_uid = uid** 且 **已开箱** |
| Claim Now | `lucky_reward_invite_success` | [`buildInviterClaimNowPopup`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) | `pending_spin_count > 0` 且存在 **未领的 GRANT_TYPE_INVITER grant** |
需求文档 [`05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) 表格里写的是「邀请人 / 被邀请人」两种**角色**,但代码**没有**写「同一 uid 只能占一种角色」。
```mermaid
flowchart TD
homeFlush["HomeEvent flush uid=X"]
openBox["ensureInviteeAutoOpenBox(X)"]
checkInvitee["buildSupportInvitePopup(X)\nhelper.invitee_uid=X ?"]
checkInviter["buildInviterClaimNowPopup(X)\npending_spin_count>0 ?"]
popSupport["lucky_reward_support_invite"]
popClaim["lucky_reward_invite_success"]
merge["array_merge 两种都进 popList"]
homeFlush --> openBox --> checkInvitee
checkInvitee -->|是| popSupport
checkInvitee -->|否| checkInviter
popSupport --> checkInviter
checkInviter -->|是| popClaim
popClaim --> merge
checkInviter -->|否| merge
```
## 为什么「一个人」会同时有两种?
**正常单链邀请A 邀 B不会出现**A 只有 Claim NowB 只有 Support Invite见 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php))。
**同一 uid 双角色时会出现**,典型场景:
1. **互邀 / 双链**A 邀 BB 也邀 A同一 `cycle_id`,均新注册有效助力)
- A`helper(invitee=A)` → Support Invite`pending_spin_count>0` → Claim Now
- B同理
2. **先被邀、后又成功邀别人**:用户 C 邀了 AA 为被邀请人);之后 A 又邀了 DA 为邀请人且未 claim→ 回大厅两种都满足
3. **邀请人未 claim 的重复提醒 + 被邀请身份长期有效**Claim Now 在 `pending_spin_count>0` 期间**每次回大厅都弹**Support Invite 在 helper 存在且已开箱后**每次回大厅都弹**(无 consume/已读标记)→ 双角色用户会**持续**收到两个 pop
`ensurePlayerRow` 会在 invite_bind 时为**邀请人**创建 player 行(不必先开箱),因此「邀请人侧 Claim Now」与「被邀请人侧 Support Invite」在数据上可以并存。
## 这不是 invite_bind 写错
[`persistInviteHelperInTransaction`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 只给**邀请人**写 grant + `pending_spin_count`;被邀请人**不再**写 invitee grant测试也断言 `invite_invitee:` grant 为 null。双弹窗来自 **HomeEvent 合并逻辑**,不是一次回调写了两种状态。
## 若产品期望「同一时刻只弹一种」
~~需产品定优先级,再在 HomeEvent 加互斥~~ **已按产品意见调整方案**:被邀请人弹窗**不再走 HomeEvent**,见下文 §「被邀请人弹窗迁出 HomeEvent」HomeEvent 只留邀请人 Claim Now从架构上消除「同一人两种 Lucky Rewards 弹窗」。
---
# 被邀请人 Support Invite 迁出 HomeEvent产品定案
## 背景
- **被邀请人**IMEI 进游戏即注册,`invite_bind` 发生时用户**在线**Support Invite **只弹一次**,不存在「离线补弹」。
- **邀请人**:可能正在玩游戏/离线Claim Now 须 **`pending_spin_count` + 每次回大厅重复弹**,继续走 HomeEvent。
当前实现把两者都塞进 [`appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php),导致:
- 被邀请人**每次回大厅重复弹** Support Invite无 consume
- 双角色用户同一 flush 可能同时出现两种 type
## 目标架构
```mermaid
sequenceDiagram
participant Agent as agent_invite_bind
participant ConsoleBus as console_bus
participant Logic as LuckyRewardInviteLogic
participant WS as WsService_notifyClientPOP
participant Home as HomeEvent
Agent->>ConsoleBus: lucky_reward_invite_bind
ConsoleBus->>Logic: handleInviteBind success
Logic->>Logic: ensureInviteeAutoOpenBox(invitee)
Logic->>Logic: buildSupportInvitePopup(invitee)
Logic->>WS: 即时推送 invitee 仅一次
Note over Home: 邀请人路径不变
Home->>Logic: buildInviterClaimNowPopup(inviter)
Home->>WS: home flush popList
```
| 弹窗 | 触发点 | 下发方式 | 重复策略 |
| --- | --- | --- | --- |
| Support Invite | `lucky_reward_invite_bind` → `status=success` 后 | `WsService::notifyClientPOP(inviteeUid, [...])` | **一次**bind 幂等保证不重复推) |
| Claim Now | HomeEvent flush | 合并进 `popList` | **未 claim 前每次回大厅** |
## 代码改动要点
### 1. [`EventBus::luckyRewardInviteBindEvent`](slot_console/app/command/EventBus.php)
`handleInviteBind` 返回 `success` 后新增(建议封装为 `LuckyRewardInviteLogic::pushInviteeSupportInvitePopup()`
1. `ensureInviteeAutoOpenBox($inviteeUid, $source)` — 从 HomeEvent **挪到这里**(弹窗需要 `my_amount`
2. `buildSupportInvitePopup($inviteeUid, $source)`
3. 非 false 则 `WsService::notifyClientPOP($inviteeUid, [$popup])`
`already_processed` / 各类 `skipped_*` **不推送**。
### 2. [`HomeEvent::appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php)
**删除**
- `ensureInviteeAutoOpenBox`
- `buildSupportInvitePopup` 及 merge
**保留**
- `buildInviterClaimNowPopup` → Claim Now
方法可重命名为 `appendInviterClaimNowPopup` 或保持原名但注释只服务邀请人。
### 3. 测试
- [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php)`testEnsureInviteeAutoOpenBoxAndSupportInvitePopup` 改为断言 bind 成功路径mock/spy `WsService` 或抽 `pushInviteeSupportInvitePopup` 返回值)
- 新增bind 幂等第二次不推 WS
- HomeEvent 单测:被邀请人 uid flush **不应**再含 `lucky_reward_support_invite`
### 4. 文档
- [`05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §5 表格拆分触发点
- YApi #109 WS popup 示例补 Support Invite 即时推送说明
## 边界说明
| 点 | 处理 |
| --- | --- |
| MQ 消费略晚于注册完成 | 产品认定被邀请人在线;仍走 WS 即时推。若 hub 未连上,**不做 HomeEvent 补弹**(与「只弹一次、无离线」一致) |
| 游戏内禁弹 | bind 发生在注册链路,通常不在 `enter_game`;若需硬约束可后续加 hub 侧 scene 判断 |
| openBox 失败 | 记录 error不推 Support Invite无 my_amount |
| 双角色用户 | HomeEvent 仅可能出 Claim NowSupport Invite 仅在作为被邀请人 bind 当时推一次 |
## 与「没有 helper」排查的关系
Support Invite 迁出 **不解决** helper 未落库;须先保证 `handleInviteBind` → `success` 写 helper再在 success 分支推 WS。
---
# 「没有助力」DB 无 lucky_reward_helper— 排查结论
> 你已确认:**不是弹窗问题**,而是 `lucky_reward_helper` 没有新记录 / `valid_invite_count` 不涨。
## 有效助力必须满足的链路
```mermaid
sequenceDiagram
participant User as slot_user注册
participant AgentMQ as agent_bus
participant Agent as slot_agent_EventBus
participant Gateway as LuckyRewardInviteCallbackGateway
participant ConsoleMQ as console_bus
participant Console as slot_console_EventBus
participant Logic as handleInviteBind
User->>AgentMQ: TYPE_INVITE_BIND\nshare_origin=wheel\nis_new_register=1
Agent->>Agent: ref_invite_relation 新建?
Agent->>Gateway: 仅 isNewInviteRelation=true 时调用
Gateway->>Gateway: lucky_reward_callback_enabled\nshare_origin=wheel
Gateway->>ConsoleMQ: TYPE_LUCKY_REWARD_INVITE_BIND
Console->>Logic: handleInviteBind
Logic->>Logic: 无 skip 则写 helper + inviter grant
```
**任意一环失败 → DB 无 helper。**
## 按概率排序的常见原因
### 1. 用了 Agent 中心短码,不是转盘 wheel 短码(最常见误用)
- **agent 网关硬过滤**[`LuckyRewardInviteCallbackGatewayService`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php) 仅 `share_origin=wheel` 才投递 console_bus`user_agent` → `reason=skipped_not_wheel_origin`**console 根本收不到消息**。
- **console 侧也会再拦**[`resolveInviteBindSkipResult`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) `share_origin !== wheel` → `skipped_not_wheel`。
- **正确链接**:须来自 `GET /api/lucky-reward/invite-link` 或 `Gift/createShareUrl from=wheel`(绑定当前 `lucky_reward_cycle` 的 `share_url.origin=wheel`)。
### 2. wheel 短码已过期(「以前有,现在没有」高概率)
- 轮次轮转后,旧短码 `share_url.biz_id` 指向**已结束 cycle**,与当前 active cycle 不一致 → console 返回 [`STATUS_SKIPPED_EXPIRED_CYCLE`](slot_console/app/entity/luckyReward/InviteBindResultEntity.php)**不写 helper**(见集成测 `testHandleInviteBindSkipsWhenShareCycleMismatchActiveCycle`)。
- **处理**:邀请人重新拉 `invite-link`,用**本轮**短码测。
### 3. 被邀请人不是「本次新注册」
- console 要求 `is_new_register=1`;老 IMEI 登录 / 手动绑码 → `is_new_register=0` → `skipped_old_user`。
- IMEI 路径:仅 [`ImeiRegisterService::run`](slot_user/app/service/register/ImeiRegisterService.php) **新建账号**时 `notifyAgentInviteBind`;已存在 IMEI 走 login**不发 invite_bind**。
- 手动绑码 [`UserController`](slot_user/app/innerapi/controller/UserController.php) 固定 `is_new_register=0`,且返回体**未带** `share_origin`MQ 里为空)→ agent 网关 wheel 过滤失败。
### 4. 活动未开启或无 active cycle
- 种子数据默认 [`lucky_reward_config.status=2`(关闭)](backend/slot_admin/db/lucky_reward.sql)。
- console `resolveInviteBindProcessContext` 活动关闭 → `activity_closed`。
- 弹窗/开宝箱也依赖开启配置,与 helper 同源。
### 5. agent 仅「首次 ref_invite_relation」才回调 console
[`slot_agent EventBus::inviteBindEvent`](slot_agent/app/command/EventBus.php)
```php
if ($isNewInviteRelation) {
LuckyRewardInviteCallbackGatewayService::...->notifyFromInviteBindEntity($entity);
}
```
若该 `invitee_uid` 在 `ref_invite_relation` **已有行**(历史任何来源绑过),**不会再投递** `lucky_reward_invite_bind`,即使本次 MQ 仍处理 agent 奖励。
### 6. MQ / 开关 / 进程
| 检查项 | 位置 |
| --- | --- |
| `lucky_reward_callback_enabled=1` | center → agent |
| `php webman event:bus` 消费 agent_bus | slot_agent |
| `php webman event:bus` 消费 console_bus | slot_console |
| agent 日志 `luckyRewardCallback` | `dispatched` / `reason` |
| console 日志 `luckyRewardInviteBindEvent` | `status` 字段 |
## 建议排查步骤(按顺序执行)
1. **复现一次邀请注册**,记录:邀请短码、`share_url.origin`、`share_url.biz_id`、当前 `lucky_reward_cycle.id`。
2. **查 agent 日志** `inviteBindEvent` + `luckyRewardCallback`
- 无 `luckyRewardCallback` → 看 `isNewInviteRelation` 或 MQ 未进 agent。
- `reason=skipped_not_wheel_origin` → 短码/来源不对。
- `reason=callback_disabled` → center 开关。
3. **查 console 日志** `EventBus::luckyRewardInviteBindEvent` 的 `status`
- 无日志 → console_bus 未消费或 agent 未投递。
- `skipped_expired_cycle` / `skipped_old_user` / `activity_closed` / `skipped_not_wheel` → 对号入座。
4. **SQL 验证**(替换 uid/cycle
- `SELECT * FROM lucky_reward_helper WHERE invitee_uid=? ORDER BY id DESC LIMIT 5;`
- `SELECT * FROM ref_invite_relation WHERE invitee_uid=?;`
- `SELECT id,status,cycle_no,end_at FROM lucky_reward_cycle WHERE source='default' ORDER BY id DESC LIMIT 3;`
- `SELECT short_code,origin,biz_type,biz_id FROM share_url WHERE short_code='?';`
## 若日志 status=success 仍无 helper
才考虑事务异常/库连接错误(少见);查 console `LuckyReward inviteBind failed` error 日志。
## 与弹窗问题的关系
helper **未落库**时:`valid_invite_count` 不涨、Record/helpers 为空、Claim Now / Support Invite **都不会出现**(弹窗是 helper/grant 的下游展示)。应先修 invite_bind 链路,再谈弹窗互斥。

View File

@@ -0,0 +1,212 @@
---
name: Lucky Reward 奖池迁移
overview: 采用全平台统一的 reward_pool / reward_pool_item以 pool_code = lucky_reward 承载大转盘手动 Spin 奖项;外层仅 prize_type 表达类型lucky_reward 业务参数(含 unlockInviteCount一律进 config逐步替换 lucky_reward_prize_config。
todos:
- id: ddl-seed
content: s_common 建 reward_pool / reward_pool_item含 prize_type 外层字段seed pool_code=lucky_reward编写从 lucky_reward_prize_config 的迁移脚本
status: completed
- id: console-draw
content: slot_consoleRewardPoolItemModel + ConfigService/DrawService/LuckyRewardLogic 改读 lucky_reward 奖池Draw 读 prize_type 列
status: completed
- id: admin-crud
content: slot_admin替换 LuckyRewardPrizeConfig CRUD 为 lucky_reward 奖池项管理,更新菜单权限
status: completed
- id: test-docs-cleanup
content: 单测/集成测、需求文档更新、删除旧 Model 与 lucky_reward_prize_config 表
status: completed
isProject: false
---
# Lucky Reward 奖池方案评估与迁移计划
## 结论:`pool_code = lucky_reward` 可行,推荐做
**方向正确**:把「可复用奖池」与「活动专用抽奖/入账」拆开,比继续扩 [`lucky_reward_prize_config`](docs/requirements/lucky_rewards/02_管理后台方案.md) 更适合后续其它活动复用同一套表结构。
**与你已确认的前提对齐**
- 全平台 **一个** 奖池:`pool_code = 'lucky_reward'`
- **替换** 现有 [`lucky_reward_prize_config`](slot_console/app/model/common/LuckyRewardPrizeConfigModel.php)(非并存长期双写)
- **外层仅 `prize_type` 表达奖项类型**lucky_reward 业务参数(含开放条件)**一律进 `config`**,避免个别字段外置导致表结构膨胀
```mermaid
flowchart LR
subgraph admin [slot_admin]
PoolCRUD[RewardPoolItem CRUD]
end
subgraph db [s_common]
RP[reward_pool pool_code=lucky_reward]
RPI["reward_pool_item prize_type + config"]
end
subgraph console [slot_console]
Draw[LuckyRewardDrawService]
Spin[LuckyRewardLogic.executeManualSpin]
end
PoolCRUD --> RPI
RP --> RPI
RPI --> Draw
Draw --> Spin
```
---
## 核心设计:`prize_type` 外层 + 业务参数全进 `config`
**字段分层原则**
| 层级 | 字段 | 说明 |
| --- | --- | --- |
| 表通用列 | `pool_id``pool_code``item_code``item_name``prize_type``weight``display_text``icon_url``status``sort` | 奖池项元数据,各 pool 共用 |
| `config` JSON | lucky_reward 及后续各 pool 的**业务参数** | 含开放条件、金额区间等;**禁止**再拆个别业务字段到表列 |
| 禁止 | 表列 `unlock_invite_count` 等 lucky_reward 专用字段 | 与「通用奖池表 + pool 级 config 约定」冲突 |
### 1. 表字段调整(相对你初版 DDL
原 DDL 中的 `reward_type` **改为 `prize_type`**TINYINT奖项命中语义。不同 `pool_code` 可约定不同枚举;`lucky_reward` 池定义如下:
| prize_type | 含义 | 抽奖 | 入账行为(与现网一致) |
| --- | --- | --- | --- |
| 1 | 随机金额 | 参与权重 | My Amount 增加(封顶至 target |
| 2 | 1x Spin | 参与权重 | spin_available +1 |
| 3 | Cash Out | 参与权重 | playerStatus → 可提取 |
| 4 | Jackpot 展示 | **不参与**weight=0 或 Draw 过滤) | 无入账,仅转盘 UI 展示 |
常量命名沿用现有 [`LuckyRewardPrizeConfigModel::PRIZE_TYPE_*`](slot_console/app/model/common/LuckyRewardPrizeConfigModel.php),迁移到 `RewardPoolItemModel`(或共用常量类),保证 [`lucky_reward_spin_record.prize_type`](docs/requirements/lucky_rewards/02_管理后台方案.md) 与 [`DrawResultEntity::prizeType`](slot_console/app/entity/luckyReward/DrawResultEntity.php) **数值不变**
### 2. `lucky_reward` 池 `config` 结构SSOT
**禁止** config 内写 `prizeType`(类型只看外层列)。按 `prize_type` 校验必填/可选字段admin Validate + 文档):
**各类型共有可选字段**(参与抽奖的类型 1/2/3 常用):
| config 字段 | 说明 |
| --- | --- |
| `unlockInviteCount` | 开放命中所需有效邀请人数,默认 0无需 |
**各类型专有字段**
| prize_type | 专有 config 字段 | 说明 |
| --- | --- | --- |
| 1 随机金额 | `amountMinQf`, `amountMaxQf` | 千分位区间min ≤ max |
| 2 1x Spin | — | 可无专有字段 |
| 3 Cash Out | — | 可无专有字段;开放条件用 `unlockInviteCount` |
| 4 Jackpot | `displayAmountQf` | 展示用金额(千分位) |
示例:
```json
// prize_type = 1需 3 个有效邀请后才开放
{ "unlockInviteCount": 3, "amountMinQf": 100, "amountMaxQf": 500 }
// prize_type = 3Cash Out 需 5 个邀请
{ "unlockInviteCount": 5 }
// prize_type = 4
{ "displayAmountQf": 6666000 }
```
### 3. 外层列(不进 config
仅保留奖池项**通用**列,不含 lucky_reward 业务参数:
| 字段 | 说明 |
| --- | --- |
| `weight` | 抽取权重 |
| `display_text` / `icon_url` / `sort` | 前端展示与排序 |
`reward_pool_item` 建议 DDL 片段(相对初版变更点):
```sql
`prize_type` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '奖项类型lucky_reward池见 PRIZE_TYPE_* 常量',
`config` JSON NOT NULL COMMENT 'pool 业务参数lucky_reward见文档 SSOT',
-- 删除 reward_type不新增 unlock_invite_count 等 pool 专用列
```
### 4. 全平台单池
所有 source 共用 `pool_code = lucky_reward` 一套转盘格子和权重;[`LuckyRewardConfigService::listEnabledPrizeConfigs($configId)`](slot_console/app/service/luckyReward/LuckyRewardConfigService.php) 改为按 `pool_code` 加载,**不再依赖** `lucky_reward_config.id`
活动 per-source 配置(开关、周期、分层开宝箱、目标金额)不变。
### 5. DDL 其它细节
| 项 | 建议 |
| --- | --- |
| 主键 | 确认 ID 策略:若无雪花发号,补 AUTO_INCREMENT |
| 时间字段 | Model 层映射 `created_at`/`updated_at` |
| 库 | **s_common** |
| 索引 | item 表增加 `KEY idx_pool_code_status (pool_code, status)``KEY idx_prize_type (prize_type)` |
### 6. 常量约定
```php
/** Lucky Rewards 大转盘手动 Spin 奖池编码 */
public const POOL_CODE_LUCKY_REWARD = 'lucky_reward';
```
`prize_type` 常量(中文注释必填)与现网 1/2/3/4 对齐。
---
## 迁移与实现步骤
### Phase 1 — 表结构与数据迁移
1.`s_common` 执行 `reward_pool``reward_pool_item` DDL`prize_type` + `config`,无 `reward_type`、无 `unlock_invite_count` 列)
2. Seed`pool_code = lucky_reward``pool_name = Lucky Rewards Wheel``status = 1`
3. 迁移 `lucky_reward_prize_config``reward_pool_item`
- `prize_type` ← 旧 `prize_type`(直迁)
- `weight``sort``status` 直迁
- `config`:写入 `unlockInviteCount`(← 旧 `unlock_invite_count`+ 类型专有字段amountMinQf/MaxQf 或 displayAmountQf
- `item_code`:建议 `lr_prize_{old_id}`
4. 验证通过后 drop 旧表(或先 rename `_bak`
### Phase 2 — slot_console 读新池抽奖
| 文件 | 变更 |
| --- | --- |
| 新建 `RewardPoolModel` / `RewardPoolItemModel` | `listEnabledByPoolCode()` |
| [`LuckyRewardConfigService`](slot_console/app/service/luckyReward/LuckyRewardConfigService.php) | `listLuckyRewardPoolItems()`,固定 `POOL_CODE_LUCKY_REWARD` |
| [`LuckyRewardDrawService`](slot_console/app/service/luckyReward/LuckyRewardDrawService.php) | 读列 `prize_type`;过滤读 `config.unlockInviteCount`(缺省 0随机金额读 `config.amountMinQf/MaxQf`Jackpot(4) 仍排除 |
| [`DrawResultEntity`](slot_console/app/entity/luckyReward/DrawResultEntity.php) | 增加 `poolItemId``prizeType` 来自 item 的 `prize_type` 列 |
| [`LuckyRewardLogic::executeManualSpin`](slot_console/app/api/logic/LuckyRewardLogic.php) | 切换数据源spin_record.prize_type 仍写 Draw 结果 |
**行为不变**:随机封顶、+1 Spin、Cash Out 改状态、spin_record 写入。
### Phase 3 — slot_admin 后台替换 CRUD
| 现状 | 目标 |
| --- | --- |
| [`LuckyRewardPrizeConfigController`](backend/slot_admin/app/game/controller/LuckyRewardPrizeConfigController.php) 等 | Lucky Reward 奖池项管理scoped `pool_code=lucky_reward` |
| Validate | 校验外层 `prize_type`、按类型校验 config`unlockInviteCount`、金额区间)、权重 |
| [`menu-lucky-rewards.sql`](backend/slot_admin/db/menu-lucky-rewards.sql) | 路由指向新 Controller |
表单:美元展示 → 写入 config 内 `_qf``unlockInviteCount` 表单项写入 config`prize_type` 下拉与现网四类一致。
### Phase 4 — 文档、测试、清理
- 更新 [`02_管理后台方案.md`](docs/requirements/lucky_rewards/02_管理后台方案.md)`reward_pool_item.prize_type` 枚举 + config 结构 SSOT
- 更新 [`LuckyRewardDrawServiceTest`](slot_console/tests/Unit/LuckyRewardDrawServiceTest.php) fixturepool item 含 `prize_type` 列 + 精简 config
- 删除 `LuckyRewardPrizeConfigModel` 及旧表引用
---
## 风险与规避
| 风险 | 规避 |
| --- | --- |
| 运营迁移窗口改旧表 | 迁移前冻结编辑;脚本完成后立即切读新表 |
| Jackpot 无 C 端拉取 | 后台仍配 prize_type=4后续如需动态转盘再补接口 |
| 其它 pool_code 的 prize_type 语义冲突 | 按 pool 文档约定枚举;通用表结构复用,语义由 pool_code + 文档定义 |
| config 误写 prizeType | Validate 拒绝;迁移脚本不写入 prizeType |
| 业务字段外置导致表膨胀 | 约定:除 `prize_type` 外 lucky_reward 参数一律 config其它 pool 同理 |
---
## 总体评价
- **赞成** `pool_code = lucky_reward` 作为大转盘唯一奖池编码。
- **采纳**:类型语义 **外层 `prize_type`****`unlockInviteCount` 等业务参数进 config**,与通用奖池表设计一致。
- 其它活动复用 `reward_pool_item` 时,同一 `prize_type` 数值在不同 pool 可代表不同含义,需在各自 pool 文档中定义lucky_reward 池先用 1/2/3/4 四态)。
确认本计划后,按 Phase 1→4 落地。

View File

@@ -0,0 +1,60 @@
<!-- 3c4f5bff-252e-46d9-942c-7d9b3f38c0c4 -->
---
todos:
- id: "hash-code"
content: "§5/§6.5account_hash 前缀与示例改用 codeusdt/paypal/cashapp保留 USDT 地址不转小写"
status: pending
- id: "schema"
content: "§7.1/§7.2/§7.3method_type 改 VARCHAR(32)=payment_method.code枚举表改编码表+payment_method 依赖说明"
status: pending
- id: "api"
content: "§8.1/§8.2/§8.4/§9列表/新增/提现使用/快照里的 method_type 改 code 值method_name 取自 payment_method.name"
status: pending
- id: "validate-summary"
content: "§12/§18/§20校验改为引用 payment_method 启用且支持提现的 code汇总条目更新"
status: pending
- id: "payment-method-dep"
content: "新增「与 payment_method 关系」说明:新增 paypal/cashapp 行、加提现方向字段、充值 all() 过滤;并写明下单 code→pay_type(usdt=3/paypal=6/cashapp=1)+pay_net 映射归属订单模块"
status: pending
isProject: false
---
# method_type 对齐 PaymentMethodModel.code
仅改需求文档 [用户提现账户管理需求文档.md](slot-pay/docs/requirements/用户提现账户管理需求文档.md);同时把对 `payment_method` 表与提现订单映射的依赖写进文档(实际建表/代码后续单独实现)。
## 核心决策
- 账户表方式字段对齐 `PaymentMethodModel.code``usdt` / `paypal` / `cashapp`(字符串)。
- 字段类型 `TINYINT``VARCHAR(32)`;字段名保留 `method_type`(存 code 值),文档注明「= payment_method.code」如需更直观可改名 `method_code`,作为可选项注明)。
- `method_name``payment_method.name` 提供,删除「按整数枚举硬编码映射」。
- `account_hash` 前缀改用 code`sha256("usdt|USDT|TRC20|address")``sha256("paypal|email小写")``sha256("cashapp|cashtag小写")`
## payment_method 前置依赖(写入文档「服务边界/与 payment_method 关系」)
- 现状:`payment_method.code` 仅 usdt/bank/alipay无 paypal/cashapp无方向字段`PaymentMethodModel::all()`[CashierController.php](slot-pay/app/innerapi/controller/CashierController.php) L101给**充值**收银台用。
- 需新增 `paypal``cashapp` 两行status=1
- 需增加「方向/场景」字段(如 `scene` 位标记或 `support_recharge`/`support_withdraw`),区分充值/提现方式。
- 充值侧 `PaymentMethodModel::all()` 须过滤为充值方式,避免 paypal/cashapp 泄漏到充值收银台;提现方式列表用「按提现方向过滤」的查询(前端「选择提现方式」据此渲染)。
## 下单映射(保留并明确,写入 §6.6/§8.4
- PayService 用**整数 pay_type** 路由 SDK[PayService.php](slot-pay/app/service/PayService.php) L86 + [config/pay.php](slot-pay/config/pay.php) `withdrawalTypes`usdt=3、paypal=6、cashapp=1
- 账户表只存 code**提现订单模块**创建订单时把 `method_type(code) → pay_type 整数 + pay_net(网络)` 映射后再调 PayService。文档明确该映射归属订单模块。
## 文档改动点(逐节)
- §5 字段设计hash 前缀示例改 code。
- §6.5 防重复:`sha256(method_type + "|" + key)` 用 code示例与大小写规则保持USDT address 不转小写)。
- §7.1 建表 SQL`method_type VARCHAR(32)` 注释「= payment_method.code取值 usdt/paypal/cashapp」索引保持 `(uid, method_type)``(uid, method_type, account_hash)`
- §7.2 字段说明method_type 改为 code 字符串说明。
- §7.3 枚举:整数枚举表 → 「提现方式编码(引用 payment_method.code」usdt/paypal/cashapp + payment_method 依赖说明。
- §8.1 列表:请求/响应 `method_type` 改 code 值,`method_name` 注明来自 payment_method.name。
- §8.2 新增:三种请求体 `method_type``"usdt"/"paypal"/"cashapp"`;后端生成 hash 用 code。
- §8.4 提现使用:读取 method_type(code),补 code→pay_type 映射归属订单模块。
- §9 快照:`method_type` 改 code 值,保留 method_name。
- §12 校验method_type 必须是「payment_method 中启用且支持提现的 code」。
- §18 关系 / §20 汇总:第 10 条「名称由 method_type 映射」改「由 payment_method.name 提供」;补 code 对齐与 code→pay_type 映射两条规则。
## 不改动
- 删除/状态(status=3)、单活跃账户、每方式最多 10、软件层唯一校验、仅 USDT 脱敏、amount×1000、uid 来源等既有决策保持不变。

View File

@@ -0,0 +1,119 @@
<!-- 215d598c-9837-4e65-a3e9-850d78cd5f00 -->
## 月返水Monthly Cashback实现方案
与周返水完全同构,仅「周」改「月」。需求见 [月返水.md](slot_console/doc/月返水.md);结构镜像 [DailyRebateLogic.php](slot_console/app/api/logic/DailyRebateLogic.php) 与周返水两份计划(核心 + 统计)。
### 需要改动的服务
- slot_lib常量。
- slot_console表 / 模型 / 服务 / Logic / Controller / Validator / 命令 / 路由 / 活动配置入口 / 部署文档。
- backend/slot_admin+ vue后台「每月返水统计」页。
- slot_wallet仅确认流水类型 69 打码归【活动赠送 Y3】。
- slot_pwa无需改动复用 `rebate_bet`)。
### 周期与口径(与周返水差异)
- 统计月:每月 1 日 00:00:00 ~ 月末 23:59:59服务器时区。
- `month_start = date('Y-m-01')`;上月 = `first day of last month`;上上月 = `first day of 2 months ago`;过期时间 = 下月 1 日。
- 月有效投注 = 当月每日 `rebate_bet` 之和(仅充值用户,沿用 pwa 现有口径)。
- VIP 取月末最后一刻(结算下月 1 日读当前 VIP`floor` 取整到厘。
### 数据流
```mermaid
flowchart TD
pwa["slot_pwa 已累加 rebate_bet"] --> redis["redis user:profit:Ymd TTL7天"]
accrue["monthlyRebateAccrue 每天00:05 处理昨日"] --> redis
accrue --> rec["monthly_rebate_record 当月Pending bet累加 + vip/rate快照"]
settle["monthlyRebateSettle 每月1日00:15"] --> rec
settle --> vip["读当前VIP rate=vip_rates level"]
settle --> expirePrev["上上月 Claimable -> Expired"]
claim["C端 claim 上月"] --> wallet["WalletService gift 类型69 打码Y3"]
admin["后台 每月返水统计"] --> rec
```
### 落库方案 B幂等
- `monthly_rebate_record``last_accrued_date`date
- `monthlyRebateAccrue`(每天)处理**昨日**key 仅 1 天,在 TTL 内):遍历昨日 profit 哈希,充值用户且 `rebate_bet>0` → 定位昨日所属月记录(无则建 Pending→ 若 `last_accrued_date < 昨日``bet_amount += rebate_bet` 且更新 `last_accrued_date`;同时写入当前 `vip_level`/`rate_snapshot`(供后台对待定行展示费率)。
- C 端 This Month = `record.bet_amount` + 今日 redis 实时 `rebate_bet`
### 结算/过期(每月 1 日)
- `monthlyRebateSettle`:上月 Pending → 读当前 VIP → `rebate=floor(bet*rate/100)``rebate>0` 置 Claimable`claimable_at=本月1日``expire_at=下月1日`;同次将上上月 `Claimable 且 expire_at<=now` 置 Expired。
- `monthlyRebateExpire`:兜底,`Claimable 且 expire_at<=now -> Expired`
- 顺序accrue 先于 settle。
### 复用 vs 新建(计费 = VIP 费率单乘,不复用 tiers
- 复用骨架:领取短事务 CAS + 钱包入账失败回滚 + 结算结构化日志 + `RechargedUidSetService`/`UserProfitEntity`/四态 + `statusLabel`/Controller/Validator 形态。
- 新建:`MonthlyRebateCalcService::calcRebateLi(betLi, ratePercent)=floor(betLi*ratePercent/100)``MonthlyRebateConfigService`(解析 `vip_rates`/`maxRate`/`rateForVip`/`canonicalExtConfigForStorage`)。
### 文件清单
slot_lib
- [Consts.php](slot_lib/src/common/const/Consts.php)`ACTIVITY_TYPE_MONTHLY_REBATE=14``TRANSACTION_TYPE_MONTHLY_REBATE=69`(若周返水未先上,取下一空位即可)。
slot_console
- 模型 `app/model/common/MonthlyRebateRecordModel.php`(四态 + `findByUidAndMonth`)。
- [RechargeGiftConfigModel.php](slot_console/app/model/common/RechargeGiftConfigModel.php)`TYPE_MONTHLY_REBATE=14` + `getMonthlyRebateActiveInfo/...ByExactSource`(镜像每日返水方法)。
- 服务 `app/service/MonthlyRebateCalcService.php``app/service/MonthlyRebateConfigService.php`
- Logic `app/api/logic/MonthlyRebateLogic.php`info/claim/accrue/settle/expire
- Controller `app/api/controller/MonthlyRebateController.php`、Validator `app/api/validator/MonthlyRebateValidator.php`claim 场景,可选 month_start
- 命令 `app/command/MonthlyRebateAccrue.php``MonthlyRebateSettle.php``MonthlyRebateExpire.php`
- 路由:注册 monthly-rebate info/claim。
- SQL `db/monthly_rebate.sql`(含 `month_start date``last_accrued_date`、唯一键 `uniq_uid_month``vip_level``rate_snapshot`)。
- 活动配置入口 [ActivityConfigEntity.php](slot_console/app/entity/activity/ActivityConfigEntity.php) 对 `TYPE_MONTHLY_REBATE``MonthlyRebateConfigService::canonicalExtConfigForStorage`
- 部署文档 `doc/monthly_rebate_deploy.md`
backend/slot_admin后台统计镜像 [FreeCreditsStatsLogic.php](backend/slot_admin/app/game/logic/FreeCreditsStatsLogic.php)
- 模型 `app/game/model/common/MonthlyRebateRecordModel.php``connection='s_common'`、四态 + `statusLabel`)。
- DTO `app/game/dto/MonthlyRebateStatsQueryDTO.php`、校验 `app/game/validate/MonthlyRebateStatsValidate.php``uid/source/status/stat_range[]/orderBy(in:bet_amount,rebate_amount)/orderType/page/limit`)。
- 控制器 `app/game/controller/MonthlyRebateStatsController.php``index` 返回 list + otherData
- Logic `app/game/logic/MonthlyRebateStatsLogic.php`
- `buildQuery``uid` 精确、`source` 精确、`status` whereIn、时间范围 snap 整月(`month_start >= 月初(start)``month_start <= 月初(end)`)、排序白名单默认 `month_start desc`
- `list`:行补 `rate(=rate_snapshot)``month_start/month_end``is_current_month``status_label`,金额 `getNumberFormat`
- `statistics`:总领取/总待领取/总过期/领取率(分母 0→02 位小数)/待定(仅当筛选含本月)。
- 菜单 `db/menu-monthly-rebate-stats.sql`(挂「综合统计」父菜单,镜像 [menu-free-credits-stats.sql](backend/slot_admin/db/menu-free-credits-stats.sql)`component=game/monthlyRebateStats/index` + `sm_system_group_menu` 授权)。
backend/slot_admin_vue镜像 [freeCreditsStats/index.vue](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue)
- `src/api/game/monthlyRebateStats.js``src/views/game/monthlyRebateStats/index.vue`:筛选(ID/渠道/状态/按日范围)、`#tableAfterButtons` 顶部总计、可排序列(有效下注额/返水金额)、统计周期列显示「本月」或 `month_start - month_end`
slot_wallet
- 确认流水类型 `69` 打码归类为 活动赠送 Y3`SOURCE_TYPE_ACTIVITY_REWARD=3`),如有类型白名单补 69。
### ext_config 标准格式type=14
```json
{
"vip_rates": [
{"vip_level": 0, "rate_percent": 0},
{"vip_level": 1, "rate_percent": 0.5},
{"vip_level": 15, "rate_percent": 2.0}
],
"banner_image": ""
}
```
### cron
- `monthlyRebateAccrue` 每天 00:05`monthlyRebateSettle` 每月 1 日 00:15`monthlyRebateExpire` 每月 1 日 00:20。
### 与周返水共用注意
- 月返水与周返水各自独立表/配置/命令/流水类型,互不影响;同一用户同期可同时拥有日/周/月三套返水记录。
- 若日/周/月三套都上,建议抽取「曾充值判断 + profit 哈希解码 + 月/周边界」公共片段,避免三处复制(可选优化,不阻塞)。
### 收尾门禁
-`app/**/*.php` 后按 `slot-backend-completion-report` Skill 跑 `verify-slot-backend.sh` + docker `php -l`,回复附「检测结果」章节(含 PHPDoc:checked
</plan>
<todos>
[
{"id": "consts", "content": "slot_lib Consts 新增 ACTIVITY_TYPE_MONTHLY_REBATE=14、TRANSACTION_TYPE_MONTHLY_REBATE=69"},
{"id": "sql-model", "content": "monthly_rebate.sql + MonthlyRebateRecordModel(month_start/last_accrued_date/vip_level/rate_snapshot/四态/findByUidAndMonth)"},
{"id": "config-model-methods", "content": "RechargeGiftConfigModel 加 TYPE_MONTHLY_REBATE 与 getMonthlyRebateActiveInfo 系列方法"},
{"id": "services", "content": "MonthlyRebateCalcService(VIP费率单乘) 与 MonthlyRebateConfigService(vip_rates/maxRate/rateForVip/canonical)"},
{"id": "logic", "content": "MonthlyRebateLogicinfo/claim(CAS+钱包回滚)/accrue(写vip/rate快照)/settle(读VIP+过期上上月)/expire"},
{"id": "controller-route", "content": "MonthlyRebateController + MonthlyRebateValidator + 路由注册"},
{"id": "commands", "content": "命令 MonthlyRebateAccrue/Settle/Expire + cron"},
{"id": "activity-entry", "content": "ActivityConfigEntity 读写 type=14 走 canonicalExtConfigForStorage"},
{"id": "admin-stats", "content": "slot_admin 每月返水统计Model(s_common)/DTO/Validate/Controller/Logic(buildQuery月snap+list+statistics)"},
{"id": "admin-vue", "content": "slot_admin_vue monthlyRebateStats api.js + index.vue(筛选/总计/排序/统计周期)"},
{"id": "admin-menu-sql", "content": "menu-monthly-rebate-stats.sql 挂综合统计父菜单 + 分组授权"},
{"id": "wallet-verify", "content": "slot_wallet 确认流水类型69 打码归 活动赠送Y3"},
{"id": "deploy-doc", "content": "编写 monthly_rebate_deploy.md"},
{"id": "verify-gate", "content": "跑 verify-slot-backend.sh + php -l 输出检测结果章节"}
]
</todos>

View File

@@ -0,0 +1,92 @@
<!-- 22f17591-d664-4371-a1b6-9e4769285ad8 -->
---
todos:
- id: "middleware"
content: "新建 app/middleware/Language.php:读 X-Lang、归一化、每请求 locale()、设 $request->language"
status: pending
- id: "register"
content: "config/middleware.php 全局组在 TraceId 前注册 Language 中间件"
status: pending
- id: "config"
content: "config/translation.php 增加 supported=['zh_CN','en'],保留默认 zh_CN"
status: pending
- id: "trans-files"
content: "新建 resource/translations/{zh_CN,en}/messages.php,含 errors(按 OtaErrorCode)+ validation + common.ok"
status: pending
- id: "exception"
content: "BusinessException message 改可选;Handler 渲染时 trans('errors.'+code);Logic 7 处抛出去掉中文只传 code"
status: pending
- id: "result-auth"
content: "Result::fail 支持空 message 回退 trans;AdminAuth/InternalTokenAuth 去掉中文消息"
status: pending
- id: "validate"
content: "3 个 validator 的 $message 改翻译 key;AdminController::validate 返回前 trans();补全 validation.* 译文"
status: pending
- id: "verify"
content: "internal/admin 带 X-Lang 验证消息随 locale 变化 + 连续请求无串台,跑收尾检测脚本"
status: pending
isProject: false
---
# OTA 接口国际化(i18n)
## 结论先行
「中间件解析 header」思路正确,但在 Webman(常驻进程)里**核心动作是 `locale($lang)`**,不是把语言塞进 `$request->language``$request->language` 只作附带标记(给 Logic/日志用,非必须)。已具备:`config/translation.php``symfony/translation`。缺:语言中间件、翻译文件、把硬编码消息接到 `trans()`
约定:自定义头 `X-Lang`(值 `zh_CN` / `en`),默认 `zh_CN`,覆盖 internal + admin。
## 数据流
```mermaid
flowchart LR
req["HTTP 请求 X-Lang: en"] --> lang["Language 中间件 locale(en) + request->language"]
lang --> auth["Auth 中间件 Result::fail(code)"]
auth --> ctrl["Controller / Validate"]
ctrl --> logic["Logic throw BusinessException(code)"]
logic --> handler["Handler trans(code) 渲染"]
handler --> resp["{code, message(已按 locale 翻译), data}"]
```
## 改动点
### 1. 语言中间件(新增)
新建 `app/middleware/Language.php`(`MiddlewareInterface`):
-`X-Lang`(可兼容 `X-Language`),归一化(`zh`/`zh-CN`/`zh_cn``zh_CN`;`en`/`en-US``en`),不在支持列表则用默认。
- **每个请求都调 `locale($lang)`**(进程级全局状态,必须每请求重置,否则串语言)。
-`$request->language = $lang`(附带)。
- 支持列表 + 默认值读 `config('translation')`(见第 5 步)。
### 2. 注册为全局中间件
[config/middleware.php](config/middleware.php) 的 `''` 组在 `TraceId` 前加入 `app\middleware\Language::class`,确保 admin/internal 鉴权失败消息也按 locale 翻译。
### 3. 翻译文件(新增)
新建 `resource/translations/zh_CN/messages.php``resource/translations/en/messages.php`,返回两段:
- `errors` => 以 `OtaErrorCode` 各常量字符串为 key 的消息(对应现 Logic / Auth 里的中文)。
- `validation` => 校验消息 key(见第 6 步)。
调用形如 `trans('errors.' . $code)``trans($validationKey)`
### 4. 消息改为 trans() 取值
- [app/exception/Handler.php](app/exception/Handler.php):渲染 `BusinessException``message = trans('errors.' . $code)`(为空回退 code)。
- [app/exception/BusinessException.php](app/exception/BusinessException.php):`message` 改为可选;Logic 抛出时只传 code,去掉硬编码中文(`FirmwareLogic` 5 处、`OtaReportLogic`/`OtaCheckLogic`/`UpgradeRecordQueryLogic` 各 1 处)。需要动态文案的保留可选 message 入参。
- [app/support/Result.php](app/support/Result.php):`fail(string $code, ?string $message = null, ...)`,`$message` 为空时取 `trans('errors.' . $code)`;成功 `message``trans('common.ok')`
- 两个 Auth 中间件(`AdminAuth``InternalTokenAuth`)`Result::fail` 去掉中文,只传 code。
### 5. 配置补充
[config/translation.php](config/translation.php) 增加 `supported => ['zh_CN','en']`,`locale` 维持 `zh_CN` 作默认。中间件据此校验/回退。
### 6. 校验消息国际化(think\Validate)
为统一到单一 i18n(trans),不引入 think\Lang 第二套:
- 3 个 validator(`FirmwareCreateValidate``FirmwareListValidate``UpgradeRecordListValidate`)的 `$message` 值改为翻译 key(如 `validation.firmware.product_key_required`)。
- [app/admin/controller/AdminController.php](app/admin/controller/AdminController.php) `validate()` 返回前对错误信息做 `trans()`(key 命中则翻译,未命中原样返回)。
- 两个 locale 的 `messages.php` 补全这些 `validation.*` key。
## 验证
- internal:`POST /internal/ota/check` 带/不带 `X-Lang: en`,断言鉴权失败、业务异常、参数错误的 message 随 locale 变化。
- admin:固件创建非法参数,`X-Lang: en` 返回英文校验消息。
- 连续两请求(en 后 zh_CN)验证无 locale 串台(常驻进程回归)。
- 收尾跑 `~/.cursor/skills/slot-backend-completion-report/scripts/report.sh` 并附检测结果。
## 不做 / 待确认
- 不替换为 Accept-Language(已选自定义头)。
- 英文译文先给准确直译,文案润色后续可调。

View File

@@ -0,0 +1,99 @@
<!-- 27a4a527-c3b0-4e37-b4ed-ba7e02f51f70 -->
---
todos:
- id: "composer"
content: "在 composer.json 增加 vcs repository 并 docker composer require quanfuxia/log:dev-main"
status: pending
- id: "logconfig"
content: "改写 config/log.php 使用 SeasLogHandler + SeasLogLineFormatter + IntrospectionProcessor"
status: pending
- id: "middleware"
content: "新增 app/middleware/TraceId.php 调用 TraceContext::init并在 config/middleware.php 注册全局中间件"
status: pending
- id: "verify"
content: "php -l 校验、请求验证日志落盘,跑收尾门禁脚本"
status: pending
isProject: false
---
# 引入并配置 quanfuxia/log
## 背景
`quanfuxia/log``Quanfuxia\Log\`)是一个 Webman/Monolog → SeasLog 的日志适配库,核心组件:
- `SeasLogHandler`Monolog 记录写入 SeasLog自动 `SeasLog::setRequestID(traceId)`BasePath 设为 `runtime_path()/logs`
- `SeasLogLineFormatter`:日志行格式化
- `Trace\TraceContext`:基于 `Webman\Context``trace_id` / `request_time` 上下文
环境已确认:`php82` 容器存在 `SeasLog` 扩展PHP 8.2.24;库要求 `php>=8.2``monolog ^2.0`(项目已具备)。仓库仅有 `main` 分支、无 tag故按 `dev-main` 引入。容器内项目路径 `/app/www/ai-device/ota`
## 1. composer 引入(在 docker php82 内执行)
在 [composer.json](composer.json) 增加 `repositories` 与依赖:
- 顶层新增:
```json
"repositories": [
{ "type": "vcs", "url": "https://git.waixingkeji.net/quanfuxia/log.git" }
]
```
- `require` 增加 `"quanfuxia/log": "dev-main"`
执行安装(遵循 dev-environment 规则,宿主机不直接跑 composer
```bash
docker exec -w /app/www/ai-device/ota php82 composer require quanfuxia/log:dev-main
```
> 项目 `minimum-stability: dev` + `prefer-stable: true``dev-main` 可正常解析。私有仓库已验证可匿名 clone若 composer 拉取需鉴权再补充凭据。
## 2. 配置 config/log.php
将 [config/log.php](config/log.php) 的 `default` channel 由 `RotatingFileHandler` 改为 `SeasLogHandler`,并加上 `IntrospectionProcessor` 注入调用源(`extra.class/function/file/line`
```php
use Monolog\Logger;
use Monolog\Processor\IntrospectionProcessor;
use Quanfuxia\Log\SeasLogHandler;
use Quanfuxia\Log\SeasLogLineFormatter;
return [
'default' => [
'handlers' => [[
'class' => SeasLogHandler::class,
'constructor' => ['level' => Logger::DEBUG, 'bubble' => true],
'formatter' => [
'class' => SeasLogLineFormatter::class,
'constructor' => [
"%message% %extra.class%%extra.callType%%extra.function% %extra.file%:%extra.line% %context%\n",
'Y-m-d H:i:s', true, true,
],
],
]],
'processors' => [[
'class' => IntrospectionProcessor::class,
'constructor' => [Logger::DEBUG, ['support\\Log'], 0],
]],
],
];
```
> [config/process.php](config/process.php) 中 `'logger' => Log::channel('default')` 无需改动,框架日志将随之写入 SeasLog。
## 3. 新增 Trace 中间件初始化 trace_id
每次 HTTP 请求开始时初始化链路 ID使同一请求内日志 trace_id 一致,并支持上游透传。
- 新增 `app/middleware/TraceId.php`(实现 `Webman\MiddlewareInterface`):从请求头(如 `X-Trace-Id`)取值,调用 `Quanfuxia\Log\Trace\TraceContext::init($headerTraceId)`,再 `return $handler($request)`
- 在 [config/middleware.php](config/middleware.php) 注册为全局中间件:
```php
return [
'' => [ app\middleware\TraceId::class ],
];
```
> CLI / 非中间件入口无需额外处理:`TraceContext::getTraceId()` 在缺失时会自动生成并写回上下文。
## 4. 验证
```bash
docker exec -w /app/www/ai-device/ota php82 php -l config/log.php
docker exec -w /app/www/ai-device/ota php82 php -l app/middleware/TraceId.php
# 启动后请求一次接口,检查日志落盘
docker exec php82 sh -lc 'ls -R /app/www/ai-device/ota/runtime/logs'
```
并按 `slot-backend-completion-report` 跑收尾门禁。
## 注意 / 待确认
- `git_status` 显示 `composer.json`/`composer.lock` 已有 `vlucas/phpdotenv` 改动,本次只追加,不回退既有改动。
- 日志格式字符串、`X-Trace-Id` 头名称如需调整可后续微调(默认按 readme 示例)。

View File

@@ -0,0 +1,47 @@
---
name: refer-earn doc update
overview: 基于浏览器实测(数字随汇率浮动、佣金计算器拆解、分享弹窗细节),更新 docs/推荐返佣需求文档.md补全「1498.87 的来源与性质」「邀请奖金解锁机制」「佣金计算器」「立即邀请分享弹窗」四块内容。
todos:
- id: invite-bonus
content: 更新 §3.3/§3.41498.87 = 1500 USDT 基准÷汇率,性质为每位好友锁定奖金上限,配置存整数 USDT
status: completed
- id: calculator
content: 新增佣金计算器章节:入口/路由、输入项、输出口径、实测样例数据表
status: completed
- id: share-modal
content: 补全立即邀请分享弹窗:社交渠道、推荐链接/二维码/推荐码+复制
status: completed
- id: usdt-fx
content: §5 补 USDT 基准币 + 汇率折算导致展示浮动§6 配置项补基准额/解锁速率/上限
status: completed
- id: open-questions
content: §7 补充邀请奖金解锁公式与上限、计算器与费率表一致性 待确认项
status: completed
isProject: false
---
# 更新推荐返佣需求文档
仅修改 [docs/推荐返佣需求文档.md](docs/推荐返佣需求文档.md) 一个文件,把本轮浏览器实测结论补进去。无代码改动。
## 关键实测结论(写入依据)
- 重载页面后数字变化:邀请奖金 `1498.87 → 1499.01`、EXP `4,996.27 → 4,996.71`。证实「整数基准 + 实时汇率」模型。
- 佣金计算器输出 `US$ ≈ USDT`,证实**基准币为 USDT**USD 为汇率折算、随汇率浮动。
- `1498.87 ≈ 1500 USDT / 实时汇率`,是**每位被邀请好友可解锁的邀请奖金上限(单人 + 上限)**,非即时白送。
- 计算器实验Lv9 / 老虎机 / 存款 0USD下注 1万→144.96、10万→1,174.86、100万→10,699.47;存款 1000 额外 +50.00=5%,确认 Lv9 存款奖金率)。
- 按 Lv9 老虎机团队 A 佣金 1%100/10k拆分剩余 ≈45/175/699 随投注增长但边际递减 → 符合「锁定邀请奖金按投注解锁且封顶」模型(精确曲线待产品确认)。
## 文档改动点
- **§3.3 / §3.4(邀请奖金)**:明确 `1498.87` = `1500 USDT 基准 ÷ 实时汇率`性质为「每位好友锁定奖金上限」由该好友投注逐步解锁落地配置应存整数基准USDT展示层做汇率折算不得硬编码 `1498.87`
- **新增 §3.x 佣金计算器**:记录入口(可领奖金卡片左侧计算器图标,路由 `/refer-earn-calc`)、输入项(我的代理级别 Lv1Lv9、朋友货币类型、朋友存款金额、朋友赌注分类、朋友下注金额、输出预期奖金 `US$ ≈ USDT`)、口径(= 存款奖金 + 投注佣金 + 解锁的邀请奖金),并附本轮实测样例数据表。
- **补全 §3.4「立即邀请」分享弹窗**:标题「分享到」,社交渠道 Facebook / WhatsApp / LINE / Messenger / Telegram / X / Email推荐链接 `https://vpgame1.vip/?c=<推荐码>`、推荐二维码、推荐代码(如 `aP53Alj`+ 复制按钮。
- **§5 业务规则**:补「金额基准币为 USDT前端按实时汇率折算为展示币种故展示值会随汇率浮动」。
- **§6 落地建议**`referral_global_config` 增加「邀请奖金基准额USDT 整数)、单人解锁速率、单人解锁上限」;明确展示层汇率折算职责。
- **§7 待确认**:把「邀请奖金单人解锁公式与上限(实测 1M 投注仅解锁约 699未达 1500 上限,曲线疑似边际递减)」「各等级/各游戏类型佣金率与计算器是否完全一致」列为重点对齐项。
## 验证
- 文档为 markdown无 PHP 改动,不触发后端校验门禁。
- 自查保留原文档结构与已有表格新增内容与实测数据一致、单位标注USD/USDT/每10k清晰。

View File

@@ -0,0 +1,78 @@
<!-- 254c9e72-64e2-4a49-bb96-cb80c68065d7 -->
---
todos:
- id: "config"
content: "新建 slot-user/config/register_bonus.php从 env 读默认开关/金额(大单位)/打码倍数,支持按 source 覆盖"
status: pending
- id: "service"
content: "新建 slot-user/app/service/wallet/RegisterBonusService.php读配置+Redis NX 幂等+slot_sdk WalletClient->register+fail-open删除旧 activity 网关"
status: pending
- id: "callers"
content: "AbstractRegisterService::grantRegisterBonusAfterNewUserRegistered 与 EventRegister::triggerRegisterBonus 切换到新服务"
status: pending
- id: "sdk-wagering"
content: "修复 slot_sdk WalletService::register() 补传 bonus_wagering"
status: pending
- id: "activity-cleanup"
content: "确认无其他调用方后,下线 slot-activity 注册赠金 innerapi 与 slot_sdk ActivityService::triggerRegisterBonus 相关代码"
status: pending
- id: "verify"
content: "注册各入口+MQ 兜底验证幂等与 wagering跑后端门禁脚本"
status: pending
isProject: false
---
# 注册赠金从活动服解耦到用户服
## 背景与现状
当前链路:`slot-user` 注册 → [`RegisterBonusGatewayService`](slot-user/app/service/activity/RegisterBonusGatewayService.php) → (slot_sdk) `ActivityClient` → [`slot-activity` `RegisterBonusLogic`](slot-activity/app/innerapi/logic/RegisterBonusLogic.php)(查活动配置 + Redis 幂等锁 + 调钱包 register
问题:注册赠金强依赖活动服;活动服不可用时新用户拿不到赠金(且 register 类型钱包调用是钱包账户创建入口,间接影响)。
目标(已确认):**完全解耦**,赠金配置放 slot-user 的 **config 文件**,由用户服直接调钱包,不再依赖 slot-activity。
```mermaid
flowchart LR
reg["slot-user 注册 / EventRegister MQ"] --> svc["新 RegisterBonusService(读 config + Redis 幂等)"]
svc -->|slot_sdk WalletClient->register| wallet["slot-wallet RegisterService 入账+建账户"]
```
## 关键修复点(必须)
- **slot_sdk 漏传 `bonus_wagering`**[`WalletService::register()`](slot_sdk/src/service/wallet/WalletService.php) 仅拼 `type/fee/bonus` + base payload`bonus_wagering` 被丢弃,导致钱包侧 [`RegisterService::resolveRegisterBonusRequiredWager`](slot-wallet/app/service/wallet/RegisterService.php) 始终按默认 1 倍。需补传。
- **幂等归属转移**:钱包 `register``biz_id` 去重,原幂等靠活动服 Redis NX 锁。解耦后由 slot-user 持有 Redis NX 锁(沿用 `biz_id = register:bonus:{uid}`),避免同步路径与 `EventRegister` MQ 兜底重复发放。
## 改动清单
### 1. slot-user新增赠金配置config 文件)
- 新建 `slot-user/config/register_bonus.php`:从 env 读默认开关/金额(大单位)/打码倍数,并支持按 `source` 覆盖;如 `enabled``default => ['amount' => env('REGISTER_BONUS_AMOUNT',0), 'wagering' => env('REGISTER_BONUS_WAGERING',1)]``sources => ['pwa_a' => [...]]`
- 金额大单位经 `slot\foundation\Tools\MoneyTool::bigUnitToSmallUnit` 转最小单位;币种取自 `UserShardModel->currency`
### 2. slot-user新增直连钱包的赠金服务替换活动网关
- 新建 `slot-user/app/service/wallet/RegisterBonusService.php`(公共能力:读配置 + 跨服务调钱包):
-`source` 解析金额/倍数金额≤0 或未启用 → 记日志跳过。
- Redis `SET NX EX` 幂等锁key 含 uid命中重复直接返回。
- 经 slot_sdk `WalletClient->register(WalletRegisterEntity{uid,source,currency,organization,biz_id,bonus,bonus_wagering})` 入账。
- fail-opencatch `Throwable``LoggerService::error`,不抛出(不阻断注册);失败时删除锁以便 MQ 兜底重试。
- 日志含 `uid/source/currency/bonus/biz_id`
- 删除 [`slot-user/app/service/activity/RegisterBonusGatewayService.php`](slot-user/app/service/activity/RegisterBonusGatewayService.php)。
### 3. slot-user切换调用方
- [`AbstractRegisterService::grantRegisterBonusAfterNewUserRegistered`](slot-user/app/service/register/AbstractRegisterService.php) 改调新 `RegisterBonusService`(影响 6 个 RegisterService 子类,无需逐个改)。
- [`EventRegister::triggerRegisterBonus`](slot-user/app/command/EventRegister.php) 同步改为新服务(保留 MQ 兜底语义)。
### 4. slot_sdk补传 bonus_wagering
- [`WalletService::register()`](slot_sdk/src/service/wallet/WalletService.php) 的 `WalletUpdateEntity` payload 增加 `'bonus_wagering' => $entity->bonus_wagering``WalletUpdateEntity`/钱包 DTO 已支持该字段)。
### 5. slot-activity下线注册赠金入口清理确认无其他调用方后执行
- 删除 `slot-activity/app/innerapi/{controller/RegisterBonusController, logic/RegisterBonusLogic, dto/RegisterBonusTriggerDto, validate/RegisterBonusValidate}.php` 及其路由。
- 删除 slot_sdk `ActivityService::triggerRegisterBonus``RegisterBonusTriggerEntity`/响应实体grep 确认仅 slot-user 旧网关使用)。
- `activity_type=7 注册赠送` 活动数据保留与否由运营决定,本次不动表结构。
## 待确认 / 决策
- **是否对每个新用户都调一次 register 以确保钱包账户必建**(即便赠金为 0当前钱包账户由 register 调用创建,建议赠金=0 时仍调用 `register(bonus=0)` 兜底建账户;若钱包账户另有创建入口则无需。实现时按现状默认「仅金额>0 才发」,此点单独标注。
-`source` 区分金额的具体取值由运营提供config 先给 `default`
## 验收
- 注册(各 RegisterService+ MQ 兜底各跑一次,确认:赠金按配置入账一次(幂等不重复)、`bonus_wagering` 生效(钱包 fund lot `required_wager` 正确)、活动服关停不影响注册与发放。
- 收尾执行 `slot-backend-completion-report` 门禁verify + `php -l`)。

View File

@@ -0,0 +1,155 @@
<!-- 82f6bca9-5c06-4eb6-bb4f-d44d3dbdfbc3 -->
---
todos:
- id: "add-compose-service"
content: "在 docker-compose.yml 新增 slotMysql 服务(镜像 8.0.33、卷 datadir_slot_mysql、端口 3310"
status: pending
- id: "start-slotmysql"
content: "docker compose up -d slotMysql 并等待 mysqladmin ping 就绪"
status: pending
- id: "dump-restore"
content: "mysqldump 全库管道导入 slotMysql--single-transaction --set-gtid-purged=OFF"
status: pending
- id: "verify-data"
content: "对比库列表并抽样校验表行数,确认复制成功"
status: pending
isProject: false
---
# 创建 slotMysql 并复制 goMysql 数据
## 现状
| 项目 | goMysql现有 |
|------|----------------|
| 镜像 | `mysql:8.0.33` |
| 数据卷 | [`docker/data/datadir_go_mysql`](/Users/ray/Documents/project/docker/data/datadir_go_mysql)(约 7.3G |
| 宿主机端口 | `3309` → 容器 `3306` |
| root 密码 | `root` |
| 库数量 | 约 70 个(全库复制) |
配置来源:[`docker/docker-compose.yml`](/Users/ray/Documents/project/docker/docker-compose.yml) 第 4153 行。
## 目标架构
```mermaid
flowchart LR
subgraph docker_net [default network]
goMysql["goMysql :3306"]
slotMysql["slotMysql :3306"]
php82[php82]
end
host3309["宿主机 :3309"] --> goMysql
host3310["宿主机 :3310"] --> slotMysql
php82 -->|"现有 .env 仍连 goMysql"| goMysql
goMysql -->|"mysqldump 全库"| slotMysql
```
两容器并行运行、数据独立;应用暂不切换。
## 步骤 1在 docker-compose 中新增 slotMysql
编辑 [`docker/docker-compose.yml`](/Users/ray/Documents/project/docker/docker-compose.yml),在 `goMysql` 服务后追加:
```yaml
slotMysql:
image: mysql:8.0.33
container_name: slotMysql
working_dir: /app
volumes:
- /Users/ray/Documents/project:/app
- ./data/datadir_slot_mysql:/var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD=root
ports:
- "3310:3306"
networks:
- default
```
要点:
- **独立数据卷** `./data/datadir_slot_mysql`(首次 `up` 时自动初始化空实例)
- **宿主机端口 `3310`**`3309` 已被 goMysql 占用)
- 与 goMysql 同镜像、同密码、同网络,便于容器内互访
## 步骤 2启动 slotMysql 并等待就绪
```bash
cd /Users/ray/Documents/project/docker
docker compose up -d slotMysql
```
轮询直到可连接(约 3060 秒):
```bash
docker exec slotMysql mysqladmin -uroot -proot ping
```
## 步骤 3全库逻辑复制推荐方式
**选用逻辑导出/导入**,原因:
- goMysql **无需停机**,现有应用不受影响
- 避免直接拷贝 `datadir` 带来的 InnoDB 文件锁/UUID 冲突风险
- 两容器版本一致(`8.0.33`),兼容性有保障
在宿主机执行(管道直连,不落盘大 SQL 文件):
```bash
docker exec goMysql mysqldump -uroot -proot \
--all-databases \
--single-transaction \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
| docker exec -i slotMysql mysql -uroot -proot
```
参数说明:
- `--single-transaction`InnoDB 一致性快照,复制期间 goMysql 可继续读写
- `--set-gtid-purged=OFF`:本地开发环境无 GTID 复制,避免导入报错
**预计耗时**7.3G 数据约 1545 分钟,视磁盘 IO 而定;期间勿重启两容器。
### 备选方案(未采用)
直接 `cp -a datadir_go_mysql → datadir_slot_mysql` 需**停止 goMysql**,且有 binlog/ibdata 一致性风险;仅适合可接受停机且追求极速的场景。本次不采用。
## 步骤 4验证数据一致性
```bash
# 库数量对比
docker exec goMysql mysql -uroot -proot -N -e "SHOW DATABASES;" | sort > /tmp/go_dbs.txt
docker exec slotMysql mysql -uroot -proot -N -e "SHOW DATABASES;" | sort > /tmp/slot_dbs.txt
diff /tmp/go_dbs.txt /tmp/slot_dbs.txt
# 抽样校验行数(示例)
docker exec goMysql mysql -uroot -proot -N -e "SELECT COUNT(*) FROM s_common.some_table"
docker exec slotMysql mysql -uroot -proot -N -e "SELECT COUNT(*) FROM s_common.some_table"
```
连通性验证:
- 容器内:`docker exec slotMysql mysql -uroot -proot -e "SELECT 1"`
- 宿主机:`mysql -h127.0.0.1 -P3310 -uroot -proot -e "SHOW DATABASES;"`(若本机有 mysql 客户端)
## 步骤 5日常使用说明
| 访问方式 | 命令/地址 |
|----------|-----------|
| 容器内php82 等) | `slotMysql:3306`,用户 `root`,密码 `root` |
| 宿主机 | `127.0.0.1:3310` |
| 启停 | `docker compose up -d slotMysql` / `docker compose stop slotMysql` |
**不改动** [`www/slot`](/Users/ray/Documents/project/www/slot) 及各服务 `.env` 中的 `DB_*_HOST = 'goMysql'`;后续若要切换,再批量改 host 并重启对应 Webman 进程。
## 风险与注意
1. **复制期间 goMysql 有新写入**`--single-transaction` 保证 InnoDB 表一致MyISAM 表(若有)可能略有偏差,开发环境通常可接受。
2. **磁盘空间**`datadir_slot_mysql` 将再占约 7G+,确保 `/Users/ray/Documents/project/docker/data` 所在磁盘有余量。
3. **复制失败重试**:若导入中断,最干净做法是 `docker compose stop slotMysql`,删除 `datadir_slot_mysql` 目录后重新 `up` 再导入。
4. **用户/权限**`--all-databases` 会同步 `mysql` 系统库中的用户与授权root 密码与 goMysql 一致。
## 涉及文件
- 修改:[`docker/docker-compose.yml`](/Users/ray/Documents/project/docker/docker-compose.yml)
- 新建(自动):`docker/data/datadir_slot_mysql/`
- 不修改:各服务 `.env``php82` 等现有配置

View File

@@ -0,0 +1,150 @@
<!-- 06741b7f-ce5d-4d77-8966-93bfe93d396a -->
---
todos:
- id: "migrate-log-calls"
content: "13 个文件LoggerService::info/error/warning → support\\Log统一 context 为 array"
status: pending
- id: "migrate-trace-timing"
content: "InnerCurlService / OutputService / EventBus改用 Context + TraceIdMiddleware::initContext"
status: pending
- id: "delete-logger-service"
content: "删除 LoggerService.php更新 README §8.1"
status: pending
- id: "fix-handler-syntax"
content: "修复 app/exception/Handler.php 末尾多余花括号"
status: pending
- id: "verify"
content: "grep 无残留引用 + 跑 report.sh + CLI/HTTP/MQ 冒烟"
status: pending
isProject: false
---
# slot_agent 废弃 LoggerService 完整迁移
## 结论
**可以不再使用 `LoggerService` 写日志**,但不能「只配好 `config/log.php` 就删类」——当前仍有 **13 个文件、约 56 处** `LoggerService::` 调用,且 3 处依赖其 trace/timing 工具方法。
你已有的新栈已覆盖旧能力:
| 能力 | LoggerService | 新配置 |
|------|---------------------|--------|
| 落盘 | 直接 `SeasLog::*` | [`config/log.php`](slot_agent/config/log.php) → `SeasLogHandler` |
| traceId | `$_SERVER['TRACE_ID']` 手动拼进 message | [`TraceIdMiddleware`](slot-foundation/src/Middleware/TraceIdMiddleware.php) → `Context::get('trace_id')` + `SeasLog::setRequestID` |
| 调用位置 | `debug_backtrace` 手动 JSON | `IntrospectionProcessor`(已排除 `support\Log` |
| 请求耗时 | `$_SERVER['REQUEST_TIME_FLOAT']`HTTP 几乎未初始化) | `Context::get('request_time')`Middleware 已写入) |
当前项目处于 **双轨并存**会导致同一次请求里日志格式、trace 来源不一致:
- 已用 `support\Log`[`AuthMiddleware`](slot_agent/app/middleware/AuthMiddleware.php)、[`RabbitMqService`](slot_agent/app/service/RabbitMqService.php)、[`Test`](slot_agent/app/command/Test.php)
- 仍用 `LoggerService`EventBus、各 Service/Logic、Exception Handler 等
```mermaid
flowchart LR
subgraph old [旧路径]
LS[LoggerService] --> SeasLogDirect[SeasLog 直连]
LS --> ServerTrace["$_SERVER TRACE_ID"]
end
subgraph new [新路径]
LogFacade["support\\Log"] --> Monolog[Monolog]
Monolog --> Handler[SeasLogHandler]
Handler --> SeasLogCtx[SeasLog + Context trace_id]
TraceMW[TraceIdMiddleware] --> Context[Context]
end
```
---
## 迁移范围(按职责)
### 1. 写日志:统一改为 `support\Log`
涉及文件(全部在 `slot_agent/app/` 下):
- [`command/EventBus.php`](slot_agent/app/command/EventBus.php)15 处)
- [`innerapi/logic/AgentLogic.php`](slot_agent/app/innerapi/logic/AgentLogic.php)
- [`service/ReferralRewardConfigService.php`](slot_agent/app/service/ReferralRewardConfigService.php)9 处)
- [`service/InnerCurlService.php`](slot_agent/app/service/InnerCurlService.php)8 处日志)
- [`service/UserReferralGatewayService.php`](slot_agent/app/service/UserReferralGatewayService.php)
- [`service/ReferralRewardService.php`](slot_agent/app/service/ReferralRewardService.php)
- [`service/activity/UserAgentRegisterService.php`](slot_agent/app/service/activity/UserAgentRegisterService.php)
- [`service/LuckyRewardInviteCallbackGatewayService.php`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php)
- [`service/ShareConfigService.php`](slot_agent/app/service/ShareConfigService.php)
- [`exception/Handler.php`](slot_agent/app/exception/Handler.php)
- [`innerapi/exception/Handler.php`](slot_agent/app/innerapi/exception/Handler.php)
**替换约定**(与现有 `AuthMiddleware` 对齐):
```php
// 旧
LoggerService::info(__METHOD__, $payload);
// 新 — context 必须是 array标量包一层
Log::info(__METHOD__, is_array($payload) ? $payload : ['msg' => $payload]);
```
```php
// 旧 — Throwable
LoggerService::error(__METHOD__, $exception);
// 新 — 与 AuthMiddleware 一致
Log::error($exception);
// 或需要业务前缀时:
Log::error(__METHOD__, ['msg' => $exception->getMessage(), 'file' => $exception->getFile(), 'line' => $exception->getLine()]);
```
CLI/MQ 消费若需独立 trace在 handler 入口调用一次:
```php
use Slot\Foundation\Middleware\TraceIdMiddleware;
TraceIdMiddleware::initContext(); // 替代 LoggerService::initGenerateTraceId()
```
[`EventBus::deal()`](slot_agent/app/command/EventBus.php) 第 59 行即此处。
### 2. trace / timing改用 Context不再读 `$_SERVER`
| 文件 | 旧 API | 新 API |
|------|--------|--------|
| [`InnerCurlService`](slot_agent/app/service/InnerCurlService.php) | `LoggerService::getTraceId()` | `Context::get('trace_id')`;空则 `TraceIdMiddleware::initContext()` 后再取 |
| [`OutputService`](slot_agent/app/service/OutputService.php) | `LoggerService::getRequestTime()` | `Context::get('request_time', microtime(true))``timing = microtime(true) - $start` |
| [`EventBus`](slot_agent/app/command/EventBus.php) | `initGenerateTraceId()` | `TraceIdMiddleware::initContext()` |
`TRACE_CHILD_ID` / `setChildId` / `initRequestTime` 在 slot_agent **无任何调用**,删除 `LoggerService` 时一并移除即可。
### 3. 删除与文档
- 删除 [`app/service/LoggerService.php`](slot_agent/app/service/LoggerService.php)
- 更新 [`README.md`](slot_agent/README.md) §8.1:由「统一 `LoggerService`」改为「统一 `support\Log` + `Context trace_id`
- **保留** [`AppBootStrap`](slot_agent/app/AppBootStrap.php):仍负责 SeasLog 按 worker 分目录(`cli` / `webman`),与 `SeasLogHandler` 互补
### 4. 顺带修复(改 Handler 时)
[`app/exception/Handler.php`](slot_agent/app/exception/Handler.php) 文件末尾有多余 `{\n\n}`,迁移时一并去掉,避免语法问题。
---
## 不需要改动的部分
- [`config/log.php`](slot_agent/config/log.php) — 已就绪
- [`config/middleware.php`](slot_agent/config/middleware.php) — `TraceIdMiddleware` 已在最前
- [`config/process.php`](slot_agent/config/process.php) — 已注入 `Log::channel('default')`
---
## 验证
1. 跑 completion report`SLOT_ROOT=/Users/ray/Documents/project/www/slot/slot_agent ~/.cursor/skills/slot-backend-completion-report/scripts/report.sh`
2. 手动冒烟:
- HTTP`AuthMiddleware` 已有 `Log::info`,确认 `runtime/logs/webman/` 含 trace + 调用栈
- CLI`php webman test`[`Test.php`](slot_agent/app/command/Test.php) 已有 Log 示例)
- MQ`event:bus` 消费一条消息,确认 CLI 日志有独立 trace_id
3. 确认全仓库 `slot_agent` 内无 `LoggerService` 引用
---
## 风险与注意
- **日志格式会变**:旧格式为 `trace_id|Class::method | json {"file":...}` 单行;新格式由 `SeasLogLineFormatter` + `IntrospectionProcessor` 决定。若 ELK/告警规则按旧格式解析,需同步调整。
- **跨服务 trace 传递**`InnerCurlService` 请求头 `traceId` 应改为传 `Context::get('trace_id')`,与 [`TraceIdMiddleware`](slot-foundation/src/Middleware/TraceIdMiddleware.php) 一致(不再用 `trace_id:xxx` 前缀的 uniqid 格式)。
- **OutputService timing 行为会修正**HTTP 请求将正确使用 Middleware 写入的 `request_time`,不再依赖几乎未初始化的 `REQUEST_TIME_FLOAT`

View File

@@ -0,0 +1,123 @@
---
name: Spin 响应补全字段
overview: 当前 `POST /api/lucky-reward/spin` 仅返回 `poolItemId` 与抽奖后总 `myAmountQf`,未返回本次命中的 `prizeType` 与实际入账 `prizeAmount`,与 [04 方案](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md) 及 PRD 中奖弹窗需求不符。需在 `SpinResultEntity` 补字段并在 Logic 填充。
todos:
- id: extend-spin-result-entity
content: SpinResultEntity 增加 prizeType、prizeAmount 及中文属性注释
status: cancelled
- id: fill-spin-logic-response
content: executeManualSpin 返回时填充 drawResult.prizeType 与 formatAmountDisplay(creditedQf)
status: cancelled
- id: update-spin-api-docs
content: 更新 lucky_reward_api.md及 deploy 冒烟说明)响应字段与示例
status: cancelled
- id: add-spin-response-test
content: 补充单元测覆盖 random/spin/cashout 三种 prizeType 与 prizeAmount 断言
status: cancelled
isProject: false
---
# Spin 响应补全:中奖类型与中奖金额
## 现状(你的判断是对的)
[`SpinResultEntity`](slot_console/app/entity/luckyReward/SpinResultEntity.php) 当前只有:
| 字段 | 含义 |
|------|------|
| `poolItemId` | 命中的奖池格 ID前端可对照 `records`/`status` 里的 `poolItems` 反查类型,但**不是直接返回类型** |
| `myAmountQf` | 抽奖**之后**的 My Amount 总额(大单位 float |
| `spinAvailable` / `playerStatus` / `isDuplicate` | 状态 |
[`executeManualSpin`](slot_console/app/api/logic/LuckyRewardLogic.php) 事务内已算出 `$drawResult->prizeType``$creditedQf`(随机金额封顶后的**实际入账**),但组装响应时**未写入 Entity**
```275:281:slot_console/app/api/logic/LuckyRewardLogic.php
return new SpinResultEntity([
'poolItemId' => $drawResult->poolItemId,
'myAmountQf' => LuckyRewardAmountService::formatAmountDisplay3((int) $activityPlayer->my_amount_qf),
'spinAvailable' => (int) $activityPlayer->spin_available,
'playerStatus' => (int) $activityPlayer->status,
'isDuplicate' => false,
]);
```
[`lucky_reward_api.md`](slot_console/doc/lucky_reward_api.md) §3 文档同样未描述 `prizeType` / `prizeAmount`。
与设计文档冲突点:[04 方案 §5 第 7 步](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md) 明确要求返回「**命中类型、金额**、刷新后的 my_amount/次数」PRD §3.2 也要求按类型展示不同中奖弹窗,且随机金额弹窗展示金额须与**实际入账**一致(封顶后 `credited_qf`)。
```mermaid
sequenceDiagram
participant Client
participant SpinAPI as POST_spin
participant Logic as LuckyRewardLogic
participant Draw as DrawService
Client->>SpinAPI: 手动 Spin
SpinAPI->>Logic: executeManualSpin
Logic->>Draw: draw
Draw-->>Logic: prizeType + rawPrizeAmountQf
Logic->>Logic: applySpinPrize -> creditedQf
Note over Logic: 现况prizeType/creditedQf 只写 DB 与日志
Logic-->>Client: poolItemId + myAmountQf缺本次中奖字段
```
---
## 建议补全方案
### 1. 扩展 `SpinResultEntity`
在 [`SpinResultEntity.php`](slot_console/app/entity/luckyReward/SpinResultEntity.php) 新增:
- **`prizeType`**`int`):见 [`RewardPoolItemModel::PRIZE_TYPE_*`](slot_console/app/model/common/RewardPoolItemModel.php)
- `1` 随机金额 / `2` 1x Spin / `3` Cash Out
- **`prizeAmount`**`float`**本次 Spin 实际展示/入账金额**(大单位,与 `openBox.initAmount`、`records.items[].prizeAmount` 口径一致)
赋值规则(与落库 [`spin_record.prize_amount_qf`](slot_console/app/api/logic/LuckyRewardLogic.php) 一致):
| prizeType | prizeAmount |
|-----------|-------------|
| 随机金额 (1) | `formatAmountDisplay($creditedQf)`(封顶后实际入账,非 raw 随机值) |
| 1x Spin (2) | `0` |
| Cash Out (3) | `0` |
保留现有 `poolItemId`(转盘停格动画)与 `myAmountQf`(更新后总额),不破坏兼容。
### 2. Logic 填充
[`LuckyRewardLogic::executeManualSpin`](slot_console/app/api/logic/LuckyRewardLogic.php) 返回处增加:
```php
'prizeType' => $drawResult->prizeType,
'prizeAmount' => LuckyRewardAmountService::formatAmountDisplay($creditedQf),
```
无需改 Controller仍 `$spinResultEntity->activeData()`)。
### 3. 文档
更新 [`lucky_reward_api.md`](slot_console/doc/lucky_reward_api.md) §3 响应表与 JSON 示例;[`lucky_reward_deploy.md`](slot_console/doc/lucky_reward_deploy.md) 冒烟项可补一句「spin 响应含 prizeType/prizeAmount」。
可选:同步 [04 方案](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md) 示例 JSON若文档有旧字段名
### 4. 测试
- 新增或扩展 Logic 单元测mock draw 结果):断言 random 命中时 `prizeAmount == creditedQf` 展示值、`prizeType == 1`Spin/CashOut 时 `prizeAmount == 0`。
- 若有 YApi #557 spin 接口文档,一并更新字段说明。
---
## 不在本次范围
- 不改抽奖算法、封顶逻辑、Redis 锁。
- 不改 `records` 列表结构Record Tab 仍只有 `prizeAmount`,无 `prizeType`;若前端 Record 也要类型可另开需求)。
- `isDuplicate` 目前恒为 `false`(无 spin 幂等重放路径),本次不扩展。
---
## 前端使用建议(供联调)
- **停格**`poolItemId`
- **弹窗类型**`prizeType`
- **弹窗金额 / 飞金币**`prizeAmount`(仅 type=1 时 >0
- **进度条/My Amount 刷新**`myAmountQf`

View File

@@ -0,0 +1,157 @@
---
name: Spin去spin_id加锁
overview: 手动 Spin 去掉前端必填 `spin_id`,改为 Redis 用户锁防并发 + 事务内 `spin_available` 校验;`spin_id` 仅服务端写库用;连点拿不到锁立即报错。
todos:
- id: api-validator
content: spin API/Validator去掉 spin_id 入参Controller 两参调用 Logic
status: completed
- id: logic-lock
content: LuckyRewardLogicSET NX 用户锁 fail-fast + 服务端生成 spin_id + 移除客户端幂等分支
status: completed
- id: tests-docs
content: RedisKeyManagerService 锁 key + 单测/文档/YApi 更新
status: completed
isProject: false
---
# Spin 去掉前端 spin_idRedis 锁 + 次数校验)
## 产品约定(已确认)
- **防并发**Redis 用户级锁,连点第二笔 **拿不到锁立即报错**(不重试等待)。
- **防重复扣次**:事务内校验 `spin_available > 0`,成功则 `-1`;无次数则拒绝。
- **不做**客户端幂等 / 弱网重试幂等:超时后若已扣次,再次 spin 会报「无可用 Spin」→ **前端应改调 `status` / `records` 刷新**,勿无脑重试 spin。
```mermaid
sequenceDiagram
participant FE as Frontend
participant Logic as LuckyRewardLogic
participant Redis
participant DB
FE->>Logic: POST spin 无 body
Logic->>Redis: SET lock uid NX TTL 5s
alt lockFail
Redis-->>Logic: 未拿到
Logic-->>FE: BusinessException Spin处理中
else lockOk
Logic->>DB: spin_available>0 校验+抽奖+写record
Logic->>Redis: unlock
Logic-->>FE: SpinResultEntity
end
```
---
## 代码改动slot_console
### 1. API 层
**[`LuckyRewardController::spin()`](slot_console/app/api/controller/LuckyRewardController.php)**
- 移除 `LuckyRewardValidator::SCENE_SPIN` 校验及对 `spin_id` 的读取。
- 调用 `$this->logic->spin($uid, $source)`(两参)。
**[`LuckyRewardValidator`](slot_console/app/api/validator/LuckyRewardValidator.php)**
- 删除 `SCENE_SPIN` / `spin_id` 规则(或保留空 scene 备用spin 接口不再走 Validate。
### 2. Logic 核心
**[`LuckyRewardLogic::spin()`](slot_console/app/api/logic/LuckyRewardLogic.php)**
| 变更 | 说明 |
| --- | --- |
| 签名 | `spin(int $uid, string $source): SpinResultEntity` |
| 删除 | 入口 `findByUidAndSpinId` + `buildSpinResultFromRecord` 的**客户端幂等**分支 |
| 新增 | 方法开头 `acquireSpinLock($uid)` / `finally releaseSpinLock` |
| 新增 | 服务端生成 `spinId``manual:{uid}:{cycleId}:{uniqid()}`(或 `random_bytes` 短串),仅用于 `lucky_reward_spin_record.spin_id` 满足 `uk_uid_spinid` |
| 保留 | 开宝箱校验、`grantDailySpinIfNeeded``ensureUserCanSpin`、事务内二次 `spin_available` 校验、抽奖与落库逻辑 |
| 响应 | `SpinResultEntity.spinId` 仍返回(服务端生成,供 records 展示);`isDuplicate` 固定 `false`(或后续从 Entity 移除该字段需评估 FE 兼容性,建议先保留恒 false |
**Redis 锁实现**(复用现有 [`RedLock`](slot_console/app/service/RedLock.php)
```php
// RedisKeyManagerService 新增
/** Lucky Rewards 手动 Spin 用户锁前缀 */
const LUCKY_REWARD_SPIN_LOCK = 'lucky_reward:spin:lock:';
// Logic 内
$lockKey = RedisKeyManagerService::luckyRewardSpinLockKey($uid);
$lock = RedLock::getInstance('default')->lock($lockKey, 5000);
if ($lock === false) {
throw new BusinessException('Spin is in progress, please try again');
}
try {
// ... 原 spin 业务 ...
} finally {
RedLock::getInstance('default')->unlock($lock);
}
```
- TTL **5000ms**:覆盖一次抽奖+事务;`RedLock::lock` 默认 `retryCount=3` 会在内部短暂重试——若需严格「一次拿不到就失败」,在 Logic 侧用 **单次 `SET NX`**`Redis::set($key, $token, 'EX', 5, 'NX')`)替代 RedLock或给 RedLock 封装 `tryLockOnce()`。**建议**:新增 private `tryAcquireSpinLockOnce()``SET NX EX 5`,符合 fail-fast 选型。
### 3. 辅助方法
- [`RedisKeyManagerService`](slot_console/app/service/RedisKeyManagerService.php):新增 `luckyRewardSpinLockKey(int $uid): string`
- 可选private `generateManualSpinId(int $uid, int $cycleId): string` 集中生成写库 id。
---
## 不改 / 兼容
- **表结构**`lucky_reward_spin_record.spin_id` + `uk_uid_spinid` 保留;值改为服务端生成。
- **records API**:仍返回 `spinId` 字段,无 breaking change。
- **开宝箱自动首次 Spin**:仍用 `auto_first:{uid}:{cycle_id}`,不受影响。
---
## 测试
| 项 | 做法 |
| --- | --- |
| 单元测 | 新增 `LuckyRewardSpinLockUnitTest`mock Redis 或使用可注入 lock 的测试双(若难 mock至少测 `generateManualSpinId` + validator 无 spin_id |
| Logic 单测 | 更新/新增:无 `spin_id` 参数签名;`isDuplicate` 恒 false |
| 集成测 | 若有 DB spin 集成测则去掉 payload `spin_id`;并发锁集成测可选(需 Redis |
当前仓库 **无** spin 集成测,以 Logic 单测 + 手动冒烟为主。
---
## 文档
- [`04_slot_console_活动主流程方案.md`](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md) §5改为 Redis 锁 + `spin_available`,去掉「前端 spin_id 必填」。
- [`00_整体技术方案.md`](docs/requirements/lucky_rewards/00_整体技术方案.md) §3.4 / §3.6:手动 Spin 幂等说明更新。
- [`lucky_reward_deploy.md`](slot_console/doc/lucky_reward_deploy.md) 冒烟项:去掉「带 spin_id 幂等」。
- **YApi** spin 接口cat 115删除 request `spin_id` 必填;补充并发锁错误文案;**FE 约定**spin 失败/超时先调 status。
---
## 前端契约(联调说明)
```text
POST /api/lucky-reward/spin
Body: {} 或空
```
- 点击 Spin 后 **UI 防抖**(禁用按钮至响应返回)。
- 请求失败/超时:**不要**立即再次 spin`status` / `records``spinAvailable``myAmount`、最新 record。
- 若返回「Spin is in progress」稍后重试或等当前请求结束。
---
## 已知边界(产品已接受)
| 场景 | 行为 |
| --- | --- |
| 连点 | 第二笔拿不到锁 → 立即报错 |
| 弱网:已成功但响应丢失 | 再次 spin → `spin_available=0` 报错;靠 status/records 恢复 UI |
| 弱网:仍有多次数且用户再点 | 会消耗**下一次** Spin非重试上一把 |
---
## 涉及文件
- [`slot_console/app/api/controller/LuckyRewardController.php`](slot_console/app/api/controller/LuckyRewardController.php)
- [`slot_console/app/api/logic/LuckyRewardLogic.php`](slot_console/app/api/logic/LuckyRewardLogic.php)
- [`slot_console/app/api/validator/LuckyRewardValidator.php`](slot_console/app/api/validator/LuckyRewardValidator.php)
- [`slot_console/app/service/RedisKeyManagerService.php`](slot_console/app/service/RedisKeyManagerService.php)
- 测试 + 需求/deploy/YApi 文档

View File

@@ -0,0 +1,165 @@
---
name: Stats init 重复分析
overview: 已用生产数据确认auto_first 以 spin_source=1、prize_type=1、prize_amount_qf=init 落库,当前 statistics 会把同一笔开宝箱金额算两遍。修复方向是在 aggregateSpinStats 排除 spin_source=AUTO_FIRST保留 init + 手动 Spin 随机入账相加。
todos:
- id: verify-auto-first-row
content: 确认 slot_console 开宝箱 auto_first 记录的 prize_type / spin_source / prize_amount_qf 落库值
status: completed
- id: exclude-auto-first-stats
content: aggregateSpinStats 排除 spin_source=AUTO_FIRST并补 LuckyRewardSpinRecordModel 常量
status: completed
- id: add-stats-test
content: 补统计用例:仅开宝箱无手动 Spin 时 total_reward_amount_qf=init 总和,且 spin_total/random_hit_count=0
status: completed
isProject: false
---
# LuckyRewardStatsLogic 统计口径分析
## 直接回答
**不是。** `$spinStats` 里**没有**完整包含 `$initAmountTotalQf`,两者是**刻意分开**再相加的:
```php
// backend/slot_admin/app/game/logic/LuckyRewardStatsLogic.php
$initAmountTotalQf = (int) (clone $playerQuery)->sum('p.init_amount_qf');
$spinStats = $this->aggregateSpinStats($playerQuery);
$prizeAmountTotalQf = (int) ($spinStats['prize_amount_total_qf'] ?? 0);
$totalRewardAmountQf = $initAmountTotalQf + $prizeAmountTotalQf;
```
`$spinStats` 返回的是 **Spin 记录维度** 的聚合,与 player 表上的 `init_amount_qf` 来源不同:
| 字段 | 数据来源 | 含义 |
|------|----------|------|
| `$initAmountTotalQf` | `lucky_reward_player.init_amount_qf` | 开宝箱命中初始金额汇总 |
| `$spinStats['prize_amount_total_qf']` | `lucky_reward_spin_record.prize_amount_qf``prize_type=1` | 随机金额类 Spin **实际入账**汇总 |
| `$spinStats['spin_total']` 等 | `lucky_reward_spin_record` 行数 | 命中次数/比率,**不是金额** |
需求文档口径([02_管理后台方案.md](docs/requirements/lucky_rewards/02_管理后台方案.md) §7.2
> **总奖励金额** = `sum(player.init_amount_qf)` + `sum(spin_record.prize_amount_qf)`(开宝箱初始 + Spin 随机入账 = 实际投放)
因此 **`$initAmountTotalQf` 不能删**`total_reward_amount_qf` 本应是「init + **手动** Spin 随机入账」,不应把 auto_first 展示记录算进 prize 部分。
---
## 生产数据已确认重复计数
用户提供的真实行:
```sql
INSERT INTO lucky_reward_spin_record
(uid, cycle_id, spin_id, spin_source, prize_type, prize_amount_qf, my_amount_after_qf)
VALUES
(1545906, 46, 'auto_first:1545906:46', 1, 1, 9603, 9603);
```
字段解读(与 DDL 一致):
| 字段 | 值 | 含义 |
|------|-----|------|
| `spin_id` | `auto_first:1545906:46` | 开宝箱自动首次 SpinRecord 展示) |
| `spin_source` | `1` | 自动首次 |
| `prize_type` | `1` | 随机金额 |
| `prize_amount_qf` | `9603` | 与 `player.init_amount_qf` 相同 |
对 uid=1545906、cycle=46 若仅开宝箱、无手动 Spin
- `$initAmountTotalQf` = **9603**(来自 player
- `$spinStats['prize_amount_total_qf']` = **9603**auto_first 被 `prize_type=1` 命中)
- `$totalRewardAmountQf` = **19206**(应为 **9603**
同时 **`spin_total` / `random_hit_count`** 也会把 auto_first 算进 Spin 次数与命中率,导致指标失真(每个参与用户天然 +1 次 Spin、+1 次 random 命中)。
---
## `spin_total` 同样算错
**是的。** `$spinStats['spin_total']``lucky_reward_spin_record` **全表 count**,没有任何 `spin_source` 过滤:
```php
$spinTotal = (int) (clone $spinQuery)->count(); // 含 auto_first
```
对你给的 uid `1545906`:只开宝箱、从未手动 Spin 时:
| 指标 | 当前错误值 | 修正后应为 |
|------|------------|------------|
| `spin_total` | **1**auto_first | **0** |
| `random_hit_count` | **1**auto_first 且 prize_type=1 | **0** |
| `random_hit_rate` | **100%** | **0%** |
需求文档 [02_管理后台方案.md](docs/requirements/lucky_rewards/02_管理后台方案.md) §7.3 明细列 **Spin** 用的是 `player.spin_used`,注释为 **「不含初始自动」**;总览 `spin_total` 应与之一致,只统计**真实消耗 Spin 次数**的记录(`spin_source` 为每日/邀请/手动),不应含开宝箱 auto_first。
连带影响的字段(同一 `$spinQuery` 未过滤):
- `spin_total` — 分母偏大
- `random_hit_count` / `random_hit_rate` — 分子、分母都含 auto_first
- `cashout_hit_count` / `cashout_hit_rate` — 若 auto_first 不会 prize_type=3通常不受影响但仍应统一排除
- `spin_prize_hit_count` / `spin_prize_hit_rate` — 同上
- `prize_amount_total_qf` — 金额重复(见上)
修复时在 `$spinQuery` 基查询一次性加 `spin_source <> AUTO_FIRST`,上述字段一并修正。
---
## 根因
[`aggregateSpinStats()`](backend/slot_admin/app/game/logic/LuckyRewardStatsLogic.php) 只按 `prize_type=1` 过滤金额/次数,**未排除 `spin_source=1`auto_first**
```php
$prizeAmountTotalQf = (int) (clone $spinQuery)
->where('sr.prize_type', LuckyRewardSpinRecordModel::PRIZE_TYPE_RANDOM)
->sum('sr.prize_amount_qf');
```
C 端开宝箱写 auto_first 时为了 Record 展示使用了 `prize_type=1` + `prize_amount_qf=init`,与 My Amount 入账无关admin 统计误将其当作「Spin 随机入账」。
需求 [04_slot_console_活动主流程方案.md](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md)`auto_first` **仅动画/Record不叠加金额**
```mermaid
flowchart TD
openBox[openBox 开宝箱]
playerInit["player.init_amount_qf = 9603"]
autoFirst["spin_record auto_first prize_amount_qf = 9603"]
statsInit["initAmountTotalQf += 9603"]
statsPrize["prize_amount_total_qf += 9603"]
doubleCount["total_reward = 19206 重复"]
openBox --> playerInit
openBox --> autoFirst
playerInit --> statsInit
autoFirst --> statsPrize
statsInit --> doubleCount
statsPrize --> doubleCount
```
---
## 修复方案(待执行)
在 [`aggregateSpinStats()`](backend/slot_admin/app/game/logic/LuckyRewardStatsLogic.php) 中,**所有** spin 记录聚合(次数与金额)统一排除 auto_first
1. [LuckyRewardSpinRecordModel](backend/slot_admin/app/game/model/common/LuckyRewardSpinRecordModel.php) 增加常量:
- `SPIN_SOURCE_AUTO_FIRST = 1`
- `SPIN_SOURCE_DAILY = 2`
- `SPIN_SOURCE_INVITE = 3`(与 DDL 注释对齐)
2. `$spinQuery` 基查询增加:`->where('sr.spin_source', '<>', SPIN_SOURCE_AUTO_FIRST)`
- 影响:`spin_total``random_hit_count``cashout_hit_count``spin_prize_hit_count``prize_amount_total_qf`
3. **保持** `$totalRewardAmountQf = $initAmountTotalQf + $prizeAmountTotalQf` 不变
4. 补测试:仅开宝箱用户 → `total_reward_amount_qf === sum(init_amount_qf)`auto_first 行存在但不计入 prize
**不改 C 端落库**auto_first 继续用于 Record 展示;仅修正 admin 统计过滤。
**不建议**删掉 `$initAmountTotalQf` 或改为只 sum spin_record。
---
## 小结
| 问题 | 答案 |
|------|------|
| `$spinStats` 是否已包含 `$initAmountTotalQf` | **否**;但 `prize_amount_total_qf` 对 auto_first **重复计入了同一份 init** |
| 两处相加是否合理? | **公式合理**;当前实现因未排除 auto_first **结果错误** |
| 生产数据是否证实? | **是**uid 1545906 示例9603 被算成 19206 |
| `spin_total` 是否也算错? | **是**;仅开宝箱用户会显示 spin_total=1、random_hit_rate=100% |

View File

@@ -0,0 +1,145 @@
---
name: Support Invite 离线补推
overview: 用户提出的「离线写 Redis + WS 连接后补推」方案可行;项目已有 `user_websocket_connect` 事件与在线 Redis Set可在 `deliverInviteeSupportInvitePopup` 与连接回调中实现,无需回退 HomeEvent。
todos:
- id: pending-service
content: 新增 LuckyRewardSupportInvitePendingServiceRedis key/TTL、enqueue、flush、可选 sent 标记
status: completed
- id: deliver-branch
content: deliverInviteeSupportInvitePopup在线即时推离线 enqueue
status: completed
- id: ws-connect-flush
content: WebsocketConnectEventService::run 连接后 flush pendingsAdd 之后)
status: completed
- id: tests
content: 集成/单测:离线 enqueue + connect flush在线直推不写 Redis
status: completed
isProject: false
---
# Support Invite 离线 Redis 补推方案
## 结论:方案可行
你的思路与现网架构**匹配**,比把 Support Invite 塞回 HomeEvent 更贴切(仍只弹一次,且针对「注册时 WS 尚未连上」的竞态)。
现网已有能力:
| 能力 | 位置 |
| --- | --- |
| WS 连接 MQ | `slot_game` gateway → `console_bus` `TYPE_USER_WEBSOCKET_CONNECT` |
| 连接处理入口 | [`EventBus::websocketConnectEvent`](slot_console/app/command/EventBus.php) → [`WebsocketConnectEventService::run`](slot_console/app/service/event/WebsocketConnectEventService.php) |
| 在线判定 Redis Set | `RedisKeyManagerService::getUserOnlineKey()`connect 时 `sAdd`、disconnect 时 `sRem` |
| 即时弹窗 | [`WsService::notifyClientPOP`](slot_console/app/service/WsService.php) → hub MQ**离线会丢** |
[`DelayTipPopService`](slot_console/app/service/DelayTipPopService.php) 是「延时 N 秒再推 tipPop」的 ZSet**不适合**本场景;本需求是「等 WS 连上再推」,应走 **connect 时 flush**,不是 cron 扫延时队列。
```mermaid
sequenceDiagram
participant Bind as invite_bind_success
participant Logic as deliverInviteeSupportInvitePopup
participant Redis as Redis_pending
participant Hub as WsService
participant WS as websocketConnectEvent
Bind->>Logic: openBox + buildPopup
alt user_online
Logic->>Hub: notifyClientPOP
else user_offline
Logic->>Redis: SET payload EX TTL
end
WS->>Redis: GET pending
alt has_pending
WS->>Hub: notifyClientPOP
WS->>Redis: DEL
end
```
---
## 推荐实现(在 Redis 方案上略作增强)
### 1. 新增 `LuckyRewardPendingPopupService`(或 Logic 内 private + 小 Service
职责单一:**被邀请人 Support Invite 的 pending 投递**。
- **Redis Key**`lucky_reward:support_invite_popup:{uid}:{cycle_id}`(幂等,同一轮次只存一条)
- **Value**`json_encode` 后的 popList 单项(与现 `buildSupportInvitePopup` 结构一致)
- **TTL**:建议 **3060 分钟**(覆盖注册加载 + WS 握手;过短会丢弹,过长无必要)
- **写入**`SET key value EX ttl NX`NX 防止 bind 重试覆盖/重复)
### 2. 改 [`deliverInviteeSupportInvitePopup`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)
顺序不变:**先** `ensureInviteeAutoOpenBox`**再** `buildSupportInvitePopup`;推送逻辑改为:
```php
if ($this->isUserOnline($inviteeUid)) {
WsService::notifyClientPOP($inviteeUid, [$supportInvitePopup]);
return;
}
$this->pendingPopupService->enqueueSupportInvite($inviteeUid, $cycleId, $supportInvitePopup);
```
**在线判定**`Redis::sIsMember(RedisKeyManagerService::getUserOnlineKey(), $inviteeUid)`(与现网 disconnect 维护的 Set 一致)。
> 注意:存在 **竞态**bind 比 `user_websocket_connect` 早几十~几百 ms。可选增强推荐**在线则即时推 + 仍 SET NX pending**connect flush 时 **GETDEL**,若已推过则 payload 相同、客户端需幂等忽略重复 type。更简单做法**仅离线写 Redis**;若偶发丢弹可接受则不做双写。
### 3. WS 连接时 flush
在 [`WebsocketConnectEventService::run($uid)`](slot_console/app/service/event/WebsocketConnectEventService.php) 末尾(或 `EventBus::websocketConnectEvent` 调 Logic 一行):
1. 查当前 active `cycle_id`(与 `buildSupportInvitePopup` 同源)
2. `GETDEL lucky_reward:support_invite_popup:{uid}:{cycle_id}`
3. 有值 → `json_decode``WsService::notifyClientPOP($uid, [$popup])`
**只弹一次**:发送成功后 key 已 DEL重连不会重复除非 bind 再次 success 且 NX 允许新 key——同一 cycle 不会)。
### 4. 可选DB 兜底(比纯 Redis 更稳)
若担心 TTL 内仍未连 WSconnect 时除读 Redis 外,可 **fallback**
- `helper` 存在 + `box_opened_at > 0` + Redis 无 key + 从未发送标记
→ 再 `buildSupportInvitePopup` 推一次
发送标记可用 Redis `lucky_reward:support_invite_sent:{uid}:{cycle_id}` SET NX永久或 7d TTL。**与「只弹一次」产品一致**,且 bind 时 openBox 已落库,**不依赖 Redis 里缓存 payload**。
建议:**Redis 存 payload 为主connect 时 GETDELTTL 过期则 fallback 从 DB 重建一次**(实现成本低、容错更好)。
---
## 不建议的做法
- **不要**再挂 HomeEvent与「只弹一次、注册时触发」冲突且会每次回大厅重复弹
- **不要**复用 `user_pending_popup` 表(项目已废弃,见 deploy 文档)
- **不要**只用 `DelayTipPopService` 固定延时 N 秒N 难估,且 WS 未连上仍会丢)
---
## 改动文件(实施范围)
| 文件 | 改动 |
| --- | --- |
| 新建 `app/service/luckyReward/LuckyRewardSupportInvitePendingService.php` | enqueue / flush / isUserOnline 封装 |
| [`LuckyRewardInviteLogic.php`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) | `deliverInviteeSupportInvitePopup` 分支在线/离线 |
| [`WebsocketConnectEventService.php`](slot_console/app/service/event/WebsocketConnectEventService.php) | connect 时 flush pending |
| [`RedisKeyManagerService.php`](slot_console/app/service/RedisKeyManagerService.php) | 新增 key 常量 + 中文注释 |
| 单测/集成测 | mock Redis 或集成测:离线 enqueue → connect flush |
**不在本次范围**除非你要求YApi/05 文档、邀请人 Claim Now、helper 未落库排查。
---
## 风险与对策
| 风险 | 对策 |
| --- | --- |
| bind 早于 connect误判离线 | 可接受则只写 Redis要严格则 connect 必 flush + DB fallback |
| hub 已连但 console 在线 Set 未更新 | connect 事件顺序flush 放在 `websocketConnectEvent` **sAdd 之后** |
| 重复弹 | key 含 cycle_id + GETDEL + 客户端按 type 去重 |
| TTL 过期未连 WS | fallback 从 DB rebuildhelper + opened box |
---
## 结论
**可行,且是正确补洞方向**;实现上建议:**离线 Redis pending + WS connect flush**,并加 **DB fallback / sent 标记** 防止 TTL 丢弹。在线判定复用现有 `USER_ONLINE_SET`,接入点放在已存在的 `WebsocketConnectEventService`

View File

@@ -0,0 +1,92 @@
<!-- 5511b223-7953-4aa5-bd6e-94f0d6c56b21 -->
---
todos:
- id: "dep"
content: "在 php82 容器执行 composer require symfony/lock:7.4.9"
status: pending
- id: "factory"
content: "新增 app/support/OtaLockFactory.php 封装 symfony LockFactory + RedisStore用 support\\Redis::connection()->client()"
status: pending
- id: "refactor"
content: "重构 OtaCheckLogic 锁方法acquire(false) 重试+SERVICE_BUSYrelease(),删除手写 SET/Lua 与 support\\Redis 依赖"
status: pending
- id: "verify"
content: "运行 phpunit + php -l + 完成门禁,确认全绿"
status: pending
isProject: false
---
# 用 symfony/lock 7.4.9 替换 OTA 分布式锁
## 目标与范围
- 仅替换 [app/logic/OtaCheckLogic.php](app/logic/OtaCheckLogic.php) 中的锁实现(`withDeviceFirmwareLock` / `acquireLock` / `releaseLock`)。
- 业务流程(先查后建/复用、§7.5 幂等)、错误码 `SERVICE_BUSY`、TTL/重试参数语义保持不变。
- 单测/集成测试结构不变(仍依赖 docker `redis`,跳过守卫不变)。
## 为什么可行
- `php82` = 8.2.24,满足 symfony/lock 7.4PHP ≥ 8.2)。
- symfony `RedisStore` 接受原生 `\Redis`,可由 `support\Redis::connection()->client()` 取得(见 `vendor/illuminate/redis/Connections/Connection.php:81``client()`)。
- symfony `Lock` 自带唯一 token + 所有者校验的 `release()`,可删除手写 Lua 释放脚本。
```mermaid
flowchart LR
Logic[OtaCheckLogic] -->|create resource| Factory[OtaLockFactory]
Factory -->|RedisStore| RawRedis["support Redis connection client (\\Redis)"]
Logic -->|"acquire(false) 重试 / release()"| LockObj[Symfony Lock]
```
## 改动点
### 1. 依赖
-`php82` 容器内执行dev-environment 规则,禁止宿主机 php/composer
`docker exec -w /app/www/ai-device/ota php82 composer require symfony/lock:7.4.9`
- 结果会更新 [composer.json](composer.json) / `composer.lock` / `vendor`
### 2. 新增 `app/support/OtaLockFactory.php`
- 职责:封装 symfony 锁与 phpredis 客户端的装配,返回可用的 `LockInterface`
- 每次调用基于「当前」连接构建 `RedisStore`(不缓存客户端,规避 webman 连接池跨请求换连接导致的陈旧句柄):
```php
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Lock\Store\RedisStore;
use support\Redis;
final class OtaLockFactory
{
/** 基于当前 redis 连接创建一个带 TTL 的命名分布式锁 */
public static function create(string $resource, float $ttlSeconds): LockInterface
{
$store = new RedisStore(Redis::connection()->client());
return (new LockFactory($store))->createLock($resource, $ttlSeconds);
}
}
```
### 3. 重构 [app/logic/OtaCheckLogic.php](app/logic/OtaCheckLogic.php)
- 删除 `use support\Redis;`;新增 `use Symfony\Component\Lock\LockInterface;``use app\support\OtaLockFactory;`
- 保留常量 `LOCK_KEY_PREFIX` / `LOCK_TTL_SECONDS` / `LOCK_MAX_RETRY` / `LOCK_RETRY_INTERVAL_US`
- `withDeviceFirmwareLock`
```php
$lock = OtaLockFactory::create(self::LOCK_KEY_PREFIX . $deviceId . ':' . $firmwareId, (float) self::LOCK_TTL_SECONDS);
$this->acquireOrFail($lock);
try {
return $criticalSection();
} finally {
$this->releaseQuietly($lock);
}
```
- `acquireOrFail(LockInterface $lock)`:循环 `LOCK_MAX_RETRY` 次调用 `$lock->acquire(false)`,成功 return否则 `usleep(LOCK_RETRY_INTERVAL_US)`;用尽后 `Log::warning` + `throw new BusinessException(OtaErrorCode::SERVICE_BUSY)`
- `releaseQuietly(LockInterface $lock)``try { $lock->release(); } catch (Throwable) { Log::warning(...) }`释放失败不影响主流程TTL 兜底)。
- 删除手写 token 生成与 Lua 释放脚本(`acquireLock`/`releaseLock` 旧实现)。
## 验证
- `docker exec -w /app/www/ai-device/ota php82 ./vendor/bin/phpunit`(含 `OtaCheckLogicTest` 真连 redis 验证加锁/幂等)。
- 对改动文件容器内 `php -l`
- 运行完成门禁 `~/.cursor/skills/slot-backend-completion-report/scripts/report.sh`
## 备注 / 取舍
- 可选:将 [composer.json](composer.json) 的 `"php": ">=8.1"` 提升为 `>=8.2` 以与 symfony/lock 对齐(非必需,运行时已是 8.2)。
- 备选方案:不新增 `OtaLockFactory`,把装配内联为 `OtaCheckLogic` 私有方法;本方案选独立 support 类以隔离基础设施、便于复用与替换 Store。
- 阻塞策略:沿用 `acquire(false)` 有界重试而非 `acquire(true)` 无限阻塞,避免阻塞 webman worker并保留 `SERVICE_BUSY` 行为。

View File

@@ -0,0 +1,230 @@
<!-- 9333b12a-cba7-4c83-9a56-b9ca81467dd0 -->
---
todos:
- id: "db-tenant-code"
content: "新增 tenant_code 字段、唯一索引,更新 saimulti.sql 与存量回填脚本"
status: pending
- id: "server-generate-resolve"
content: "SystemOrganizationLogic 实现码生成、add 自动写入、appInfo code 分支LoginController 接收 tenant_code"
status: pending
- id: "tenant-vue-code-mode"
content: "tenant-vue siteStore/App.vue/http/.env/types 从 app_id 模式切换为 tenant_code 模式"
status: pending
- id: "admin-vue-display"
content: "admin-vue 机构列表与编辑弹窗展示 tenant_code只读及可选登录链接"
status: pending
- id: "docs-verify"
content: "更新 README跑 verify 脚本与手动登录联调"
status: pending
isProject: false
---
# tenant_code 租户登录改造方案
## 现状
当前租户端通过 [`tenant-vue/.env`](tenant-vue/.env) 的 `VITE_APP_MODE` 区分两种模式:
| 模式 | 环境值 | 识别方式 | 入口示例 |
|------|--------|----------|----------|
| app_id | `appid` | URL `?app_id=1` | 可猜测自增 id |
| domain | `domain` | `location.host` | 管理端配置域名 |
核心链路:
```mermaid
sequenceDiagram
participant Browser
participant TenantVue
participant Server
participant DB
Browser->>TenantVue: ?app_id=1 或域名访问
TenantVue->>Server: GET /saimulti/appInfo
Server->>DB: 按 id 或 domain 查 sm_system_organization
Server-->>TenantVue: 返回 id/title/logo...
TenantVue->>Server: 后续请求 Header App-Id=机构id
```
关键文件:
- 前端站点识别:[`tenant-vue/src/store/modules/site.ts`](tenant-vue/src/store/modules/site.ts)、[`tenant-vue/src/App.vue`](tenant-vue/src/App.vue)、[`tenant-vue/src/utils/http/index.ts`](tenant-vue/src/utils/http/index.ts)
- 后端入口:[`server/plugin/saimulti/app/controller/LoginController.php`](server/plugin/saimulti/app/controller/LoginController.php) → [`SystemOrganizationLogic::appInfo()`](server/plugin/saimulti/app/logic/system/SystemOrganizationLogic.php)
- 租户隔离:全站仍依赖 `App-Id` 请求头([`TenantController`](server/plugin/saimulti/basic/TenantController.php)、[`TenantModel`](server/plugin/saimulti/basic/TenantModel.php)**内部继续使用数字机构 id不改为 tenant_code**
## 目标架构
`appid` 模式替换为 `code` 模式domain 模式不动):
```mermaid
sequenceDiagram
participant Browser
participant TenantVue
participant Server
participant DB
Browser->>TenantVue: /auth/login?tenant_code=A3K9M2
TenantVue->>Server: GET /saimulti/appInfo?tenant_code=A3K9M2&mode=code
Server->>DB: WHERE tenant_code=A3K9M2
Server-->>TenantVue: id, title, logo, tenant_code...
Note over TenantVue: 持久化 tenant_code + 解析后的 org id
TenantVue->>Server: POST /tenant/login, Header App-Id=1
```
**安全收益**:对外 URL 只暴露 6 位随机码(大写+数字,排除 0/O、1/I/L不再暴露可枚举的自增 id。
**tenant_code 获取方式**:与现有 app_id 一致,从浏览器 URL 查询参数自动读取([`getUrlQueryValue`](tenant-vue/src/store/modules/site.ts) 已支持 hash 路由),**登录页无需新增输入框**(避免与验证码字段 `code` 混淆)。
---
## 1. 数据库变更
在 [`sm_system_organization`](db/saimulti.sql) 增加字段:
```sql
ALTER TABLE `sm_system_organization`
ADD COLUMN `tenant_code` varchar(6) NOT NULL COMMENT '租户访问码6位随机' AFTER `domain`,
ADD UNIQUE INDEX `uk_tenant_code` (`tenant_code`);
```
同步更新 [`db/saimulti.sql`](db/saimulti.sql) 建表语句与 seed 数据。
**存量机构回填**:新增独立迁移脚本(如 `db/migrate_tenant_code.sql` 或由一次性 PHP 命令生成),为已有记录生成不重复 6 位码;规则与新建一致。
---
## 2. Server 后端
### 2.1 Model
[`SystemOrganization.php`](server/plugin/saimulti/app/model/system/SystemOrganization.php) 补充类 PHPDoc表说明 + `@property string $tenant_code 租户访问码`)。
可选:新增 `findEnabledByTenantCode(string $tenantCode)` 查询方法。
### 2.2 Logic — 核心改动
[`SystemOrganizationLogic.php`](server/plugin/saimulti/app/logic/system/SystemOrganizationLogic.php)
- **`generateUniqueTenantCode()`**private
- 字符集:`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`32 字符)
- 循环生成 6 位 + 查重,直到唯一
- **`add($data)`** 重写/扩展:保存前若未传 `tenant_code`,自动调用生成器写入
- **`appInfo($identifier, $mode)`** 扩展分支:
- `mode === 'domain'` → 现有逻辑不变
- `mode === 'code'``where('tenant_code', $identifier)` 查询
- 移除或废弃 `mode !== 'domain'` 时按自增 id 查询的路径(原 appid 模式)
- **`appInfo` 返回字段**增加 `tenant_code`(供前端展示/持久化)
### 2.3 Controller
[`LoginController::appInfo()`](server/plugin/saimulti/app/controller/LoginController.php)
- 读取 `tenant_code` 查询参数code 模式)
- `mode=code` 时传入 `appInfo($tenantCode, 'code')`
- domain 模式保持 `appid=host` + `mode=domain`
**不改动** `App-Id` 头解析逻辑 — 租户 API 仍接收机构数字 id。
---
## 3. tenant-vue 租户端
### 3.1 环境配置
[`tenant-vue/.env`](tenant-vue/.env)
```env
# 模式1: code 通过 URL ?tenant_code= 区分租户
# 模式2: domain 通过域名区分
VITE_APP_MODE = code
```
### 3.2 siteStore 重构
[`tenant-vue/src/store/modules/site.ts`](tenant-vue/src/store/modules/site.ts)
- 常量 `TENANT_CODE_QUERY_KEY = 'tenant_code'`
- `SiteInfoParams` 改为 `{ tenantCode: string; mode: string }`(替代 `appid`
- `loadSiteInfo``/saimulti/appInfo``tenant_code` + `mode`
- 解析成功后:`info.id` 用于 `App-Id` 头;`tenant_code` 持久化到 localStorage刷新后无需 URL 参数也能识别租户)
- domain 模式分支逻辑保持不变
### 3.3 路由监听
[`tenant-vue/src/App.vue`](tenant-vue/src/App.vue)
- `route.query.app_id``route.query.tenant_code`
- 相应函数重命名(如 `getRouteTenantCode`
### 3.4 HTTP 拦截器
[`tenant-vue/src/utils/http/index.ts`](tenant-vue/src/utils/http/index.ts)
- `appMode === 'code'`:优先 `siteStore.info.id` 作为 `App-Id`;未加载时用已持久化的 org id
- 移除 `appid` 模式分支
### 3.5 类型定义
[`tenant-vue/src/types/api/api.d.ts`](tenant-vue/src/types/api/api.d.ts)`siteInfoResponse` 增加 `tenant_code?: string`
---
## 4. admin-vue 总后台
### 4.1 机构列表
[`admin-vue/src/views/admin/panel/organization/index.vue`](admin-vue/src/views/admin/panel/organization/index.vue) 增加列:
- `tenant_code` — 标签「租户码」
- 可选:展示租户登录链接模板 `租户端地址?tenant_code=xxx`(方便复制发给客户)
### 4.2 编辑弹窗
[`admin-vue/src/views/admin/panel/organization/modules/edit-dialog.vue`](admin-vue/src/views/admin/panel/organization/modules/edit-dialog.vue)
- **新增**时:不展示 tenant_code后端自动生成
- **编辑**时:只读展示 `tenant_code`(不可修改,保证链接稳定)
- 不在表单提交中包含 tenant_code防篡改
---
## 5. 文档
更新 [`README.md`](README.md) 租户前端配置章节:
- 模式 1`code` + `?tenant_code=A3K9M2`
- 模式 2`domain`(不变)
- 删除 app_id 相关说明
---
## 6. 兼容与测试要点
| 场景 | 预期 |
|------|------|
| 新机构创建 | 自动生成唯一 6 位 tenant_code |
| `?tenant_code=xxx` 访问 | 加载站点信息,登录成功 |
| 刷新页面(无 URL 参数) | localStorage 中 tenant_code + org id 仍可识别 |
| 错误/不存在 tenant_code | appInfo 报错「未找到该应用」 |
| domain 模式部署 | 行为与改造前一致 |
| 旧 `?app_id=1` 链接 | **不再支持**(按你的替换需求) |
手动验证路径:
1. 总后台新建机构 → 列表出现 tenant_code
2. 租户端 `http://localhost:16888/?tenant_code=XXXXXX` → Logo/标题正确
3. admin/admin 登录 → 租户 API 正常(菜单、用户信息)
4. domain 模式 `.env` 切换后仍可用
---
## 改动范围摘要
| 层 | 文件 | 动作 |
|----|------|------|
| DB | `db/saimulti.sql` + 迁移脚本 | 加字段、唯一索引、存量回填 |
| Server | `SystemOrganizationLogic.php` | 生成码、appInfo code 分支、add 钩子 |
| Server | `LoginController.php` | 接收 tenant_code 参数 |
| Server | `SystemOrganization.php` | PHPDoc |
| tenant-vue | `site.ts`, `App.vue`, `http/index.ts`, `.env`, `api.d.ts` | appid → code |
| admin-vue | `organization/index.vue`, `edit-dialog.vue` | 展示 tenant_code |
| Docs | `README.md` | 更新模式说明 |
**刻意不改**`App-Id` 请求头机制、TenantModel 全局 scope、登录接口参数验证码 `code` 保持不变)。

View File

@@ -0,0 +1,136 @@
---
name: toBalance 清零分析
overview: 产品要求 To Balance 后客户端仍展示领取金额。推荐**保留 my_amount_qf 清零**(进度已兑现),在 status 接口对已提取用户用 cashout_record 回填 myAmountDisplay并禁止已提取用户继续 Spin 污染状态。
todos:
- id: build-state-withdrawn-display
content: buildStateEntityplayerStatus=已提取时myAmountDisplay 取自 lucky_reward_cashout_record.amount_qf大单位
status: completed
- id: block-spin-after-withdrawn
content: executeManualSpinstatus=已提取时拒绝 Spin防 status/my_amount 被改回可提取)
status: completed
- id: keep-zero-my-amount
content: toBalance 事务内保留 my_amount_qf=0 + status=已提取(不改删除清零逻辑)
status: completed
- id: sync-docs-yapi
content: 更新 lucky_reward_api.md、04 需求 §7、YApi
status: completed
isProject: false
---
# To Balance 后展示领取金额 — 方案(迭代)
## 产品诉求(新增)
客户端 To Balance **成功之后**,仍要在活动页展示**领取时的金额**(含刷新 status、再次进入活动不能只靠 toBalance 响应本地缓存)。
当前问题:`toBalance``my_amount_qf = 0` 后,`POST /api/lucky-reward/status``myAmountDisplay` 变为 `0.00`,客户端无法继续展示 $10.00 等已领取金额。
---
## 推荐方案:库内清零 + status 展示回填(推荐)
**不改为「不清零 my_amount_qf」**,而是区分「活动进度」与「展示金额」:
| 层 | 行为 |
| --- | --- |
| DB `my_amount_qf` | **继续清零** — 表示本轮可 Spin 累积的进度已兑现,避免尾差/Spin 叠加 |
| DB `status` | **已提取(3)** — 客户端据此隐藏 To Balance 按钮 |
| status 响应 `myAmountDisplay` | **已提取时**从 [`LuckyRewardCashoutRecordModel::findSuccessByUidAndCycle`](slot_console/app/model/common/LuckyRewardCashoutRecordModel.php) 读 `amount_qf`,经 `formatAmountDisplay()` 返回 |
改动点:[`LuckyRewardLogic::buildStateEntity()`](slot_console/app/api/logic/LuckyRewardLogic.php)(约 788806 行)
```php
// 伪代码
if ($hasOpenedBox && (int)$activityPlayer->status === STATUS_WITHDRAWN) {
$cashout = LuckyRewardCashoutRecordModel::findSuccessByUidAndCycle($uid, $cycleId);
$myAmountQf = $cashout !== null ? (int)$cashout->amount_qf : 0;
} else {
$myAmountQf = $hasOpenedBox ? (int)$activityPlayer->my_amount_qf : 0;
}
```
展示值为**实际入账金额**`floor(my_amount/10)*10` 后的值),与 toBalance 响应 `amountDisplay` 一致,语义正确。
```mermaid
flowchart TD
toBalance[toBalance 成功] --> zero["my_amount_qf=0\nstatus=已提取"]
zero --> cashoutRow[cashout_record 保留 amount_qf]
cashoutRow --> statusPoll[客户端调 status]
statusPoll --> display["myAmountDisplay=提取金额\nplayerStatus=3"]
```
---
## 备选方案:取消清零(不推荐单独使用)
删除 `my_amount_qf = 0` 一行status 直接读库内 `my_amount_qf` 即可展示。
**缺点**(见原分析):
- 尾差残留(转入 floor 后库内仍 > 转入额)
- 提取后若还有 Spin[`executeManualSpin`](slot_console/app/api/logic/LuckyRewardLogic.php) 会改 `my_amount_qf` / 把 `status` 改回可提取(2)
- 与需求文档 §7「my_amount 本笔归零」不一致
若走此方案,**必须**同步加 Spin 拦截,否则状态机会脏。
---
## 必须配套:已提取用户禁止 Spin
无论是否清零,[`executeManualSpin()`](slot_console/app/api/logic/LuckyRewardLogic.php) 当前**不校验** `STATUS_WITHDRAWN`。提取后若 `spin_available > 0`(邀请 Spin 等),仍可能:
- 增加 `my_amount_qf`
-`status` 从 3 改回 2
建议在 Spin 入口增加:
```php
if ((int)$activityPlayer->status === LuckyRewardPlayerModel::STATUS_WITHDRAWN) {
throw new BusinessException('Already withdrawn this cycle');
}
```
资金安全仍由 `cashout_record` 幂等保证;此拦截主要为**状态一致 + 展示正确**。
---
## 客户端约定(无需新字段)
沿用现有 [`LuckyRewardStateEntity`](slot_console/app/entity/luckyReward/LuckyRewardStateEntity.php)
- `playerStatus === 3`(已提取)→ 隐藏 To Balance / 展示「已领取」态
- `myAmountDisplay` → 仍展示进度条/金额数字(提取后由服务端回填为领取金额)
- toBalance 当场成功仍可用响应 `amountDisplay`;之后以 status 为准
**不新增** `withdrawnAmountDisplay` 字段,除非客户端希望区分「当前可提进度」与「历史已提」——当前产品只需展示领取金额,复用 `myAmountDisplay` 即可。
---
## 需求文档调整
[`04_slot_console_活动主流程方案.md`](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md) §7 第 6 步建议改为:
> 成功:写 cashout_record`player.status=已提取`、**`my_amount_qf` 归零(进度清空)****status 接口对已提取用户 `myAmountDisplay` 取 cashout_record.amount_qf 展示**。
---
## 实施范围(待你确认后执行)
| 文件 | 改动 |
| --- | --- |
| [`LuckyRewardLogic.php`](slot_console/app/api/logic/LuckyRewardLogic.php) | `buildStateEntity` 已提取回填;`executeManualSpin` 拦截已提取 |
| 集成测 | 新增/扩展toBalance 后 status 的 `myAmountDisplay` 等于提取额 |
| [`lucky_reward_api.md`](slot_console/doc/lucky_reward_api.md) + YApi #554/#561 | status 字段说明:已提取时 myAmountDisplay 含义 |
| 需求 §7 | 与实现对齐 |
**不改动**`toBalance``my_amount_qf = 0`(保留)。
---
## 原分析问题答复(更新结论)
| 问题 | 更新结论 |
| --- | --- |
| 必须清零吗? | **库内建议继续清零**;展示不清零,改由 status 回填 |
| 不清零唯一目的「展示金额」? | **否**;用 cashout_record 回填更干净 |
| 重复打款? | 仍靠 cashout_record与是否清零无关 |

View File

@@ -0,0 +1,320 @@
<!-- 8500b436-6684-4cb3-a26f-8d927dd70f9b -->
---
todos:
- id: "define-constants"
content: "在 slot-foundation 新增 WalletEventMQ 常量exchange + routingKey"
status: pending
- id: "add-publisher"
content: "在 slot-wallet 新增 WalletBetSuccessMqPublisher封装 {type, uid, data, ts} 载荷组装"
status: pending
- id: "extend-wallet-dto"
content: "slot-wallet / slot-pwa DTO 与 Validate 增加 provider_code、game_code 透传字段"
status: pending
- id: "hook-bet-success"
content: "在 WalletBetWinLogic::bet() commit 后调用 publisher幂等命中跳过"
status: pending
- id: "pwa-pass-game-context"
content: "slot-pwa CashLogic 调 wallet 时透传 provider_code如 pop与 game_code"
status: pending
- id: "consumer-bind"
content: "各消费服务自建 queue 并 bind topic 模式wallet.bet.success / wallet.bet.#"
status: pending
isProject: false
---
# wallet.bet.success 消息体设计方案
## 背景与定位
当前 `slot-wallet` 下注成功后已有两类 MQ但都不适合作为**通用钱包事件总线**
| 现有通道 | 载荷 | 局限 |
|---|---|---|
| `slot_console` / `console_bus` | `{ uid, type:'bet', data:{ source, amount } }` | 仅统计,缺 biz_id / round_id / 资金拆分 |
| `slot_activity` / `trial_reward_bus` | `{ type, uid, data, ts }` | 活动专用,且目前由 **slot-pwa** 在钱包 API 返回后投递,非 wallet 权威源 |
| `slot_hub` | WebSocket 钱包刷新 | 面向客户端,非业务事件 |
`slot-foundation` 文档/测试中的示例 `{"uid":1001,"bet_amount":500}` 过于简陋,无法支撑多服务幂等与金额语义区分。
**新事件定位**wallet 事务提交后的**权威下注成功事件**,供 activity / console / 统计 / 返水等各自按 Topic 模式订阅(如 `wallet.bet.#``wallet.#`)。
```mermaid
sequenceDiagram
participant Caller as 调用方_PWA或游戏网关
participant Wallet as slot_wallet
participant DB as wallet_log
participant Exchange as slot.wallet.event.exchange
participant Activity as slot_activity
participant Stats as 统计服务
Caller->>Wallet: bet(biz_id, fee, round_id, ...)
Wallet->>DB: commit 写入 biz_type=bet
Wallet->>Exchange: publish wallet.bet.success
Exchange-->>Activity: 各自 queue 绑定
Exchange-->>Stats: 各自 queue 绑定
```
---
## 推荐信封结构
沿用 [`TrialRewardMqPublisher`](slot-wallet/app/service/mq/TrialRewardMqPublisher.php) 的约定,便于消费方统一解析:
```json
{
"type": "wallet.bet.success",
"uid": 12345,
"ts": 1718092800,
"data": { }
}
```
- `type`:与 routingKey 一致,便于日志/死信/转发时脱离 routing 元数据也能识别
- `uid`:放信封层,消费方可快速过滤
- `ts`事件产生时间Unix 秒),非 DB `created_at`
- `data`:业务载荷(见下)
---
## 推荐 `data` 字段(通用可扩展)
### 1. 幂等与追溯(必填)
| 字段 | 来源 | 说明 |
|---|---|---|
| `biz_id` | `WalletUpdateRequestDTO->biz_id` | 业务幂等键,消费方应用 `uid + biz_id` 去重 |
| `ledger_id` | `wallet_log.id` | wallet 侧唯一流水号,需查账时可直接定位 |
| `round_id` | request | 关联同局 win |
| `trace_id` | request可空 | 链路追踪 |
### 2. 渠道上下文(必填)
| 字段 | 来源 |
|---|---|
| `currency` | request |
| `source` | request |
| `organization` | request |
### 3. 金额语义(必填,三者不可混用)
下注存在三种路径([`WalletBetWinLogic::bet()`](slot-wallet/app/api/logic/WalletBetWinLogic.php)),金额字段必须区分:
| 字段 | 含义 | 计算规则 |
|---|---|---|
| `bet_amount` | 名义下注额 | 恒等于 request `fee`(最小货币单位,*1000 |
| `wallet_deduct_amount` | 实际扣款额 | real/trial = `fee`FS 代付 = `0` |
| `valid_bet_amount` | 计入打码/有效投注额 | real = `betResult.withdraw + betResult.deposit`trial = `0`FS 代付 = `fee`(全额计入,与 [`TrialValidBetNotifyService`](slot-wallet/app/service/trial/TrialValidBetNotifyService.php) 一致) |
> 消费方按业务选用:`bet_amount` 做流水统计;`valid_bet_amount` 做活动/返水;`wallet_deduct_amount` 做资金变动核对。
### 4. 下注分类(必填)
| 字段 | 取值 | 判定 |
|---|---|---|
| `bet_mode` | `trial` / `real_money` / `free_spin` | 对应三条执行分支 |
辅助布尔字段(便于过滤,与 `bet_mode` 冗余但实用):
- `is_fs_paid_bet`0/1
- `fs_account_id`FS 路径时 > 0否则 0
### 5. 资金拆分摘要real_money 路径建议带trial/FS 可简化)
```json
"deduction": {
"bonus": 0,
"deposit": 3000,
"withdraw": 2000,
"hit_side": "deposit"
}
```
来源:[`BetService::execute()`](slot-wallet/app/service/wallet/BetService.php) 返回的 `betResult`。供返水、RTP、资金归因等消费方使用。
**不建议**在 MQ 中带完整 `fund_detail` / `remark` JSON体积大、含 lot_no 等内部细节);需要时可凭 `ledger_id` 回查 `wallet_log`
### 6. 游戏身份(多上游平台场景,建议必填)
slot-pwa 已有完整游戏映射链(见下),但 **slot-wallet 不查 g_game 表**,因此游戏身份必须由调用方(通常是 slot-pwa在下注请求中透传wallet 原样写入 MQ。
```mermaid
flowchart LR
subgraph pwa [slot_pwa]
ProviderCode["provider_code 如 pop"]
ExternalCode["vendor_game_id 即 external_game_code"]
GameCode["game_code 本平台编码"]
end
subgraph mapping [g_game_platform_mapping]
MapRow["platform_id + external_game_code"]
end
subgraph game [g_game]
GameRow["game_code 全局唯一"]
end
ProviderCode --> MapRow
ExternalCode --> MapRow
MapRow --> GameRow
GameRow --> GameCode
```
| 字段 | 对应 slot-pwa 概念 | 说明 |
|---|---|---|
| `provider_code` | `GPlatformModel.code` / `GameRoundModel.provider_code` | 上游平台编码,如 `pop`;多平台并存时**必填** |
| `game_code` | `GGameModel.game_code` / `PopTransferCallbackContext::gameCode()` | 本平台全局唯一游戏编码;消费方可直接关联 `g_game` |
| `vendor_game_id` | `GGamePlatformMappingModel.external_game_code` | 上游原始游戏 IDPOP 回调 `gameid`);与 `provider_code` 组合可唯一定位 mapping |
| `game_category` | `WalletTrialGameContextResolver::resolveGameCategory()` | 如 `SLOTS`;默认 `SLOTS` |
**唯一定位规则**(与 [`PopGameResolveService`](slot-pwa/app/service/game/PopGameResolveService.php) 一致):
- 主键语义:`provider_code` + `vendor_game_id``g_game_platform_mapping``g_game`
- 消费方若只需本平台游戏:读 `game_code` 即可
- 消费方若需区分上游来源:用 `provider_code` + `vendor_game_id`
- 三者齐全时消费方可交叉校验,避免 mapping 变更导致歧义
当前 gap[`CashLogic::invokeWalletBetOrWin()`](slot-pwa/app/pop/logic/CashLogic.php) 已解析出 `boundGame``providerCode()`,但调 wallet 时**仅传了** `vendor_game_id` / `game_category`**未传** `provider_code` / `game_code`——落地时需补齐。
wallet 层**不持久化**游戏字段到 `wallet_log`,必须在 commit 后从 request 即时写入 MQ。
---
## 完整示例
### 真实资金下注
```json
{
"type": "wallet.bet.success",
"uid": 12345,
"ts": 1718092800,
"data": {
"biz_id": "pg_tx_20240611_001",
"ledger_id": 987654321,
"currency": "USD",
"round_id": "round_abc123",
"source": "myslot",
"organization": 1001,
"trace_id": "trace_xyz",
"bet_amount": 5000,
"wallet_deduct_amount": 5000,
"valid_bet_amount": 5000,
"bet_mode": "real_money",
"is_fs_paid_bet": 0,
"fs_account_id": 0,
"provider_code": "pop",
"game_code": "pop_sweet_bonanza",
"game_category": "SLOTS",
"vendor_game_id": "pg_soft_001",
"deduction": {
"bonus": 0,
"deposit": 3000,
"withdraw": 2000,
"hit_side": "deposit"
}
}
}
```
### FS 代付下注
```json
{
"type": "wallet.bet.success",
"uid": 12345,
"ts": 1718092800,
"data": {
"biz_id": "pg_tx_fs_001",
"ledger_id": 987654322,
"currency": "USD",
"round_id": "round_fs_001",
"source": "myslot",
"organization": 1001,
"trace_id": "",
"bet_amount": 2000,
"wallet_deduct_amount": 0,
"valid_bet_amount": 2000,
"bet_mode": "free_spin",
"is_fs_paid_bet": 1,
"fs_account_id": 5566,
"provider_code": "pop",
"game_code": "pop_sweet_bonanza",
"game_category": "SLOTS",
"vendor_game_id": "pg_soft_001",
"deduction": {
"bonus": 0,
"deposit": 0,
"withdraw": 0,
"hit_side": ""
}
}
}
```
### 试玩期下注
```json
{
"type": "wallet.bet.success",
"uid": 12345,
"ts": 1718092800,
"data": {
"biz_id": "trial_bet_001",
"ledger_id": 987654323,
"currency": "USD",
"round_id": "round_trial_001",
"source": "myslot",
"organization": 1001,
"trace_id": "",
"bet_amount": 1000,
"wallet_deduct_amount": 1000,
"valid_bet_amount": 0,
"bet_mode": "trial",
"is_fs_paid_bet": 0,
"fs_account_id": 0,
"provider_code": "pop",
"game_code": "pop_trial_slots",
"game_category": "SLOTS",
"vendor_game_id": "pg_trial_001",
"deduction": {
"bonus": 1000,
"deposit": 0,
"withdraw": 0,
"hit_side": "bonus"
}
}
}
```
---
## 关键行为约定
1. **仅首次成功投递**:幂等命中(`findByBizIdAndType` 已有记录)时**不**发 MQ避免重复消费。
2. **投递时机**`commit()` 之后,与现有 `sendConsoleBus('bet')` 同级([`WalletBetWinLogic` L106-108](slot-wallet/app/api/logic/WalletBetWinLogic.php))。
3. **失败策略**MQ 发送失败只记 warning 日志,**不回滚**已提交的钱包事务(与 `TrialRewardMqPublisher` 一致)。
4. **消费方幂等**:建议以 `uid + biz_id``ledger_id` 做唯一约束/去重。
5. **与 trial_reward_bus 关系**:短期可并存;长期 activity 可改为订阅 `wallet.bet.success` 并按 `bet_mode` / `valid_bet_amount` 自行路由,逐步去掉 PWA 侧重复投递。
---
## 若落地实现(后续步骤)
1. **slot-foundation**:新增 [`WalletEventMQ`](slot-foundation/src/Constants/MQ/) 常量类(`EXCHANGE = slot.wallet.event.exchange``ROUTING_KEY_BET_SUCCESS = wallet.bet.success`)。
2. **slot-wallet**
- `WalletUpdateRequestDTO` / Validate 增加 `provider_code``game_code`(字符串,可空但游戏下注场景应由调用方传入)
- 新增 `WalletBetSuccessMqPublisher`(或通用 `WalletEventMqPublisher`
-`WalletBetWinLogic::bet()` commit 后组装上述 `data` 并 publish
- 抽取 `resolveBetMode()` / `resolveValidBetAmount()` 私有方法,避免金额语义散落
3. **slot-pwa**(游戏上下文来源):
- `WalletWinOrBetRequestDTO` 增加 `provider_code``game_code`
- [`CashLogic::invokeWalletBetOrWin()`](slot-pwa/app/pop/logic/CashLogic.php) 从 `transferContext->providerCode()``boundGame->game_code` 透传
- [`WalletService::appendTrialGameContext()`](slot-pwa/app/service/WalletService.php) 非空时写入请求体
- 后续新上游平台(非 pop沿用同一字段平台 code 取自各自 `providerCode()` 实现
4. **消费方**:各服务自建 queue + bind`wallet.bet.success``wallet.bet.#`),按 `provider_code` / `game_code` 过滤统计。
---
## 设计原则小结
- **信封统一**`{ type, uid, data, ts }`,与 activity 事件一致
- **金额三分**`bet_amount` / `wallet_deduct_amount` / `valid_bet_amount`,避免 FS、trial、real 混用
- **自洽但不臃肿**:含消费方 80% 场景所需字段;深度明细靠 `ledger_id` 回查
- **游戏三元组**`provider_code` + `vendor_game_id` + `game_code`,对齐 slot-pwa 映射,支持多上游平台
- **权威源在 wallet**:从 PWA 侧活动通知逐步收敛到 wallet 发出的事件;游戏身份由 PWA 解析后透传

View File

@@ -0,0 +1,106 @@
<!-- 2b9f6c44-867e-43e5-b054-01a0ea21c12a -->
---
todos:
- id: "scaffold"
content: "创建 webman-log 目录与 composer.json、.gitignore、phpunit.xml.dist"
status: pending
- id: "src"
content: "复制 SeasLogHandler / SeasLogLineFormatter / TraceContext 到 src 并改为 Quanfuxia\\Log 命名空间"
status: pending
- id: "tests"
content: "复制 testsbootstrap、config/app、config/log、Log 测试)并改命名空间引用"
status: pending
- id: "readme"
content: "编写新库 readme.md用途、安装、log.php 接入示例、ext-seaslog 要求)"
status: pending
- id: "verify"
content: "容器内 composer install 并运行 phpunit 验证"
status: pending
isProject: false
---
# webman-log 独立库拆分方案
## 目标与决策
- 位置:同级目录 `/Users/ray/Documents/project/www/ray/webman-log`
- 包名 `quanfuxia/log`,命名空间 `Quanfuxia\Log\`
- 完全独立:自带一份 trace 上下文,不依赖 `slot/foundation`
- 原库 `slot-foundation/src/Log` 及其引用保持不动(仅复制)
## 来源文件(复制并改命名空间)
- [src/Log/SeasLogHandler.php](/Users/ray/Documents/project/www/ray/slot-foundation/src/Log/SeasLogHandler.php) → `webman-log/src/SeasLogHandler.php`,命名空间改为 `Quanfuxia\Log``use slot\foundation\Trace\TraceContext` 改为 `use Quanfuxia\Log\Trace\TraceContext`
- [src/Log/SeasLogLineFormatter.php](/Users/ray/Documents/project/www/ray/slot-foundation/src/Log/SeasLogLineFormatter.php) → `webman-log/src/SeasLogLineFormatter.php`,命名空间改为 `Quanfuxia\Log`
- [src/Trace/TraceContext.php](/Users/ray/Documents/project/www/ray/slot-foundation/src/Trace/TraceContext.php) → `webman-log/src/Trace/TraceContext.php`,命名空间改为 `Quanfuxia\Log\Trace`(自带,保证独立)
## 新建库目录结构
```text
webman-log/
composer.json
.gitignore
phpunit.xml.dist
readme.md
src/
SeasLogHandler.php # Quanfuxia\Log
SeasLogLineFormatter.php # Quanfuxia\Log
Trace/TraceContext.php # Quanfuxia\Log\Trace
tests/
bootstrap.php
config/
app.php
log.php
Log/
SeasLogLineFormatterTest.php
```
## composer.json 要点
```json
{
"name": "quanfuxia/log",
"type": "library",
"autoload": { "psr-4": { "Quanfuxia\\Log\\": "src/" } },
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } },
"require": {
"php": ">=8.2",
"monolog/monolog": "^2.0",
"workerman/webman-framework": "^2.1"
},
"suggest": { "ext-seaslog": "写入 SeasLog 需要该扩展" },
"require-dev": { "phpunit/phpunit": "^11.5" }
}
```
说明:保留 `workerman/webman-framework`,因为 `TraceContext``Webman\Context``SeasLogHandler``runtime_path()` / `request()` 辅助函数。
## 依赖关系
```mermaid
graph LR
Monolog[Monolog Logger] --> Handler[SeasLogHandler]
Handler --> Formatter[SeasLogLineFormatter]
Handler --> Trace[TraceContext]
Handler --> SeasLog[ext-seaslog]
Trace --> Ctx[Webman Context]
```
## 测试与配置
- 复制并改命名空间:`tests/Log/SeasLogLineFormatterTest.php``use Quanfuxia\Log\...`
- 复制 `tests/bootstrap.php``tests/config/app.php`
- 复制 `tests/config/log.php` 并把 handler/formatter 类引用改为 `Quanfuxia\Log\` 命名空间
- `phpunit.xml.dist` 与原库一致testsuite 名改为 `quanfuxia-log`
- `.gitignore` 复用原库内容vendor、runtime、.phpunit.cache 等)
## readme.md
新库 readme 说明用途Monolog→SeasLog 适配、自动注入 trace_id、安装、`config/log.php` 接入示例、对 `ext-seaslog` 的要求。
## 验证
拆分后在容器内运行:
```bash
docker exec php82 sh -lc 'cd /app/www/ray/webman-log && composer install && ./vendor/bin/phpunit'
```

View File

@@ -0,0 +1,98 @@
<!-- 4a3bab11-4dc6-415d-869a-958278641c6b -->
---
todos:
- id: "disable-webman-log"
content: "在 config/plugin/webman/log/app.php 将 enable 设为 false或接 WEBMAN_LOG_ENABLE 环境变量)"
status: pending
- id: "restart-webman"
content: "docker exec 内执行 php webman restart 使配置生效"
status: pending
- id: "verify-logs"
content: "验证 SeasLog/LoggerService 仍写入 runtime/logs/{worker}/,且 webman.log 不再出现 [Redis] 请求块"
status: pending
isProject: false
---
# 关闭 webman/log middleware 对业务日志的影响
## 结论(直接回答)
**不会影响你本身的业务日志。**
`config/plugin/webman/log/middleware.php` 注册的是插件自带的 `Webman\Log\Middleware`,与业务代码里的 `LoggerService``LogMiddleware` 是两套独立机制。
---
## slot_pwa 里实际有三套日志
```mermaid
flowchart LR
subgraph business [业务日志_不受影响]
LoggerService --> SeasLog
SeasLog --> runtime_logs_worker["runtime/logs/{worker}/"]
end
subgraph monolog [Monolog_不受影响]
SupportLog["support\\Log / Log::debug"] --> config_log["config/log.php"]
config_log --> webman_log_file["runtime/logs/webman.log"]
config_log --> log_log_file["runtime/logs/log.log"]
end
subgraph plugin [webman/log插件_关闭后停止]
WebmanLogMW["Webman\\Log\\Middleware"] --> SupportLogDefault["Log::channel default"]
WebmanLogMW --> auto["自动记录 IP/URL/POST/SQL/Redis"]
end
```
| 日志来源 | 配置/入口 | 写入位置 | 关 webman/log 后 |
|---------|-----------|----------|------------------|
| **LoggerService**(主力) | [app/service/LoggerService.php](slot_pwa/app/service/LoggerService.php) + [app/AppBootStrap.php](slot_pwa/app/AppBootStrap.php) 初始化 SeasLog | `runtime/logs/{worker_name}/` | **正常** |
| **LogMiddleware**traceId | [config/middleware.php](slot_pwa/config/middleware.php) 的 `api`/`gasea` 路由 | 只初始化 traceId业务 log 大多已注释 | **正常** |
| **support\Log / Monolog** | [config/log.php](slot_pwa/config/log.php) | `webman.log` / `log.log` | **正常**(你主动 `Log::info()` 仍会写) |
| **webman/log 插件** | [config/plugin/webman/log/middleware.php](slot_pwa/config/plugin/webman/log/middleware.php) | 同上 `default` 通道 → `webman.log` | **停止**自动请求汇总 |
你看到的 `[Redis] Redis::hincrby('prometheus:...')` 属于最后一行:**插件在每个请求结束时自动拼接 Redis/SQL**,不是 `LoggerService` 打的。
---
## 关闭后会失去什么
仅失去 **webman/log 自动请求日志**,典型内容:
- 请求行:`IP METHOD URL [xxms] [webman/log]`
- `[POST]` 请求体
- `[SQL]` / `[Redis]` 本请求内所有命令(含 Prometheus 的 `hincrby`
- 经插件捕获的异常堆栈(写入同一请求日志块)
官方说明:插件**不替代** webman 内核日志;未捕获异常仍可能由框架异常处理单独记录。
## 关闭后会失去的一个「副作用」能力
`Webman\Log\Middleware` 还会在请求结束检测 **未提交的数据库事务**`rollBack` + 抛 `RuntimeException`。关掉插件后这条开发期保护也会消失(与业务 `LoggerService` 无关,但排障时需注意)。
---
## 推荐关闭方式(不要直接删 middleware.php
Webman 加载插件配置时,若同目录 [config/plugin/webman/log/app.php](slot_pwa/config/plugin/webman/log/app.php) 中 `enable => false`,会**跳过** `middleware.php` 的加载(见 `Config::loadFromDir` 逻辑)。
**推荐**:改 `app.php`,而不是删/清空 `middleware.php`
```php
'enable' => false,
```
或按环境控制(与现有 `DEBUG` 类似):
```php
'enable' => filter_var(getenv('WEBMAN_LOG_ENABLE'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false,
```
改完后需 **restart webman** 生效。
---
## 验证清单(关闭后自测)
1. 触发一条业务接口,确认 `runtime/logs/{worker}/` 下 SeasLog 仍有 `LoggerService` 输出
2. 确认 `runtime/logs/webman.log` 不再出现 `[Redis]` / `[webman/log]` 请求块
3. 故意打一条 `LoggerService::error()``Log::error()`,确认仍写入对应文件
4. 异常接口:确认业务异常处理与 SeasLog 错误日志仍正常

View File

@@ -0,0 +1,108 @@
<!-- 215d598c-9837-4e65-a3e9-850d78cd5f00 -->
## 周返水Weekly Cashback实现方案
### 已定决策(来自本轮确认)
- 二、三:按评审意见修订需求文档、计费/配置与每日返水解耦。
- 四:方案 B —— 周有效投注每日落库,规避 `user:profit:Ymd` 的 7 天 TTL。
- 五.1VIP 费率存活动配置 `ext_config.vip_rates`type=13不放 VIP 表。
- 五.2VIP 取「周末最后一刻」(结算下周一读当前 VIP 即可)。
- 五.3:结算上周的同时把「上上周」未领取的置过期。
- 五.4VIP0 不进页面,不处理。
- 五.5/6/7倒计时对齐周界四周记录入库返水 `floor` 取整到厘。
### 计费模型(与每日返水的关键差异)
- 每日返水 = 下注累进分段(`tiers`);周返水 = `周有效投注 × rate(vip%)` 单乘,按 `floor` 取整。
- 统计周:周一 00:00:00 ~ 周日 23:59:59**服务器时区**(统一口径,修订文档里"美国时间"表述)。
- 周有效投注 = 当周 7 天 `rebate_bet` 之和(仅充值用户累加,沿用 `slot_pwa` 现有口径pwa 无需改动)。
### 数据流
```mermaid
flowchart TD
pwa["slot_pwa incBet 仅充值用户累加 rebate_bet"] --> redis["redis user:profit:Ymd TTL7天"]
accrue["weeklyRebateAccrue 每天00:05 处理昨日"] --> redis
accrue --> rec["weekly_rebate_record 当周Pending bet_amount累加"]
settle["weeklyRebateSettle 周一00:15"] --> rec
settle --> vip["读当前VIP rate=vip_rates level"]
settle --> expirePrev["上上周 Claimable -> Expired"]
claim["C端 claim 上周"] --> wallet["WalletService gift 类型68 打码Y3"]
```
### 落库方案 B幂等
- 新表 `weekly_rebate_record` 增加 `last_accrued_date`date
- `weeklyRebateAccrue`(每天)处理**昨日**key 仅 1 天,必在 TTL 内):遍历昨日 `user:profit:` 哈希,充值用户且 `rebate_bet>0` → 定位昨日所属周的记录(无则建 Pending→ 若 `last_accrued_date < 昨日``bet_amount += rebate_bet` 且更新 `last_accrued_date`(重跑不重复累加)。
- C 端「This Week」展示 = `record.bet_amount`(昨日及之前)+ 今日 redis 实时 `rebate_bet``rebate = floor(weekBet * rate /100)`(镜像每日返水 `syncTodayPending` 思路)。
### 结算/过期(周一)
- `weeklyRebateSettle`:对上周 `week_start` 的 Pending 记录,读当前 VIP → `rate=vip_rates[level]``rebate=floor(bet*rate/100)``rebate>0``Claimable`,写 `vip_level`/`rate_snapshot`/`claimable_at=本周一`/`expire_at=下周一`;同一次把「上上周」`Claimable 且 expire_at<=now``Expired`
- `weeklyRebateExpire`:兜底,`status=Claimable AND expire_at<=now -> Expired`(镜像 `expireDueRecords`)。
- 顺序accrue 先于 settle。
### 复用 vs 新建
- 复用骨架:领取短事务 CAS + 钱包入账失败回滚 + 结算结构化日志(镜像 [DailyRebateLogic](slot_console/app/api/logic/DailyRebateLogic.php)`RechargedUidSetService``UserProfitEntity`、四态 + `statusLabel`、Controller/Validator 形态。
- 新建VIP 费率,不复用 tiers 累进):`WeeklyRebateCalcService::calcRebateLi(betLi, ratePercent)``WeeklyRebateConfigService`(解析 `vip_rates``maxRate``rateForVip``canonicalExtConfigForStorage`)。
### 文件清单
slot_lib
- [Consts.php](slot_lib/src/common/const/Consts.php) 新增 `ACTIVITY_TYPE_WEEKLY_REBATE=13``TRANSACTION_TYPE_WEEKLY_REBATE=68`
slot_console
- 模型 `app/model/common/WeeklyRebateRecordModel.php`(四态 + `findByUidAndWeek`)。
- `RechargeGiftConfigModel``TYPE_WEEKLY_REBATE=13``getWeeklyRebateActiveInfo/...ByExactSource`(镜像每日返水方法)。
- 服务 `app/service/WeeklyRebateCalcService.php``app/service/WeeklyRebateConfigService.php`
- Logic `app/api/logic/WeeklyRebateLogic.php`info/claim/accrue/settle/expire
- Controller `app/api/controller/WeeklyRebateController.php`、Validator `app/api/validator/WeeklyRebateValidator.php`
- 命令 `app/command/WeeklyRebateAccrue.php``WeeklyRebateSettle.php``WeeklyRebateExpire.php`
- 路由:在 api 路由注册 weekly-rebate info/claim与每日返水同处
- SQL `db/weekly_rebate.sql`(含 `last_accrued_date`、唯一键 `uniq_uid_week`)。
- 活动配置入口 [ActivityConfigEntity.php](slot_console/app/entity/activity/ActivityConfigEntity.php) 读写处对 `TYPE_WEEKLY_REBATE``WeeklyRebateConfigService::canonicalExtConfigForStorage`
- 部署文档 `doc/weekly_rebate_deploy.md`(镜像 daily_rebate_deploy
backend/slot_admin+ vue
- 字典 `activity_type` 增「周返水」value=13菜单「周返水统计」活动 type=13 编辑页 VIP 费率16 档)配置 UI周返水统计页。
slot_wallet
- 确认 `TRANSACTION_TYPE_WEEKLY_REBATE=68` 打码归类为 活动赠送 Y3`SOURCE_TYPE_ACTIVITY_REWARD=3`),与每日返水一致;如有类型白名单需补 68。
### 需求文档修订(周返水.md
- 领取按钮「昨天返水金额」→「上一周返水金额」。
- 规则文案「refreshes daily」改为「本周累计实时刷新、每周一结算」。
- 时区统一为服务器时区(删「美国时间」歧义)。
- `No Btes``No Bets`
### ext_config 标准格式type=13
```json
{
"vip_rates": [
{"vip_level": 0, "rate_percent": 0},
{"vip_level": 1, "rate_percent": 0.5},
{"vip_level": 15, "rate_percent": 2.0}
],
"banner_image": ""
}
```
### cron
- `weeklyRebateAccrue` 每天 00:05`weeklyRebateSettle` 每周一 00:15`weeklyRebateExpire` 每周一 00:20兜底
### 收尾门禁
-`app/**/*.php` 后按 `slot-backend-completion-report` Skill 跑 `verify-slot-backend.sh` + docker `php -l`,回复附「检测结果」章节。
</plan>
<todos>
[
{"id": "doc-fix", "content": "修订 周返水.md 笔误(昨天->上周、daily->weekly、时区统一、No Bets"},
{"id": "consts", "content": "slot_lib Consts 新增 ACTIVITY_TYPE_WEEKLY_REBATE=13、TRANSACTION_TYPE_WEEKLY_REBATE=68"},
{"id": "sql-model", "content": "建 weekly_rebate.sql 与 WeeklyRebateRecordModel含 last_accrued_date、四态、findByUidAndWeek"},
{"id": "config-model-methods", "content": "RechargeGiftConfigModel 加 TYPE_WEEKLY_REBATE 与 getWeeklyRebateActiveInfo 系列方法"},
{"id": "services", "content": "WeeklyRebateCalcService(VIP费率单乘) 与 WeeklyRebateConfigService(解析 vip_rates/maxRate/rateForVip/canonical)"},
{"id": "logic", "content": "WeeklyRebateLogicinfo/claim(短事务CAS+钱包回滚)/accrue/settle(读VIP+过期上上周)/expire"},
{"id": "controller-route", "content": "WeeklyRebateController + WeeklyRebateValidator + 路由注册"},
{"id": "commands", "content": "命令 WeeklyRebateAccrue/Settle/Expire + cron 配置"},
{"id": "activity-entry", "content": "ActivityConfigEntity 读写 type=13 走 canonicalExtConfigForStorage"},
{"id": "admin", "content": "slot_admin 字典/菜单/type=13 VIP费率配置页/周返水统计页"},
{"id": "wallet-verify", "content": "slot_wallet 确认流水类型68 打码归 活动赠送Y3"},
{"id": "deploy-doc", "content": "编写 weekly_rebate_deploy.md 部署文档"},
{"id": "verify-gate", "content": "跑 verify-slot-backend.sh + php -l输出检测结果章节"}
]
</todos>
</invoke>

View File

@@ -0,0 +1,68 @@
<!-- 215d598c-9837-4e65-a3e9-850d78cd5f00 -->
## 每周返水统计(后台)
镜像现有 Free Credits 统计页([FreeCreditsStatsController.php](backend/slot_admin/app/game/controller/FreeCreditsStatsController.php) / [FreeCreditsStatsLogic.php](backend/slot_admin/app/game/logic/FreeCreditsStatsLogic.php) / [freeCreditsStats/index.vue](backend/slot_admin_vue/src/views/game/freeCreditsStats/index.vue) / [menu-free-credits-stats.sql](backend/slot_admin/db/menu-free-credits-stats.sql)),同请求返回 `data/total/otherData(总计)`
### 依赖
- 依赖「周返水核心」计划已建表 `s_common.weekly_rebate_record`(字段:`uid, source, week_start, bet_amount(厘), rebate_amount(厘), status(1待定/2待领/3已领/4过期), vip_level, rate_snapshot, last_accrued_date, claimable_at, expire_at, claimed_at`)。
- 小调整(核心计划侧):`weeklyRebateAccrue` 累加当周 Pending 行时,同时把当前 `vip_level``rate_snapshot` 写入该行,使后台对所有状态(含待定)都能直接读行内费率,无需查实时 VIP / 共享 Redis。
### 数据流
```mermaid
flowchart LR
vue["每周返水统计 index.vue sa-table"] -->|"GET /game/weeklyRebateStats/index"| ctrl["WeeklyRebateStatsController"]
ctrl --> dto["WeeklyRebateStatsQueryDTO + Validate"]
dto --> logic["WeeklyRebateStatsLogic list + statistics"]
logic --> model["WeeklyRebateRecordModel connection s_common"]
logic -->|"data/total"| vue
logic -->|"otherData 总计"| vue
```
### 后端文件backend/slot_admin
- 模型 `app/game/model/common/WeeklyRebateRecordModel.php``connection='s_common'``table='weekly_rebate_record'`,四态常量 + `statusLabel`(镜像 [FreeCreditsPlayerModel.php](backend/slot_admin/app/game/model/common/FreeCreditsPlayerModel.php))。
- DTO `app/game/dto/WeeklyRebateStatsQueryDTO.php`(镜像 [FreeCreditsStatsQueryDTO.php](backend/slot_admin/app/game/dto/FreeCreditsStatsQueryDTO.php),归一化 page/limit
- 校验 `app/game/validate/WeeklyRebateStatsValidate.php``uid/source/status/stat_range[]/orderBy(in:bet_amount,rebate_amount)/orderType/page/limit`(镜像 [FreeCreditsStatsValidate.php](backend/slot_admin/app/game/validate/FreeCreditsStatsValidate.php))。
- 控制器 `app/game/controller/WeeklyRebateStatsController.php``index()` 返回 `list + otherData=statistics`(路由自动 `/game/weeklyRebateStats/index`)。
- Logic `app/game/logic/WeeklyRebateStatsLogic.php`
- `buildQuery($params)``uid` 精确;`source` 精确;`status` 单/多选 `whereIn`;按日范围 snap 整周 → `week_start >= 周一(start)``week_start <= 周一(end)`;排序白名单 `bet_amount`/`rebate_amount`,默认 `week_start desc`
- `list()`:分页取行,逐行补 `rate(=rate_snapshot)``week_start/week_end(week_start+6)``is_current_week``status_label`,金额 `getNumberFormat` 转美元字符串。
- `statistics()`(顶部总计,对筛选集合聚合):
- 总领取 `SUM(rebate_amount) WHERE status=已领`
- 总待领取 `SUM(rebate_amount) WHERE status=待领取`
- 总过期 `SUM(rebate_amount) WHERE status=过期`
- 领取率 `总领取/(总领取+总待领取+总过期)`(分母 0 → 0保留 2 位百分比
- 待定 `SUM(rebate_amount) WHERE status=待定`,仅当筛选范围包含本周时计算,否则 0
### 前端backend/slot_admin_vue
- `src/api/game/weeklyRebateStats.js``getPageList``/game/weeklyRebateStats/index`
- `src/views/game/weeklyRebateStats/index.vue``sa-table`(镜像 freeCreditsStats
- 搜索区用户ID(input)、渠道(`commonStore.allSourcesOptionsNoAll` 可搜)、状态(四种 select)、统计周期(`a-range-picker` mode=date 不带时分)。
- `#tableAfterButtons` 顶部总计:总领取 / 总待领取 / 总过期 / 领取率% / 待定。
-用户ID、渠道号、有效下注额(sortable)、返水率、返水金额(sortable)、状态(tag)、统计周期(`本周``week_start - week_end`)。
- `stats = crudRef.getTableOtherData()`;金额已由后端格式化,前端直接展示。
### 菜单 SQLbackend/slot_admin/db/menu-weekly-rebate-stats.sql
- 镜像 [menu-free-credits-stats.sql](backend/slot_admin/db/menu-free-credits-stats.sql):挂在「综合统计」父菜单下(部署时确认其 `sm_system_menu.id`,缺失则建),`code/route/component = game/weeklyRebateStats/index`,并补 `sm_system_group_menu` 授权。
### 字段口径与说明
- 仅统计充值用户(`weekly_rebate_record` 本就只对充值用户落库)。
- 返水率取行内 `rate_snapshot`:历史行为结算时快照,待定行为 accrue 时写入的当前 VIP 费率(每周按最新 VIP
- 待定行 `bet_amount` 为已 accrue 到昨日的累计(后台读库口径,不含当日实时增量)。
- 金额单位厘 → `getNumberFormat` 转美元展示。
### 收尾门禁
-`app/**/*.php` 后按 `slot-backend-completion-report` Skill 跑 `verify-slot-backend.sh` + docker `php -l`,回复附「检测结果」章节(含 PHPDoc:checked
</plan>
<todos>
[
{"id": "stats-model", "content": "slot_admin WeeklyRebateRecordModel(connection s_common) + 四态/statusLabel"},
{"id": "stats-dto-validate", "content": "WeeklyRebateStatsQueryDTO + Validate(uid/source/status/stat_range/orderBy白名单)"},
{"id": "stats-controller", "content": "WeeklyRebateStatsController index 返回 list + otherData"},
{"id": "stats-logic", "content": "WeeklyRebateStatsLogicbuildQuery(周snap/排序) + list(行补week区间/rate/状态) + statistics(五项总计+领取率+待定本周判断)"},
{"id": "stats-vue", "content": "weeklyRebateStats api.js + index.vue(筛选/顶部总计/可排序列/统计周期展示)"},
{"id": "stats-menu-sql", "content": "menu-weekly-rebate-stats.sql 挂综合统计父菜单 + 分组授权"},
{"id": "accrue-rate-snapshot", "content": "核心侧调整accrue 对 Pending 行写入当前 vip_level/rate_snapshot"},
{"id": "stats-verify", "content": "跑 verify + php -l 输出检测结果章节"}
]
</todos>

View File

@@ -0,0 +1,111 @@
---
name: 充值后下注返水
overview: 在下注发生时用现有 `user:recharged` 集合判断是否已充值,仅充值用户额外累计 `rebate_bet`;每日返水改用 `rebate_bet`,不影响 VIP 输钱返利等仍使用全量 `bet`
todos:
- id: pwa-rebate-bet
content: pwa UserProfitService 增加 rebate_bet并在 incBet 时用 user:recharged 集合判断后累计
status: completed
- id: console-use-rebate-bet
content: console UserProfitEntity + DailyRebateLogic 结算/同步改用 rebate_bet
status: completed
- id: deploy-doc-test
content: 更新 daily_rebate_deploy.md 发布顺序与冒烟用例;跑 verify + 相关测试
status: completed
isProject: false
---
# 充值后的下注才计入每日返水
## 背景与缺口
当前实现([`DailyRebateLogic`](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php))仅在结算/同步时用 `userHasDeposit()` 过滤,下注额来自共享 Redis 日哈希([`slot_pwa` `UserProfitService::incBet`](file:///Users/ray/Documents/project/www/slot/slot_pwa/app/service/user/UserProfitService.php)**全天 `bet` 无差别累计**。
因此「同一天先下注、后首充」会把首充前下注也算进返水——与产品「充值后的下注才算」不符。
**产品已确认口径**
- 开启计时:普通充值 + 签到购(与现网 `RechargedUidSetService::add` 一致)
- 历史:仅上线日起按新规则;不回溯历史结算日
**约束**:同一 Redis 日盈利哈希还被 [`LoseReturnDeposit`](file:///Users/ray/Documents/project/www/slot/slot_console/app/command/LoseReturnDeposit.php)VIP 输钱返利)使用,**不能**改 `bet` 语义或阻止未充值用户累计 `bet`
```mermaid
flowchart LR
subgraph pwa [slot_pwa]
Bet[WalletService bet]
IncBet[UserProfitService.incBet]
Bet --> IncBet
IncBet --> betField["bet 全量"]
IncBet --> CheckSet["SISMEMBER user:recharged"]
CheckSet --> rebateBetField["充值用户才累加 rebate_bet"]
end
subgraph wallet [slot_wallet]
Recharge[recharge / rechargeSign]
Recharge --> SetUid[SADD user:recharged]
end
subgraph console [slot_console]
Settle[dailyRebateSettle]
Settle --> UseRebateBet["读 rebate_bet 算档"]
end
rebateBetField --> Settle
SetUid --> CheckSet
```
---
## 方案:新增 `rebate_bet`,下注时用集合判断
### 1. slot_pwa — 下注时累计 `rebate_bet`
扩展 [`UserProfitService`](file:///Users/ray/Documents/project/www/slot/slot_pwa/app/service/user/UserProfitService.php)
- 序列化结构:`bet``win`、**`rebate_bet`**(默认 0反序列化兼容旧数据
- 新建或复用一个轻量 `RechargedUidSetService`(读 share Redis `SISMEMBER user:recharged`
- `incBet` 逻辑:
- `bet` 仍始终 `+= fee`(供输钱返利等)
-`SISMEMBER user:recharged uid = 1`,则 `rebate_bet += fee`
- 否则不累加 `rebate_bet`
**性能**:每次下注 1 次 `SISMEMBER`;如下注高频,可对「已充值」状态做短 TTL 本地/Redis 缓存可选非必须。Redis 异常时按未充值处理,宁可少算不可多算。
### 2. slot_console — 返水只读 `rebate_bet`
| 文件 | 改动 |
|------|------|
| [`UserProfitEntity`](file:///Users/ray/Documents/project/www/slot/slot_console/app/entity/command/UserProfitEntity.php) | 增加 `public int $rebate_bet = 0` |
| [`DailyRebateLogic`](file:///Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php) | `settleOneUserFromProfitHash``readTodayBetFromRedis``syncTodayPending` 使用 **`rebate_bet`** 作为有效下注(无字段或旧日为 0**禁止** fallback 到 `bet`,避免误发) |
| 结算日志 | `samples` 中 bet 字段可改为 `rebate_bet` 或同时打 `bet`/`rebate_bet` 便于对账 |
保留现有 `userHasDeposit()` 校验(双保险:未在集合内仍 skip
### 3. 部署与验收(更新 [`daily_rebate_deploy.md`](file:///Users/ray/Documents/project/www/slot/slot_console/doc/daily_rebate_deploy.md)
**发布顺序**:确认 `center`/`wallet` 已有 `user:recharged` 机制并完成回填 → 发布 `slot_pwa` → 发布 `slot_console``slot_pwa` 必须早于次日 `dailyRebateSettle` cron
**冒烟用例**
1. 未充值用户下注 → `rebate_bet=0` → 结算 skip / 无可领
2. 同日:先下注 100 → 再首充 → 再下注 200 → 仅 `rebate_bet=200` → 返水按 200 档位
3. 已充值老用户(已在 `user:recharged` 集合)→ 上线后下注直接累计 `rebate_bet`
4. VIP 输钱返利:仍读 `bet`,行为不变
---
## 不改动的部分
- `user:recharged` SET 语义不变
- `center` 不新增共享 Redis key
- `wallet` 充值成功后写集合的逻辑不变
- 领取/活动页 `userHasDeposit` 门槛不变
- 不改动 `slot_sdk`(无跨服务 HTTP
- 不做历史日 `rebate_bet` 回填
---
## 风险与说明
| 项 | 说明 |
|----|------|
| 上线前已在 set 的老用户 | 上线后发生的下注会直接累计 `rebate_bet`,符合“已充值用户后续下注算返水” |
| 首充与下注并发 | 以下注时 `SISMEMBER user:recharged` 的结果为准;集合写入前的下注不计入,写入后的下注计入 |
| Redis 异常 | pwa 判断集合失败应记 error 且 **不累加** `rebate_bet`(宁可少算不可多算) |

View File

@@ -0,0 +1,236 @@
---
name: 全局活动轮次
overview: Lucky Rewards 活动配置与轮次均改为全平台唯一:`lucky_reward_config``lucky_reward_tier_config``lucky_reward_cycle` 移除 `source`;转盘奖项已迁至 `reward_pool_item`(无 sourcelegacy `lucky_reward_prize_config` 确认删除C 端仍用 `lucky_reward_player.source` 记录用户注册渠道。
todos:
- id: ddl-migration
content: 新增 lucky_reward_drop_source.sqlconfig/tier/cycle 删 source确认 legacy prize_config 已 DROP同步 lucky_reward.sql 与需求文档
status: completed
- id: config-model-service
content: LuckyRewardConfigModel 改为 findEnabled() 单例ConfigService requireEnabledConfig/resolveActiveCycle/createNextCycle 去 source 参数
status: completed
- id: cycle-model-rollover
content: LuckyRewardCycleModel 全局 findActive/maxCycleNoCycleRolloverService 去 sourceTierConfig 去 source
status: completed
- id: console-callers
content: Logic/Service 配置查询改 findEnabled();轮次查询改 findActive()player 写入仍保留 resolvedSource
status: completed
- id: admin-crud
content: slot_admin Config/Tier Logic/Validate/Controller 去 source 字段;主配置改为单例校验
status: completed
- id: tests-verify
content: 更新集成测试 seed全局 config+cycle补跨渠道同 config/cycle 用例phpunit + verify-slot-backend.sh
status: completed
isProject: false
---
# Lucky Rewards 配置与轮次全局化(不区分渠道)
## 目标与边界
| 维度 | 变更后 |
| --- | --- |
| `lucky_reward_config` | **全平台唯一**主配置,**移除 `source` 列**(表内仅保留一行或业务层强制单例) |
| `lucky_reward_tier_config` | **移除 `source` 列**,仅通过 `config_id` 关联主配置 |
| `lucky_reward_prize_config` | **已废弃**;奖项 SSOT 为 **`reward_pool` + `reward_pool_item`**`pool_code=lucky_reward`**本身无 `source`**)。若环境仍残留 legacy 表,执行 DROP**无需**对其 ALTER 删列 |
| `lucky_reward_cycle` | **全平台唯一**进行中轮次,**移除 `source` 列** |
| `lucky_reward_player.source` 等业务表 | **保留**,表示**用户注册/归属渠道**,供后台统计筛选,与活动配置无关 |
| wheel 短码 `biz_id=cycle_id` | **不变**,全渠道共用同一 `cycle_id` |
```mermaid
flowchart TB
subgraph before [现状]
ConfigA[config source=A]
ConfigB[config source=B]
ConfigA --> CycleA[cycle source=A]
ConfigB --> CycleB[cycle source=B]
end
subgraph after [改造后]
GlobalConfig[单一 lucky_reward_config]
GlobalCycle[单一 active cycle]
PlayerA[player source=渠道A]
PlayerB[player source=渠道B]
GlobalConfig --> GlobalCycle
GlobalCycle --> PlayerA
GlobalCycle --> PlayerB
end
```
与 [`00_整体技术方案.md`](docs/requirements/lucky_rewards/00_整体技术方案.md) 一致cycle 表本无 `source`;配置亦改为全局单套参数。
---
## 1. DDL 与数据迁移
新增 [`backend/slot_admin/db/lucky_reward_drop_source.sql`](backend/slot_admin/db/lucky_reward_drop_source.sql)(合并原 cycle 脚本,一次迁移三表):
### 1.1 `lucky_reward_config`
1. **合并多行配置**(若存在按渠道多行)
- 保留 **`source='default'``status=1` 的行**;若无 default 启用行则保留 `id` 最小且 `status=1` 的行
- 将其余行的 `lucky_reward_tier_config.config_id` **重指向**保留行
- 删除其余 config 行
2. **改表**
- `DROP INDEX idx_source`
- `DROP COLUMN source`
### 1.2 `lucky_reward_tier_config`
- `DROP COLUMN source`(已通过 `config_id` 关联列冗余admin Tier Logic/Validate 同步去 source 字段)
### 1.3 `lucky_reward_prize_config`legacy
> 现状:转盘奖项已迁移至 [`reward_pool.sql`](backend/slot_admin/db/reward_pool.sql) 的 `reward_pool_item`console 通过 [`LuckyRewardConfigService::listLuckyRewardPoolItems()`](slot_console/app/service/luckyReward/LuckyRewardConfigService.php) 读 `pool_code=lucky_reward`**无渠道维度**。
迁移步骤(写入 `lucky_reward_drop_source.sql` 末尾或独立段落):
1.**`lucky_reward_prize_config` 表仍存在** 且尚未迁移:先跑 [`reward_pool_migrate_lucky_reward_prize.sql`](backend/slot_admin/db/reward_pool_migrate_lucky_reward_prize.sql)(迁移脚本内 `@config_id` 改为取合并后的**唯一** config 行,不再 `WHERE source='default'`
2. 验证 `reward_pool_item` 数据完整后,执行 [`reward_pool_drop_legacy_prize_config.sql`](backend/slot_admin/db/reward_pool_drop_legacy_prize_config.sql)`DROP TABLE IF EXISTS lucky_reward_prize_config`
3. **无 PHP Model/Logic 改动**(已无 `LuckyRewardPrizeConfig*` 代码路径;后台菜单已指向 `RewardPoolItemController`
文档同步:[`04_slot_console_活动主流程方案.md`](docs/requirements/lucky_rewards/04_slot_console_活动主流程方案.md) 中将 `lucky_reward_prize_config` 表述改为 `reward_pool_item`
### 1.4 `lucky_reward_cycle`
1. 合并重复 `status=1`:保留 `id` 最大一行,其余改 `STATUS_ENDED=2`(不合并 player 行)
2. `DROP INDEX idx_source_status``DROP COLUMN source``ADD KEY idx_status (status)`
### 1.5 同步基线 DDL
- [`backend/slot_admin/db/lucky_reward.sql`](backend/slot_admin/db/lucky_reward.sql) — config / tier / cycle 去掉 `source`**不包含**已废弃的 prize_config 建表
- [`docs/requirements/lucky_rewards/02_管理后台方案.md`](docs/requirements/lucky_rewards/02_管理后台方案.md) §2.1 / §2.3 / §2.4(去掉「用 source 区分渠道」§2.2 已说明 prize 在 reward_pool
---
## 2. Model 层
### 2.1 [`LuckyRewardConfigModel`](slot_console/app/model/common/LuckyRewardConfigModel.php)console + admin 同步)
- 删除 `@property string $source`
- 删除 `findEnabledBySourceWithFallback(string $source)`
- 新增 `findEnabled(): ?self``status=STATUS_ENABLED``order id asc`,取唯一启用配置
- admin Model 去掉 search 按 source 的 scope若有
### 2.2 [`LuckyRewardTierConfigModel`](slot_console/app/model/common/LuckyRewardTierConfigModel.php)
- 删除 `@property string $source`
- `listEnabledByConfigId(int $configId)` 不变
### 2.3 [`LuckyRewardCycleModel`](slot_console/app/model/common/LuckyRewardCycleModel.php)
- 删除 `source``findActiveBySource` / `maxCycleNo(string $source)`
- 新增 `findActive(): ?self``maxCycleNo(): int`
---
## 3. Service 层slot_console
### 3.1 [`LuckyRewardConfigService`](slot_console/app/service/luckyReward/LuckyRewardConfigService.php)
| 方法 | 改造 |
| --- | --- |
| `requireEnabledConfig()` | 无参;内部 `findEnabled()`,未开启抛 `BusinessException` |
| `resolveActiveCycle(LuckyRewardConfigModel $activityConfig)` | 去掉 `$source`;查 `findActive()`;创建下一轮调 `createNextCycle($activityConfig, ...)` |
| `createNextCycle(LuckyRewardConfigModel $activityConfig, ?int $baseTimestamp = null)` | 去掉 `$source``cycle_days` 直接取自传入的**唯一** `$activityConfig`;不写 cycle.source事务 + `FOR UPDATE` 防并发双 active |
### 3.2 [`LuckyRewardCycleRolloverService`](slot_console/app/service/luckyReward/LuckyRewardCycleRolloverService.php)
- `rolloverExpiredCycle`:去掉 `$expiredCycle->source`
- 开启下一轮:`findActive()` 为空且 `findEnabled()` 非空时 `createNextCycle($activityConfig, $nowTimestamp)`
---
## 4. Logic / 其它调用点slot_console
**配置读取**:所有 `findEnabledBySourceWithFallback($resolvedSource)` / `requireEnabledConfig($source)` 改为无参全局读取。
**轮次读取**:所有 `findActiveBySource(...)` 改为 `findActive()`
**仍保留 `resolveSource($uid, $source)` 的场景**(仅写业务行渠道归属,**不**用于查配置):
- `lucky_reward_player.source`
- `lucky_reward_spin_grant.source`
- `lucky_reward_helper.source`
- `lucky_reward_spin_record.source`
- 日志 / MQ 回调 `channel_code` 字段
| 文件 | 改动要点 |
| --- | --- |
| [`LuckyRewardLogic.php`](slot_console/app/api/logic/LuckyRewardLogic.php) | enter/spin/openBox/toBalance配置无参轮次 `findActive()`player 写入仍用 `$resolvedSource` |
| [`LuckyRewardInviteLogic.php`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) | invite_bind、弹窗、claim配置/轮次全局helper/player 仍写用户渠道 |
| [`LuckyRewardSupportInvitePendingService.php`](slot_console/app/service/luckyReward/LuckyRewardSupportInvitePendingService.php) | flush 时查全局 config + active cycle |
| [`ShareService.php`](slot_console/app/service/ShareService.php) | `resolveOrCreateWheelShareRow``requireEnabledConfig()` + `resolveActiveCycle($config)` |
Controller 仍向 Logic 传 `$request->userEntity->source`(供 player 归属Logic 内部不再用它查 config。
---
## 5. 管理后台slot_admin
| 文件 | 改动 |
| --- | --- |
| [`LuckyRewardConfigLogic`](backend/slot_admin/app/game/logic/LuckyRewardConfigLogic.php) | 去掉 `source` 写入与 `ensureSourceUnique`;新增 **单例校验**(全表仅允许 1 行update 除外) |
| [`LuckyRewardConfigValidate`](backend/slot_admin/app/game/validate/LuckyRewardConfigValidate.php) | save/update scene 移除 `source` |
| [`LuckyRewardConfigController`](backend/slot_admin/app/game/controller/LuckyRewardConfigController.php) | index 去掉 source 筛选options 返回 `id,status`(无 source |
| [`LuckyRewardTierConfigLogic`](backend/slot_admin/app/game/logic/LuckyRewardTierConfigLogic.php) | 去掉 `source` 写入与列表 `config_source` 展示 |
| [`LuckyRewardTierConfigValidate`](backend/slot_admin/app/game/validate/LuckyRewardTierConfigValidate.php) | 移除 `source` |
| Model PHPDoc | Config / Tier / Cycle 三表同步 |
[`LuckyRewardStatsLogic`](backend/slot_admin/app/game/logic/LuckyRewardStatsLogic.php)**不变** — 统计仍可按 `lucky_reward_player.source` 筛用户渠道。
**前端**(若 [`slot_admin_vue`](backend/slot_admin_vue) 有 Lucky Reward 配置页渠道字段):移除主配置/分层表单中的「渠道」输入与列表列(本计划标注为 admin 联调项,改 PHP 后前端需同步)。
---
## 6. 测试
[`LuckyRewardDbTestCase::seedEnabledActivity`](slot_console/tests/Integration/LuckyRewardDbTestCase.php)
- 不再按 `TEST_SOURCE` 创建独立 config改为 **更新全局唯一 config 为 ENABLED**(或 insert 仅当表空)
- cycle seed 不写 `source`;先 `findActive()` 结束旧 active
- player / helper 测试行仍可写 `source => TEST_SOURCE` 模拟用户渠道
更新所有集成测试中的 `findEnabledBySourceWithFallback` / `findActiveBySource` / config 按 source 查询。
**新增用例**:两渠道用户(不同 `player.source`)进入活动,得到相同 `config` 参数与 `cycle_id`
---
## 7. 部署顺序
1. 执行 `lucky_reward_drop_source.sql`(合并 config + 删列)
2. **同批发布** slot_console + slot_admin删列后旧代码会 SQL 报错)
3. slot_admin_vue 去渠道字段(可与后端同批或紧随其后)
---
## 8. 风险与验收
| 风险 | 处理 |
| --- | --- |
| 线上多渠道各有 config / active cycle | 迁移脚本合并为单行 config + 单行 active cycle |
| 非 default 渠道 config 有差异化参数 | 迁移保留 default 启用行;运营需事先确认 default 参数为 SSOT |
| 并发创建双 active cycle | `createNextCycle` 事务 + 行锁 |
| 后台误建第二条 config | Logic 单例校验拦截 |
**验收清单**
- 全渠道用户 `enter` 读到同一套 target_amount / cycle_days / tier
- 全渠道同一 `cycle_id`;轮转后仅一条新 active
- 后台主配置 CRUD 无渠道字段,不可新增第二条
- 统计页仍可按 **玩家 source** 筛选
- `phpunit` Lucky Rewards 集成测 + `verify-slot-backend.sh` PASS
---
## 涉及文件汇总
**DDL**`lucky_reward_drop_source.sql``lucky_reward.sql``reward_pool_migrate_lucky_reward_prize.sql`(改 config 选取逻辑)、`reward_pool_drop_legacy_prize_config.sql`、需求文档
**slot_console**`LuckyRewardConfigModel``LuckyRewardTierConfigModel``LuckyRewardCycleModel``LuckyRewardConfigService``LuckyRewardCycleRolloverService``LuckyRewardLogic``LuckyRewardInviteLogic``LuckyRewardSupportInvitePendingService``ShareService`、集成测试
**slot_admin**`LuckyRewardConfigModel``LuckyRewardTierConfigModel``LuckyRewardCycleModel``LuckyRewardConfigLogic``LuckyRewardTierConfigLogic`、对应 Validate/Controller
**无需改动(已无 source**`reward_pool` / `reward_pool_item``RewardPoolItemLogic`(转盘奖项 CRUD
**保留 source 不改**`lucky_reward_player` 及 spin/helper/record/grant 业务表;`LuckyRewardStatsLogic` 玩家渠道筛选

View File

@@ -0,0 +1,137 @@
---
name: 渠道编号自动生成
overview: 在 slot-admin 后端创建渠道时自动生成唯一 6 位字母数字编号编辑时禁止修改slot-admin-vue 前端对应隐藏创建输入、编辑只读展示,并移除前端校验。
todos:
- id: backend-add-generate
content: GameServerLogic实现 add() + generateUniqueSource(),合并原 save() 逻辑edit() unset source
status: completed
- id: backend-validate
content: GameServerValidatesave/update 场景移除 source 校验
status: completed
- id: frontend-edit-form
content: edit.vue新增隐藏编号、编辑只读、移除校验、提交时不传 source
status: completed
- id: verify
content: Docker 内手工验证创建/编辑,跑 verify-slot-backend.sh
status: completed
isProject: false
---
# 渠道编号系统自动生成方案
## 背景与范围
渠道管理对应模块:
| 层级 | 路径 |
|------|------|
| 前端列表/表单 | [`slot-admin-vue/src/views/game/source/index.vue`](slot-admin-vue/src/views/game/source/index.vue)、[`edit.vue`](slot-admin-vue/src/views/game/source/edit.vue) |
| API | `POST /game/game-server/save``PUT /game/game-server/update` |
| 后端 | [`GameServerController`](slot-admin/app/game/controller/GameServerController.php) → [`GameServerLogic`](slot-admin/app/game/logic/GameServerLogic.php) → [`GameServerModel`](slot-admin/app/model/GameServerModel.php) |
| 校验 | [`GameServerValidate`](slot-admin/app/game/validate/GameServerValidate.php) |
当前行为:创建/编辑时前端手动填写 `source`(编号),后端 `save`/`update` 场景均校验 `source` 必填。
**额外发现(需一并修复)**[`GameServerLogic::save()`](slot-admin/app/game/logic/GameServerLogic.php) 含 `secret_key` 生成、`extend_data` 格式化、Redis 同步等逻辑,但 [`BaseController::save()`](slot-admin/plugin/saimulti/basic/BaseController.php) 实际调用的是 `logic->add()`,导致创建时这些逻辑**未执行**。本次应改为在 `add()` 中统一处理(参照 [`GGameLogic::add()`](slot-admin/app/game/logic/GGameLogic.php) 模式)。
```mermaid
sequenceDiagram
participant Vue as slot-admin-vue
participant Ctrl as GameServerController
participant Logic as GameServerLogic
participant DB as game_server
Vue->>Ctrl: POST save不含 source
Ctrl->>Logic: add(data)
Logic->>Logic: generateUniqueSource()
Logic->>Logic: prepareCreateData()
Logic->>DB: insert
Logic->>Logic: sync(redis)
Ctrl-->>Vue: success
Vue->>Ctrl: PUT updatesource 只读或不传)
Ctrl->>Logic: edit(id, data)
Logic->>Logic: unset source
Logic->>DB: update
Logic->>Logic: sync(redis)
```
---
## 编号规则(已确认)
- **格式**6 位 **大写字母 + 数字** 混合(如 `A3K9X2`
- **字符集**`A-Z` + `0-9`(统一大写,与现有 `sync()``strtoupper($source)` 行为一致)
- **唯一性**:生成后查库 [`GameServerModel::getBySource()`](slot-admin/app/model/GameServerModel.php),冲突则重试(最多 20 次),失败抛 `ApiException`
- **存量数据**:已有渠道编号不变,仅新建渠道走自动生成
---
## 后端改动slot-admin
### 1. [`GameServerLogic.php`](slot-admin/app/game/logic/GameServerLogic.php)
**新增 `add()`**(替代当前未接入的 `save()`
- 调用 `generateUniqueSource()` 写入 `$data['source']`,忽略前端传入值
- 复用现有 `save()` 内创建前处理:`secret_key``format(extend_data)``dot_type`/`fb_status`/`fb_pix`
- `parent::add($data)``sync($data['source'])`
- 删除或内联原 `save()` 方法,避免双入口
**新增私有方法**(均需中文 PHPDoc
- `generateUniqueSource(): string` — 生成唯一 6 位编号
- `prepareCreateData(array $data): array` — 从原 `save()` 提取的创建前数据整理(可选,保持方法 ≤50 行)
**修改 `edit()`**
- 在更新前 `unset($data['source'])`,防止通过 API 篡改编号
- 其余逻辑保持不变
### 2. [`GameServerValidate.php`](slot-admin/app/game/validate/GameServerValidate.php)
- `save` 场景:**移除** `source`(后端生成,不要求前端传)
- `update` 场景:**移除** `source`(不可编辑)
- 可保留 `source` 规则定义供其他场景复用,但不再出现在 save/update scene
### 3. [`GameServerModel.php`](slot-admin/app/model/GameServerModel.php)(可选小改)
- 新增 `existsBySource(string $source): bool`,供 Logic 查重,语义更清晰(非必须,可直接用 `getBySource`
---
## 前端改动slot-admin-vue
### [`edit.vue`](slot-admin-vue/src/views/game/source/edit.vue)
**模板**(约 L19-23
- **新增模式**:不展示编号输入框;可加一行提示文案「渠道编号将在创建后自动生成」
- **编辑模式**:展示 `source``disabled` 只读输入框
**校验规则**(约 L306-311
- 移除 `source``required` 规则
**提交**(约 L344-357
- 新增/编辑提交前均 `delete data.source`,避免误传
列表页 [`index.vue`](slot-admin-vue/src/views/game/source/index.vue) 已有「编号」列,创建成功后刷新即可看到生成结果,无需改动。
---
## 安全与兼容
- 后端 `edit()` 强制 `unset source` 为最终防线,不依赖前端
- 新建渠道若前端仍传 `source`,后端忽略并覆盖为系统生成值
- 不影响 [`refreshFb`](slot-admin/app/game/controller/GameServerController.php) 等按 `source` 查询的现有功能
---
## 验证步骤
1. Docker 内创建渠道:不传编号,保存成功,列表出现 6 位字母数字编号
2. 编辑渠道:编号字段只读,修改名称等其他字段保存成功,编号不变
3. 尝试通过 API 直接 PUT 修改 `source`,确认后端不更新该字段
4. 执行 `SLOT_ROOT=/Users/ray/Documents/project/www/ray ~/.cursor/hooks/verify-slot-backend.sh`

View File

@@ -0,0 +1,167 @@
---
name: 移除 pending popup 表
overview: 移除 Lucky Rewards 转盘邀请人 Claim Now 弹窗对 `user_pending_popup` 的依赖,改为在 HomeEvent 中根据 `lucky_reward_player.pending_spin_count > 0` 动态构建弹窗;未领取前每次回大厅重复展示,直至 `claim-inviter-spin` 清零。
todos:
- id: build-inviter-popup
content: LuckyRewardInviteLogic 新增 buildInviterClaimNowPopup(),删除 notifyInviterInviteSuccess/enqueue
status: completed
- id: home-event-simplify
content: HomeEvent 改用 buildInviterClaimNowPopup移除 PendingPopupService
status: completed
- id: remove-pending-popup-infra
content: 删除 PendingPopupService/Model/DTO/Entity 及 deploy DDL 引用
status: completed
- id: update-tests-docs
content: 更新集成测、需求文档与 lucky_reward_deploy 冒烟说明
status: completed
isProject: false
---
# 移除 user_pending_popup改用 pending_spin_count 驱动转盘弹窗
## 背景与结论
当前邀请人「Claim Now」弹窗存在**双源**
```mermaid
sequenceDiagram
participant InviteBind as invite_bind
participant Player as lucky_reward_player
participant Grant as lucky_reward_spin_grant
participant Popup as user_pending_popup
participant Home as HomeEvent
InviteBind->>Grant: 写 invite_inviter grant
InviteBind->>Player: pending_spin_count += N
InviteBind->>Popup: enqueue lr_invite:{cycle}:{invitee}
Home->>Popup: fetchPending + consume
Home->>Player: resolvePendingSpinCount 注入 payload
```
- [`LuckyRewardInviteLogic::notifyInviterInviteSuccess()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 入队 `user_pending_popup`
- [`HomeEvent::appendLuckyRewardHomePopups()`](slot_console/app/command/event/HomeEvent.php) 再读 `pending_spin_count` 注入 payload
**全仓库检索结果**`PendingPopupService` / `user_pending_popup` **仅被 Lucky Rewards 使用**,无其它业务消费者。可以按你的要求移除该表及相关代码,改以 player 表为唯一触发源。
你已确认弹窗行为:**`pending_spin_count > 0` 时每次回大厅都弹,直到 `claim-inviter-spin` 领完**比旧「consume 后不再弹」更符合待领语义)。
---
## 目标行为
| 场景 | 是否弹 Claim Now | 数据来源 |
| --- | --- | --- |
| `pending_spin_count > 0` | 是,每次 HomeEvent flush | [`resolvePendingSpinCount()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) |
| 领取后 `pending_spin_count = 0` | 否 | claim 流程已有扣减 |
| `inviter_spin_reward = 0` | 否(无待领 Spin | 与 grant 逻辑一致 |
| 多次邀请、多笔待领 | **一条**弹窗,`pending_spin_count` 为累计值;被邀请人信息取**最早未领** grant | [`findUnclaimedInviterGrant()`](slot_console/app/model/common/LuckyRewardSpinGrantModel.php) + 解析 `biz_id` `invite_inviter:{cycle_id}:{invitee_uid}` |
弹窗 payload 保持与现网客户端兼容(`type: lucky_reward_invite_success`
```json
{
"type": "lucky_reward_invite_success",
"data": {
"invitee_uid": 200001,
"invitee_nickname": "Alice",
"invitee_avatar": "3",
"spin_reward": 1,
"cycle_id": 34,
"pending_spin_count": 2,
"title": "LUCKY YOU!",
"content": "You've received 1 Free Spin",
"button_text": "Claim Now",
"priority": 50
}
}
```
- `spin_reward`:当前最早未领 grant 的 `spin_count`(通常 1
- `pending_spin_count`player 累计待领总数(已有 [`resolvePendingSpinCount()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)
Support Invite被邀请人弹窗**不变**,仍走 [`buildSupportInvitePopup()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) + `ensureInviteeAutoOpenBox()`
---
## 代码改动
### 1. 新增邀请人弹窗构建方法
在 [`LuckyRewardInviteLogic`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 增加 public 方法,例如 `buildInviterClaimNowPopup(int $uid, string $source): array|false`
- 活动关闭 / 无有效轮次 → `false`
- `resolvePendingSpinCount($uid, $source) <= 0``false`
-`findUnclaimedInviterGrant($uid, $cycleId)`;若 grant 为空但 count>0脏数据→ 打 error 日志并 `false`
- 从 grant `biz_id` 解析 `invitee_uid``fetchUserProfile()` 取昵称/头像
- 返回与现网一致的 `type` + `data` 结构
### 2. 简化 HomeEvent
[`HomeEvent::appendLuckyRewardHomePopups()`](slot_console/app/command/event/HomeEvent.php)
- 删除 `PendingPopupService` 引用及 `fetchPending` / `consume` 循环
- 在 Support Invite 之后调用 `buildInviterClaimNowPopup()`,有则 `$luckyRewardPopups[]` 追加
- `pending_spin_count` 已在 popup data 内组装,无需二次注入
### 3. 删除 invite_bind 入队逻辑
[`LuckyRewardInviteLogic`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)
- 删除 `notifyInviterInviteSuccess()` 及对 `PendingPopupService::enqueue` 的调用(`handleInviteBind` 第 108 行)
- 移除 `PendingPopupService` / `PendingPopupEnqueueDTO` 依赖
- `InviteBindProcessContextDTO::$popupBizId` 若仅用于 popup可一并删除保留 `inviterBizId` 供 grant 幂等)
### 4. 移除 pending popup 基础设施(无其它引用)
删除 slot_console 内:
- [`app/service/PendingPopupService.php`](slot_console/app/service/PendingPopupService.php)
- [`app/model/common/UserPendingPopupModel.php`](slot_console/app/model/common/UserPendingPopupModel.php)
- [`app/dto/pendingPopup/PendingPopupEnqueueDTO.php`](slot_console/app/dto/pendingPopup/PendingPopupEnqueueDTO.php)
- [`app/entity/pendingPopup/*`](slot_console/app/entity/pendingPopup/)
DDL / 部署文档:
- [`backend/slot_admin/db/user_pending_popup.sql`](backend/slot_admin/db/user_pending_popup.sql) 标记废弃或删除
- 可选新增 `backend/slot_admin/db/user_pending_popup_drop.sql``DROP TABLE IF EXISTS user_pending_popup`)供已部署环境执行
- 更新 [`slot_console/doc/lucky_reward_deploy.md`](slot_console/doc/lucky_reward_deploy.md) 部署步骤(去掉第 2 步建表)
### 5. 测试
更新 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php)
- 删除对 `UserPendingPopupModel` 的断言(约 6178、9699 行)
- 新增用例:`buildInviterClaimNowPopup` 在 bind 后返回非 false且含 `pending_spin_count``claimInviterSpin` 后返回 false
更新 [`LuckyRewardDbTestCase`](slot_console/tests/Integration/LuckyRewardDbTestCase.php)
- 去掉 `user_pending_popup` 表存在性检查与 cleanup
可选:新增单元测覆盖 `buildInviterClaimNowPopup` 的 grant biz_id 解析mock 较少时可放集成测)。
### 6. 文档
- [`docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §5Claim Now 改为 `pending_spin_count > 0` + `buildInviterClaimNowPopup`
- [`docs/requirements/lucky_rewards/07_用户定向弹窗通知机制方案.md`](docs/requirements/lucky_rewards/07_用户定向弹窗通知机制方案.md):标注 Lucky Rewards 已不再使用通用表;若整表无其它用途则机制废弃
- [`lucky_reward_deploy.md`](slot_console/doc/lucky_reward_deploy.md) 冒烟:邀请人回大厅见 Claim Now可重复至领取
---
## 行为差异说明(验收时注意)
| 项 | 旧user_pending_popup | 新pending_spin_count |
| --- | --- | --- |
| 未点 Claim 再次回大厅 | 不再弹(已 consume | **继续弹**(你已确认) |
| 多笔邀请 | 可能多条 popup 队列 | **一条**count 累计 |
| inviter_spin_reward=0 | 仍入队通知 | **不弹**count 为 0 |
---
## 验收清单
1. invite_bind success → `pending_spin_count` 增加,**不写** `user_pending_popup`
2. 邀请人 HomeEvent → popList 含 `lucky_reward_invite_success``pending_spin_count` 正确
3. 未 claim 前多次回大厅 → 弹窗仍出现
4. `POST claim-inviter-spin` 后 → `pending_spin_count=0`,弹窗不再出现
5. 被邀请人 Support Invite + auto openBox 不受影响
6. `RUN_DB_TESTS=1` 集成测通过verify + php -l PASS

View File

@@ -0,0 +1,140 @@
---
name: 移除被邀请人Spin配置
overview: 去掉错误的「被邀请人 invite_bind 发 Spin / invitee_spin_reward」能力保留 PRD 4.3 Support Invite在轮次有效期内按 helper 关系为被邀请人自动开宝箱一次(复用现有 openBox 自动 Spin过期轮次不建 helper、不开箱、不弹窗。
todos:
- id: remove-invitee-spin-grant
content: 删除 grantInviteeSpin/claim-invitee-spin/ProcessContext invitee 字段及 Controller 路由
status: completed
- id: support-invite-auto-openbox
content: 实现 ensureInviteeAutoOpenBox + 重构 buildSupportInvitePopupHomeEvent 接入
status: completed
- id: admin-remove-invitee-config
content: Admin/API/Model 去掉 invitee_spin_reward 配置项
status: completed
- id: docs-tests-invitee-removal
content: 更新 05/API 文档;调整集成测并跑 verify
status: completed
isProject: false
---
# 移除被邀请人 Spin 配置,改为 helper + 自动开宝箱
## 背景澄清
- **错误理解**`invitee_spin_reward` + `invite_bind` 给被邀请人预发 Spingrant_type=3 / `claim-invitee-spin`)。
- **产品本意**:被邀请人 Spin 来自**开宝箱**既有流程([`LuckyRewardLogic::openBox`](slot_console/app/api/logic/LuckyRewardLogic.php) 内 `createOpenBoxAutoFirstSpinRecord` + `grantDailySpinIfNeeded`**不**单独配置「被邀请人 Spin 次数」。
- **Support InvitePRD 4.3)保留**:有效期内按 **helper 关系**处理;**自动开宝箱一次**;轮次过期则**无 helper、不自动开箱、不弹 Support Invite**(与已有 `skipped_expired_cycle` 一致)。
```mermaid
sequenceDiagram
participant User as slot_user_register
participant Logic as LuckyRewardInviteLogic
participant Home as HomeEvent
participant Box as LuckyRewardLogic_openBox
User->>Logic: invite_bind wheel有效短码
alt cycle有效
Logic->>Logic: helper+邀请人pending_spin
Note over Logic: 不再 grantInviteeSpin
else cycle过期
Logic-->>User: skipped_expired_cycle
end
User->>Home: 被邀请人回大厅
alt helper存在且cycle有效且未开箱
Home->>Logic: ensureInviteeAutoOpenBox
Logic->>Box: openBox一次
Home->>User: SupportInvite弹窗my_amount
else 过期或无helper
Home-->>User: 无SupportInvite
end
```
---
## 1. 删除「被邀请人发 Spin」链路
### slot_console Logic / API
| 删除/停用 | 文件 |
|-----------|------|
| `grantInviteeSpinForHelper()` 及事务内调用 | [`LuckyRewardInviteLogic.php`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) |
| `claimInviteeSpin()` / `claimPendingSpinByGrantType(... INVITEE)` | 同上 |
| `POST claim-invitee-spin` | [`LuckyRewardController.php`](slot_console/app/api/logic/LuckyRewardController.php) + 路由 |
| `InviteBindProcessContextDTO``inviteeSpinReward``inviteeBizId` | [`InviteBindProcessContextDTO.php`](slot_console/app/dto/luckyReward/InviteBindProcessContextDTO.php) |
保留 `LuckyRewardSpinGrantModel::GRANT_TYPE_INVITEE` / 历史 grant 只读兼容(不在新流程写入)。
### 配置与后台
| 项 | 处理 |
|----|------|
| [`lucky_reward_config.invitee_spin_reward`](backend/slot_admin/db/lucky_reward.sql) | **停止读写**;可选后续 DDL 删列(本次可留列默认 0避免强迁移 |
| Admin 列表/编辑「被邀请人Spin」 | [`backend/slot_admin_vue/.../luckyReward/config/*.vue`](backend/slot_admin_vue/src/views/game/luckyReward/config/) 移除字段 |
| slot_console 保存配置 API | 若 DTO/Validator 含该字段,一并移除 |
| [`LuckyRewardConfigModel`](slot_console/app/model/common/LuckyRewardConfigModel.php) `@property` | 标注废弃或删除 |
### 文档
- 更新 [`05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md):删除被邀请人 grant/claim 描述;改为「被邀请人 Spin = 开宝箱流程」。
- 更新 [`lucky_reward_api.md`](slot_console/doc/lucky_reward_api.md):移除 `claim-invitee-spin`
- 在 PRD 侧注记:`invitee_spin_reward` 配置项作废(可选改 [`转盘活动-功能需求文档.md`](docs/requirements/转盘活动-功能需求文档.md) 配置表一行)。
---
## 2. Support Invite + 自动开宝箱(有效期内)
### 新增编排(`LuckyRewardInviteLogic`
**`ensureInviteeAutoOpenBox(int $uid, string $source): void`**
1. 解析 active cycle`resolveActiveCycle`);无进行中轮次 → return。
2. `LuckyRewardHelperModel::findByInviteeAndCycle($uid, $cycleId)` 无记录 → return无 helper 不开箱)。
3. `LuckyRewardPlayerModel``box_opened_at > 0` → return幂等
4. 调用 [`LuckyRewardLogic::openBox($uid, $source)`](slot_console/app/api/logic/LuckyRewardLogic.php)(吞「已开箱」类 BusinessException打 info 日志)。
**重构 `buildSupportInvitePopup()`**
- **删除**对 `findUnclaimedInviteeGrant` / `pending_spin_count` 的依赖。
- **条件**helper 存在 + active cycle 有效(`end_at > now`+ 已开箱(可先调 `ensureInviteeAutoOpenBox` 再读 player
- **payload**:邀请人头像/昵称、`my_amount_qf``target_amount_qf`(去掉 spin 待领字段)。
### HomeEvent 接入
[`HomeEvent::appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php) 顺序调整为:
```php
$inviteLogic->ensureInviteeAutoOpenBox($uid, $source);
$supportInvitePop = $inviteLogic->buildSupportInvitePopup($uid, $source);
// 再 merge pendingPopup...
```
过期 wheel 短码:`invite_bind``skipped_expired_cycle`**无 helper** → 上述两步自然 no-op。
---
## 3. 邀请人侧不变
- 邀请人仍:`pending_spin_count` + Claim Now 弹窗 + `claim-inviter-spin`
- wheel 分享 cycle 绑定方案**不改动**。
---
## 4. 测试
| 用例 | 预期 |
|------|------|
| invite_bind 成功 | 仅 inviter grant/helper**无** invitee grantinvitee `spin_available` 仍为 0 |
| invitee HomeEventhelper + 有效 cycle | 自动 `openBox` 一次;`spin_available` 来自 daily/openBox 逻辑Support Invite 有 `my_amount_qf` |
| 过期短码 invite_bind | `skipped_expired_cycle`;无 helperHomeEvent 无开箱、无 Support Invite |
| 删除 claim-invitee-spin 相关集成测 | 改为 auto openBox + Support Invite 断言 |
更新 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php)、[`LuckyRewardDbTestCase`](slot_console/tests/Integration/LuckyRewardDbTestCase.php)(去掉 `invitee_spin_reward` seed
---
## 5. 不在本次范围
- 修改开宝箱 / 每日 Spin 核心算法(仅**复用** `openBox`)。
- 删除 DB 列 `invitee_spin_reward`(可后续单独 migration
- 清理历史 `grant_type=3` 数据。

View File

@@ -0,0 +1,119 @@
---
name: 精简 Spin 响应
overview: 调整 `POST /api/lucky-reward/spin``SpinResultEntity`:去掉 spinId/prizeType/prizeAmountQf/myAmountDisplay新增 poolItemIdmyAmountQf 改为大单位 float3 位小数向下取整)。
todos:
- id: amount-format-3dp
content: LuckyRewardAmountService 新增 formatAmountDisplay3 + 单测
status: completed
- id: spin-result-entity
content: 精简 SpinResultEntity 字段poolItemId + myAmountQf float
status: completed
- id: logic-return
content: executeManualSpin 返回新结构
status: completed
- id: docs-yapi
content: 更新 lucky_reward_api.md §3 与 YApi spin 接口
status: completed
isProject: false
---
# 手动 Spin 响应精简方案
## 现状
- 路径:`POST /api/lucky-reward/spin`**无请求 Body**JWT 鉴权)
- 返回 [`SpinResultEntity`](slot_console/app/entity/luckyReward/SpinResultEntity.php) 由 [`LuckyRewardLogic::executeManualSpin()`](slot_console/app/api/logic/LuckyRewardLogic.php) 组装
- 抽奖结果 [`DrawResultEntity`](slot_console/app/entity/luckyReward/DrawResultEntity.php) 已含 `poolItemId`(与活动详情 `poolItems[].id` 同源,即 `reward_pool_item.id`
## 目标响应 `data`
| 字段 | 类型 | 说明 |
|------|------|------|
| poolItemId | int | 命中的奖池项 ID客户端对照 `poolItems` 定位格子/本地图) |
| myAmountQf | float | 抽奖后 My Amount**大单位、3 位小数、向下取整**(字段名沿用,语义变更) |
| spinAvailable | int | 剩余 Spin 次数 |
| playerStatus | int | 玩家状态 |
| isDuplicate | bool | 幂等重复(当前手动 Spin 仍固定 `false` |
**删除:** `spinId``prizeType``prizeAmountQf``myAmountDisplay`
示例:
```json
{
"poolItemId": 3,
"myAmountQf": 9.570,
"spinAvailable": 0,
"playerStatus": 1,
"isDuplicate": false
}
```
## 实现步骤
### 1. 金额格式化3 位小数)
在 [`LuckyRewardAmountService`](slot_console/app/service/luckyReward/LuckyRewardAmountService.php) 新增专用方法,例如 `formatAmountDisplay3(int $amountQf): float`
- 规则:`floor($amountQf) / 1000`,再 `number_format(..., 3, '.', '')` 转 float
- 与现有 2 位 `formatAmountDisplay()` 并存,避免影响 status/open-box/records 等接口
在 [`LuckyRewardAmountServiceTest`](slot_console/tests/Unit/LuckyRewardAmountServiceTest.php) 补充用例(如 `9570 → 9.570``9576 → 9.576`)。
### 2. 更新 Entity
修改 [`SpinResultEntity`](slot_console/app/entity/luckyReward/SpinResultEntity.php)
- 删除:`spinId``prizeType``prizeAmountQf``myAmountDisplay`
- 新增:`poolItemId`int
- 修改:`myAmountQf` 类型 `int → float`PHPDoc 注明「大单位 3 位小数展示值」
### 3. 更新 Logic 组装
[`LuckyRewardLogic::executeManualSpin()`](slot_console/app/api/logic/LuckyRewardLogic.php) 返回处改为:
```php
return new SpinResultEntity([
'poolItemId' => $drawResult->poolItemId,
'myAmountQf' => LuckyRewardAmountService::formatAmountDisplay3((int) $activityPlayer->my_amount_qf),
'spinAvailable' => (int) $activityPlayer->spin_available,
'playerStatus' => (int) $activityPlayer->status,
'isDuplicate' => false,
]);
```
- 服务端仍生成 `manualSpinId` 写库/日志,**仅不再返回给客户端**
- `prize_type``prize_amount_qf` 仍写入 `lucky_reward_spin_record`Record Tab 不受影响
### 4. 文档与 YApi
- 更新 [`slot_console/doc/lucky_reward_api.md`](slot_console/doc/lucky_reward_api.md) §3 字段表与示例
- 同步 YApi 手动 Spin 接口project 14cat_115 内 spin 对应 ID#553561 同组)
### 5. 自检
-`verify-slot-backend.sh` + docker `php -l`
-`LuckyRewardAmountServiceTest`、现有 `LuckyRewardDrawServiceTest`(确认 `poolItemId` 链路未断)
## 数据流(变更后)
```mermaid
sequenceDiagram
participant Client
participant SpinAPI as POST_spin
participant Logic as LuckyRewardLogic
participant Draw as DrawService
participant DB as spin_record
Client->>SpinAPI: JWT, no body
SpinAPI->>Logic: executeManualSpin
Logic->>Draw: draw(poolItems)
Draw-->>Logic: poolItemId, prizeType
Logic->>DB: spin_id, prize_type, prize_amount_qf
Logic-->>Client: poolItemId, myAmountQf(3dp), spinAvailable, playerStatus
```
## 影响范围
- **Breaking change**:依赖旧 Spin 响应字段的前端需改为用 `poolItemId` 对照 `poolItems` 展示命中格My Amount 读 `myAmountQf`float 3 位)
- **无改动**请求体仍为空Controller/Validator 无需改;后台奖池项 CRUD 不变

View File

@@ -0,0 +1,178 @@
---
name: 转盘与Agent分享归因
overview: 实现「转盘分享双计agent + 转盘、agent 分享仅计 agent」wheel 与 user_agent 各一条 share_url 短码MQ 的 share_origin 直接来自 invite_code 查表console 仅 share_origin=wheel 时发转盘助力。
todos:
- id: console-origin
content: "slot_console: wheel 独立落库 origin=wheelLogic 仅 wheel 计助力invite-link/createShareUrl 走 wheel 短码"
status: completed
- id: user-mq
content: "slot_user: shouldNotifyAgentByInviteOrigin 含 wheelMQ share_origin 来自 share_url.origin"
status: completed
- id: agent-filter
content: "slot_agent建议: console 回调粗筛 share_origin=wheel"
status: completed
- id: tests-docs
content: 单测/集成测 + 需求文档/YApi/deploy 对齐(撤销 wheel→user_agent 映射说明)
status: completed
isProject: false
---
# 转盘分享 vs Agent 分享单向归因方案share_origin 双短码)
## 产品规则SSOT
| 分享入口 | share_url.origin | Agent 邀请 | Lucky Rewards 助力 |
| --- | --- | --- | --- |
| 转盘分享(`from=wheel` / invite-link | `wheel` | 算 | 算 |
| Agent 分享(`from=user_agent` | `user_agent` | 算 | **不算** |
## 方案选型(已确认)
**方案 A两条短码**`wheel``user_agent``share_url` 各存一行,同一邀请人可有 **两个** `short_code`
- MQ `share_origin` 由注册时 `invite_code``share_url.origin` 得到,**无需** `landing_scene`、**无需** FE 注册透传额外字段。
- 撤销当前 [`ShareService::resolvePersistOrigin()`](slot_console/app/service/ShareService.php) 把 `wheel → user_agent` 的映射。
```mermaid
sequenceDiagram
participant Inviter
participant Console as slot_console
participant User as slot_user
participant Agent as slot_agent
alt WheelShare
Inviter->>Console: createShareUrl from=wheel
Console->>Console: share_url origin=wheel 短码W
Note over Inviter: 分享短码W
User->>User: register invite_code=W
User->>Agent: MQ share_origin=wheel
Agent->>Console: callback share_origin=wheel
Console->>Console: success发Spin
else AgentShare
Inviter->>Console: createShareUrl from=user_agent
Console->>Console: share_url origin=user_agent 短码A
User->>User: register invite_code=A
User->>Agent: MQ share_origin=user_agent
Agent->>Console: callback share_origin=user_agent
Console->>Console: skipped_not_wheel
Note over Agent: agent关系照常
end
```
---
## 为何之前不能「只改 MQ 不改落库」
当前实现把 `from=wheel` **映射成** `user_agent` 落库(复用一条短码)。此时代码查表得到的 `origin` 永远是 `user_agent`MQ 无法出现 `share_origin=wheel`,除非 FE 在注册时强行覆盖 —— 那与 `landing_scene` 同类,且语义与 DB 不一致。
**双短码**后:`invite_code` 本身即归因,链路最简。
---
## 实现步骤
### 1. slot_consolewheel 独立短码 + 助力判定
**[`ShareService`](slot_console/app/service/ShareService.php)**
- **删除或停用** `resolvePersistOrigin()` 对 wheel 的 `user_agent` 映射;`SHARE_FROM_WHEEL` 直接作为 `createUrl()``$from` / `ShareUrlModel.origin`
- `isAgentShareFrom()` 保持 `user_agent` + `wheel`agent 文案接口仍适用)。
- 更新/替换 [`ShareServiceWheelFromTest`](slot_console/tests/Unit/ShareServiceWheelFromTest.php):断言 wheel 创建后 `origin=wheel`,且与 `user_agent` 为**不同** `short_code`(同一 uid 两行)。
**[`GiftController::createShareUrl()`](slot_console/app/api/controller/GiftController.php)**
- `from=wheel``createUrl(ShareService::SHARE_FROM_WHEEL)`,不再经 `resolvePersistOrigin`
**[`LuckyRewardInviteLogic::buildInviteLink()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)**
- 同上,直接 `createUrl(SHARE_FROM_WHEEL)`
- PHPDoc 更新:不再写「落库复用 user_agent 短码」。
**[`LuckyRewardInviteLogic::handleInviteBind()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php)**
- 将现有「必须 `share_origin=user_agent`」改为 **必须 `share_origin=wheel`**
```php
if ($callbackDto->shareOrigin !== ShareService::SHARE_FROM_WHEEL) {
return $this->buildInviteBindResult(
InviteBindResultEntity::STATUS_SKIPPED_NOT_WHEEL,
'Invite code origin is not wheel'
);
}
```
**[`InviteBindResultEntity`](slot_console/app/entity/luckyReward/InviteBindResultEntity.php)**
- 新增 `STATUS_SKIPPED_NOT_WHEEL = 'skipped_not_wheel'`(保留旧 `skipped_not_user_agent` 常量亦可,新逻辑用新 status
- **不需要** `landing_scene` / `skipped_no_landing`(除非后续另有用途)。
**[`InviteBindCallbackDTO`](slot_console/app/dto/luckyReward/InviteBindCallbackDTO.php)**
- 已有 `shareOrigin`,无需新增 `landingScene`agent/SDK 侧 landing_scene 字段可留空,不读)。
### 2. slot_user发 MQ + agent 资格
**[`AbstractRegisterService::shouldNotifyAgentByInviteOrigin()`](slot_user/app/service/register/AbstractRegisterService.php)**
```php
return in_array($origin, ['user_agent', 'wheel'], true);
```
**[`notifyAgentInviteBind()`](slot_user/app/service/register/AbstractRegisterService.php)**
- `share_origin` 继续写 `$this->invite_share_origin`(来自 `share_url.origin`wheel 注册自然为 `wheel`
- **无需**新增 `landing_scene` 属性或 MQ 字段(与 agent 侧已有 `landing_scene` 字段兼容即可,留空)。
**[`resolveInviteUidByInviteCode()`](slot_user/app/service/register/AbstractRegisterService.php)** — 已读 `shareRow->origin`,双短码下无需改逻辑。
### 3. slot_agentconsole 回调粗筛(建议)
[`LuckyRewardInviteCallbackGatewayService::canNotifyInviteBind()`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php) 增加:
-`share_origin === 'wheel'` 时 HTTP 调 consoleagent 绑定、奖励、关系同步**不受影响**,仍处理全部 invite_bind MQ
- `user_agent` 绑定不再打 console减少无效请求。
### 4. 前端 / 产品说明
- 转盘页 Copy Link / 社媒分享:使用 **invite-link****create-share-url `from=wheel`** 返回的 `code`wheel 短码)。
- Agent 中心分享:继续使用 **`from=user_agent`** 返回的 `code`(另一条短码)。
- **不要混用**:用 agent 短码进转盘页,只计 agent、不计转盘 —— 符合产品规则。
### 5. 测试
| 仓库 | 用例 |
| --- | --- |
| slot_console | `ShareServiceWheelFromTest`:同 uid 存在 wheel + user_agent 两行、短码不同 |
| slot_console | `LuckyRewardInviteLogicUnitTest``share_origin=user_agent``skipped_not_wheel``wheel` + 新注册 → success |
| slot_console | `LuckyRewardInviteBindIntegrationTest``buildValidInviteBindPayload()``share_origin``wheel` |
| slot_user | `shouldNotifyAgentByInviteOrigin`wheel / user_agent 为 true其它为 false |
### 6. 文档与 YApi
- 更新 [`05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §2有效助力 = **新注册** + **`share_origin=wheel`**(双短码,非 landing_scene
- 更新 [`00_整体技术方案.md`](docs/requirements/lucky_rewards/00_整体技术方案.md) 归因段落。
- [`lucky_reward_deploy.md`](slot_console/doc/lucky_reward_deploy.md)、YApi **81** / invite-link说明 wheel 与 user_agent **不同 code**;撤销「复用 user_agent 短码」描述。
- innerapi 回调文档:`skipped_not_wheel``landing_scene` 标注为可选/未使用。
---
## 判定优先级console 更新后)
1. invalid_params
2. skipped_self_invite
3. skipped_old_user
4. **skipped_not_wheel**(原 skipped_not_user_agent 逻辑替换)
5. activity_closed
6. already_processed
7. success
---
## 风险与边界
- **历史已发出的 wheel 链接**:若当时用的是映射后的 `user_agent` 短码,升级后只计 agent、不计转盘新 wheel 链接需走 invite-link / `from=wheel` 拿新短码。可在发版说明中写清。
- **同一用户两条码**:运营/客服需知 wheel 与 agent 链接不可互换。
- **手工绑码**`share_url.origin` 决定 MQ `share_origin`;绑 agent 码不计转盘。
- **slot_sdk / agent** `landing_scene` 字段保留兼容,本期不参与判定。
---
## 涉及仓库
- **必改**`slot_console``slot_user`
- **建议改**`slot_agent`HTTP 粗筛 `share_origin=wheel`
- **不改**`slot_sdk`(已有 share_origin 转发)
- **FE**:使用正确短码即可,无 register 额外字段

View File

@@ -0,0 +1,172 @@
---
name: 转盘分享周期绑定
overview: 推荐在 `share_url` 增加通用 `biz_type` + `biz_id`(转盘绑定 `lucky_reward_cycle.id`),按当前轮次复用/新建短码;过期校验以 `lucky_reward_cycle.end_at` 为真源invite_bind 侧对过期轮次返回 skipped 而不影响注册。
todos:
- id: ddl-share-url-biz
content: share_url 增加 biz_type/biz_id + 唯一键;同步 slot_console/slot_user Model PHPDoc
status: completed
- id: share-service-wheel-reuse
content: ShareService wheel 分支:按 active cycle_id 复用或新建短码
status: completed
- id: invite-bind-expired-check
content: LuckyRewardInviteLogic invite_bind 校验 share 绑定 cycle 未过期且匹配 active cycle返回 skipped_expired_cycle
status: completed
- id: docs-tests
content: 更新 05 文档;单测/集成测覆盖同轮复用、跨轮新建、过期跳过
status: completed
isProject: false
---
# 转盘分享链接与活动轮次绑定方案
## 结论(直接回答你的二选一)
**推荐:`biz_type` + `biz_id`(转盘 `biz_id = cycle_id`),不推荐单独加 `expire_at` 作为唯一方案。**
| 方案 | 优点 | 缺点 |
|------|------|------|
| **仅 `expire_at`** | 查询简单(`expire_at > now` | 与 `lucky_reward_cycle.end_at` **双份真源**,易漂移;`user_agent`/`red_packet` 等 origin 语义不同,全局 `expire_at` 难统一 |
| **`biz_type` + `biz_id`(推荐)** | 与业务轮次一一对应;**有效期以 `lucky_reward_cycle.end_at` 为准**;同轮次内复用、新轮次新建;可扩展其它活动 | 需改 `ShareService` 查询与唯一键invite_bind 需多一步校验 |
可选:**可冗余 `expire_at = cycle.end_at` 仅作索引加速**,但判定仍应 join/查 `lucky_reward_cycle`,不以 `share_url.expire_at` 为 SSOT。
---
## 现状问题
[`ShareService::createUrl`](slot_console/app/service/ShareService.php) 通过 [`ShareUrlModel::isPlayerCreated($uid, $from)`](slot_console/app/model/ShareUrlModel.php) 按 `(creator_id, origin)` **永久复用**一条记录:
```php
$model = ShareUrlModel::isPlayerCreated($this->userEntity->uid, $from);
if ($model === false) { /* 新建 */ }
```
[`LuckyRewardInviteLogic::buildInviteLink`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 每次调 `createUrl(SHARE_FROM_WHEEL)`**新 cycle 开始后仍返回旧短码**。
注册侧 [`slot_user` AbstractRegisterService](slot_user/app/service/register/AbstractRegisterService.php) 只校验 `short_code` 存在,**不校验轮次**console `handleInviteBind` 直接用**当前 active cycle** 发奖,导致**旧链接可能计入新轮次**(与「每轮转盘有有效期」不符)。
你已确认过期策略:**注册/agent 关系照常Lucky Rewards 不计助力**。
---
## 推荐数据模型
在 [`s_common.share_url`](slot_user/app/model/ShareUrlModel.php) 增加:
```sql
ALTER TABLE share_url
ADD COLUMN biz_type varchar(32) NOT NULL DEFAULT '' COMMENT '业务类型wheel填lucky_reward_cycle',
ADD COLUMN biz_id bigint NOT NULL DEFAULT 0 COMMENT '业务IDwheel填lucky_reward_cycle.id';
-- wheel同一用户同一轮次唯一其它 origin 保持 biz 为空
ADD UNIQUE KEY uk_creator_origin_biz (creator_id, creator_type, origin, biz_type, biz_id);
```
语义约定:
| origin | biz_type | biz_id | 行为 |
|--------|----------|--------|------|
| `user_agent` / `red_packet` / `user_self` | `''` | `0` | 现网永久短码,不变 |
| `wheel` | `lucky_reward_cycle` | `cycle.id` | **每轮一条**,同轮复用 |
`code` 加密 JSON 可冗余写入 `cycle_id` 便于排查,但**复用查询走 DB 列**,不走解密。
---
## 核心流程
```mermaid
sequenceDiagram
participant Client as ActivityPage
participant Console as ShareService
participant Cycle as lucky_reward_cycle
participant Share as share_url
participant User as slot_user_register
participant Logic as LuckyRewardInviteLogic
Client->>Console: invite-link / createShareUrl from=wheel
Console->>Cycle: resolveActiveCycle
Console->>Share: find uid+wheel+biz(cycle_id)
alt 当前轮次已有短码
Share-->>Console: 复用 short_code
else 新轮次或无记录
Console->>Share: insert 新 short_code + biz_id
end
Console-->>Client: code + land_url
User->>Share: invite_code 查 short_code
User->>Logic: invite_bind share_origin=wheel
Logic->>Share: invite_code -> biz_id(cycle_id)
Logic->>Cycle: 校验 cycle 仍进行中且 end_at>now
alt cycle 已结束或不匹配 active
Logic-->>User: skipped_expired_cycle
else
Logic-->>User: 正常助力流程
end
```
---
## 实现要点(按服务)
### 1. slot_console — 分享创建
改动 [`ShareService::createUrl`](slot_console/app/service/ShareService.php)
- **`from !== wheel`**:保持 `isPlayerCreated(uid, from)`biz 为空)。
- **`from === wheel`**
1. `LuckyRewardConfigService::requireEnabledConfig` + `resolveActiveCycle`
2. `ShareUrlModel::findByPlayerBiz($uid, 'wheel', 'lucky_reward_cycle', $cycleId)`
3. 命中则复用;否则新建并写入 `biz_type/biz_id`
4. (可选)冗余 `expire_at = cycle.end_at` 仅索引
同步改 [`GiftController::createShareUrl`](slot_console/app/api/controller/GiftController.php) 与 [`LuckyRewardInviteLogic::buildInviteLink`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 路径(均走 ShareService一处改即可
[`ShareUrlModel`](slot_console/app/model/ShareUrlModel.php) + [`slot_user/ShareUrlModel`](slot_user/app/model/ShareUrlModel.php) 补 `@property` 与新查询方法。
### 2. slot_console — invite_bind 过期校验
在 [`LuckyRewardInviteLogic::handleInviteBind`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 有效助力判定链中(`skipped_not_wheel` 之后)增加:
1.`invite_code``share_url`console 可读 `share_url` 或经 slot_user innerapi优先 console 直读同库 `s_common.share_url`
2.`origin=wheel``biz_type=lucky_reward_cycle`
-`lucky_reward_cycle` by `biz_id`
-`status=已结束``end_at <= now()` → 返回新 status **`skipped_expired_cycle`**(注册/agent 不受影响,仅不发 Spin
-`biz_id != 当前 active cycle.id` → 同上(旧轮短码不计新轮助力)
在 [`InviteBindResultEntity`](slot_console/app/entity/luckyReward/InviteBindResultEntity.php) 增加常量 + 文档表更新([05 文档](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §3 判定表)。
### 3. slot_user — 无需阻断注册
按你的选择:**不在注册阶段 reject invite_code**。`AbstractRegisterService` 可不改,或仅补日志(`share_url.biz_id` + cycle 状态)便于排障。
### 4. 文档
更新 [05_slot_console_邀请助力与弹窗方案.md](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §2.1
- wheel 短码 **按 cycle 复用/新建**,非永久一条
- 过期短码:注册 OKLucky Rewards `skipped_expired_cycle`
---
## 测试
- **Unit**`ShareService` wheel 分支 — mock cycle`findByPlayerBiz` 命中复用 / 新 cycle 新建
- **Integration**:同一 uid 同 cycle 两次 `invite-link` 返回相同 `code`;模拟新 cycle 返回不同 `code`
- **Integration**:旧 cycle 短码 + invite_bind → `skipped_expired_cycle`,无 helper/grant
---
## 为何不选「只加 expire_at」
1. **真源重复**cycle 结束时间已在 [`lucky_reward_cycle.end_at`](backend/slot_admin/db/lucky_reward.sql);单独 `expire_at` 需与轮转任务同步,易不一致。
2. **无法表达「属于哪一轮」**:仅有 `expire_at` 难以区分「同用户第 N 轮 vs 第 N+1 轮」历史短码;`biz_id=cycle_id` 可精确归因与审计。
3. **影响面**`expire_at` 若加在全局 `share_url`,需约定 `user_agent` 等为 0/NULL`biz_id` 对非 wheel 恒为 0更清晰。
---
## 不在本次范围
- 修改 `user_agent` 分享逻辑
- 前端 Copy Link 缓存策略(后端保证同 cycle 幂等即可)
- 删除历史 cycle 的 `share_url` 行(可保留作审计;过期靠 invite_bind 拦截)

View File

@@ -0,0 +1,179 @@
---
name: 邀请回调改MQ
overview: 将 agent → console 的 Lucky Rewards 邀请助力从 HTTP innerapi 改为经 `console_bus` MQ 投递;删除 innerapi 与 slot_sdk 对应方法console EventBus 消费后复用现有 `LuckyRewardInviteLogic::handleInviteBind()`
todos:
- id: agent-mq-producer
content: "slot_agent: Gateway 改发 console_bus MQ + MQBusEntity 常量"
status: completed
- id: console-consumer
content: "slot_console: EventBus 消费 TYPE_LUCKY_REWARD_INVITE_BIND + 删 innerapi Controller"
status: completed
- id: sdk-cleanup
content: "slot_sdk: 删除 luckyRewardInviteBindCallback 及 Entity"
status: completed
- id: tests-docs-mq
content: 单测 + 需求/deploy 文档更新
status: completed
isProject: false
---
# Agent 邀请回调改走 console_bus MQ
## 目标
- **删除**`POST /innerapi/lucky-reward/invite-bind-callback`、slot_sdk `luckyRewardInviteBindCallback`
- **新增**agent 发 MQ → console `event:bus` 消费 → 发 Spin / helper
- **复用**[`LuckyRewardInviteLogic::handleInviteBind()`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 业务不变;仍仅 `share_origin=wheel` 计转盘助力
```mermaid
sequenceDiagram
participant User as slot_user
participant AgentBus as agent_bus
participant Agent as slot_agent
participant ConsoleBus as console_bus
participant Console as slot_console
User->>AgentBus: TYPE_INVITE_BIND
Agent->>AgentBus: 建关系/绑码奖励
Agent->>ConsoleBus: TYPE_LUCKY_REWARD_INVITE_BIND
Console->>Console: handleInviteBind
```
对照现网邮件:[`EventBus::notifyRewardEmail()`](slot_agent/app/command/EventBus.php) 已用 `EXCHANGE_CONSOLE` + `QUEUE_CONSOLE_BUS`
---
## 1. MQ 契约
**Exchange / Queue**(与现网一致):
- Exchange: `slot_console``MQKeyManagerService::EXCHANGE_CONSOLE`
- Queue: `console_bus``MQKeyManagerService::QUEUE_CONSOLE_BUS`
**新事件类型**agent、console 两侧常量字符串一致):
```php
const TYPE_LUCKY_REWARD_INVITE_BIND = 'lucky_reward_invite_bind';
```
**消息体**(与现 HTTP body 同形,便于 DTO 复用):
```json
{
"uid": 2001,
"type": "lucky_reward_invite_bind",
"data": {
"event_id": "invite_bind:2001",
"inviter_uid": 100,
"invitee_uid": 2001,
"invite_code": "ABC123",
"channel_code": "default",
"bind_time": 1710000000,
"share_origin": "wheel",
"is_new_register": 1,
"landing_scene": "",
"landing_token": ""
}
}
```
- `uid` = **被邀请人** `invitee_uid`(对齐其它 console_bus 消息)
- `event_id` 仍用 `invite_bind:{invitee_uid}` 幂等
---
## 2. slot_agent生产者
**重构** [`LuckyRewardInviteCallbackGatewayService`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php)
| 变更 | 说明 |
| --- | --- |
| 删除 | `ConsoleClient`、slot_sdk Entity、`consoleApiHost`、HTTP 重试 |
| 保留 | `isEnabled` 开关、`share_origin=wheel` 粗筛、参数校验 |
| 新增 | `RabbitMqService::getInstance(EXCHANGE_CONSOLE, QUEUE_CONSOLE_BUS)->sendMessageByBusEntity(...)` |
[`MQBusEntity`](slot_agent/app/entity/mq/MQBusEntity.php) 增加 `TYPE_LUCKY_REWARD_INVITE_BIND`
[`LuckyRewardInviteCallbackResultEntity`](slot_agent/app/entity/lucky_reward/LuckyRewardInviteCallbackResultEntity.php)
- `dispatched=true` 表示 **MQ 发送成功**
- `reason``mq_failed` 替代 `http_failed`;去掉 `console_api_host_empty`
**触发点不变**
- [`EventBus::inviteBindEvent()`](slot_agent/app/command/EventBus.php)(新建关系)
- [`AgentLogic::bindInviteCode()`](slot_agent/app/innerapi/logic/AgentLogic.php)(手工绑码补偿)
**配置**[`LuckyRewardInviteCallbackConfigService`](slot_agent/app/service/LuckyRewardInviteCallbackConfigService.php) 可移除 `timeout`/`retrySleep`HTTP 专用);保留 `lucky_reward_callback_enabled`
---
## 3. slot_console消费者
**[`MQBusEntity`](slot_console/app/entity/mq/MQBusEntity.php)**:增加 `TYPE_LUCKY_REWARD_INVITE_BIND`
**[`EventBus::deal()`](slot_console/app/command/EventBus.php)**:新增 `case`,调用 private `luckyRewardInviteBindEvent(int $uid, array $data)`
```php
$callbackDto = InviteBindCallbackDTO::fromRequest($busEntity->data);
$result = (new LuckyRewardInviteLogic())->handleInviteBind($callbackDto);
LoggerService::info('lucky_reward_invite_bind', $result->activeData());
```
**[`InviteBindCallbackDTO`](slot_console/app/dto/luckyReward/InviteBindCallbackDTO.php)**`fromRequest()` 已可读 MQ `data` 数组无需改签名PHPDoc 注明 HTTP/MQ 共用。
**ack 策略**(对齐业务语义,非 5xx 不重试):
- `handleInviteBind` 正常返回(含 `skipped_*``already_processed`)→ **ack**
- 未捕获 `Throwable`DB 异常等)→ **log + ack**(与多数 console_bus 分支一致;避免无限 requeue。若需失败重试可后续单独加 dead-letter本期不扩 scope。
**删除** [`app/innerapi/controller/LuckyRewardController.php`](slot_console/app/innerapi/controller/LuckyRewardController.php)(仅此一个 action
---
## 4. slot_sdk清理
删除或不再使用:
- [`ConsoleService::luckyRewardInviteBindCallback()`](slot_sdk/src/service/console/ConsoleService.php)
- [`LuckyRewardInviteBindCallbackRequestEntity`](slot_sdk/src/service/console/entity/LuckyRewardInviteBindCallbackRequestEntity.php)
- [`LuckyRewardInviteBindCallbackResultEntity`](slot_sdk/src/service/console/entity/LuckyRewardInviteBindCallbackResultEntity.php)
保留 `freeCreditsStatistics/list`admin 仍用)。
agent `composer.json` 若仅为该回调依赖 console SDK 路径无需改依赖agent 仍可能间接用 slot_sdk 其它能力)。
---
## 5. 测试
| 仓库 | 用例 |
| --- | --- |
| slot_console | 新增 `EventBusLuckyRewardInviteBindTest`mock AMQPMessage`type=lucky_reward_invite_bind`,断言调用 Logic / ack参考 [`EventBusFreeCreditInitTest`](slot_console/tests/Unit/EventBusFreeCreditInitTest.php) |
| slot_console | 现有 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php) 继续直调 Logic不受影响 |
| slot_agent | 单测 Gatewaywheel 发 MQ、user_agent skip、disabled 不发(可 mock RabbitMqService 或测 payload 组装 private 方法) |
---
## 6. 文档
- [01_slot_agent邀请回调对接方案.md](docs/requirements/lucky_rewards/01_slot_agent邀请回调对接方案.md)HTTP 改 MQ 时序图、删除 §6.1 innerapi
- [05](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §3接收方式改为 console_bus
- [00](docs/requirements/lucky_rewards/00_整体技术方案.md) §3.5 时序图Agent→Console 改为 MQ
- [lucky_reward_deploy.md](slot_console/doc/lucky_reward_deploy.md):去掉 `consoleApiHost`agent 调 console与 innerapi 冒烟项;补充 console `event:bus` 需运行
---
## 7. 部署注意
- **无需** agent 配置 `consoleApiHost`(仅此回调)
- **必须** slot_console `php webman event:bus` 消费 `console_bus`(与邮件、注册等共用进程)
- 发版顺序建议:**console 先上消费** → **agent 再切 MQ 生产**,避免短暂消息无人消费(旧 agent HTTP + 新 console 无 innerapi 窗口)
---
## 涉及文件摘要
**改**`slot_agent` Gateway + MQBusEntity + Config`slot_console` EventBus + MQBusEntity
**删**`slot_console/app/innerapi/controller/LuckyRewardController.php`slot_sdk 3 个 invite callback 文件
**不动**`LuckyRewardInviteLogic` 核心判定、C 端 `/api/*`