ok
This commit is contained in:
128
plans/EventBus-78ee2e02.plan.md
Normal file
128
plans/EventBus-78ee2e02.plan.md
Normal file
@@ -0,0 +1,128 @@
|
||||
<!-- 78ee2e02-349b-4b66-a20c-03e7ee129a45 -->
|
||||
---
|
||||
todos:
|
||||
- id: "rabbitmq-prefetch"
|
||||
content: "RabbitMqService:connect 去掉 prefetch=10;getMessage 增加 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` 即可
|
||||
326
plans/FbService-46ed22aa.plan.md
Normal file
326
plans/FbService-46ed22aa.plan.md
Normal file
@@ -0,0 +1,326 @@
|
||||
<!-- 46ed22aa-9d18-40e3-a7fb-ed92e69d49a2 -->
|
||||
---
|
||||
todos:
|
||||
- id: "fix-fbc-format"
|
||||
content: "FbService 增加 resolveFbcForCapi:raw fbclid 格式化为 fbc;creationTime 优先从 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 100,Invalid 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 发送层完成)。
|
||||
122
plans/Fix version2 wager skip-6b933c7f.plan.md
Normal file
122
plans/Fix version2 wager skip-6b933c7f.plan.md
Normal file
@@ -0,0 +1,122 @@
|
||||
<!-- 6b933c7f-da14-42c8-8223-1fd4068c9b61 -->
|
||||
---
|
||||
todos:
|
||||
- id: "unify-pending-query"
|
||||
content: "WagerTaskModel:getUncompletedTasksVersion2 / 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
167
plans/HTTP-c8f2b444.plan.md
Normal 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` 存 labels(bucket 额外带 `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 名新增,非替换)。
|
||||
176
plans/IMEI FB-3a02c714.plan.md
Normal file
176
plans/IMEI FB-3a02c714.plan.md
Normal 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: "实现 resolveLatestDotPayloadBySource:zPopMax 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. 落地页 mock:POST `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` 即可**,无需动登录链路。
|
||||
132
plans/Model appmodel-4459211d.plan.md
Normal file
132
plans/Model appmodel-4459211d.plan.md
Normal 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
|
||||
53
plans/OTA admin logic split-921d6aef.plan.md
Normal file
53
plans/OTA admin logic split-921d6aef.plan.md
Normal file
@@ -0,0 +1,53 @@
|
||||
<!-- 921d6aef-b550-4dca-b907-d43c81593092 -->
|
||||
---
|
||||
todos:
|
||||
- id: "move-logic"
|
||||
content: "将 FirmwareLogic、UpgradeRecordQueryLogic 迁到 app/admin/logic,namespace 改为 app\\admin\\logic"
|
||||
status: pending
|
||||
- id: "move-validate"
|
||||
content: "将 FirmwareCreateValidate、FirmwareListValidate、UpgradeRecordListValidate 迁到 app/admin/validate,namespace 改为 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`,确认删除的旧类名不在其它已改文件中出现。
|
||||
180
plans/POP modifyFee-454dc363.plan.md
Normal file
180
plans/POP modifyFee-454dc363.plan.md
Normal 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 去掉同步 queryBizLog,markWalletSuccess(0) + 异步 ledger 回填"
|
||||
status: pending
|
||||
- id: "wallet-return-ledger"
|
||||
content: "Phase 2: slot-wallet update 响应增加 ledger_id,POP 直接写入 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 → success;middleware 还有 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` 加分段耗时 metrics(middleware / provider_tx / round / wallet / finalize),用数据验证 Phase 1/2 收益。
|
||||
|
||||
---
|
||||
|
||||
## 预期收益(量级)
|
||||
|
||||
- Phase 1:BET 成功路径 **减少 1~2 次 wallet HTTP**(通常 20~80ms/次,视网络与 wallet 负载)。
|
||||
- Phase 2:WIN/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
146
plans/PRD-9774f234.plan.md
Normal 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 Request)PRD**,结构对齐你仓库里已有规范,例如:
|
||||
|
||||
- 父文档:[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 模式下还缺少以下最小信息(建议下一条回复用 5~10 行写完):
|
||||
|
||||
| 必填 | 示例 |
|
||||
|------|------|
|
||||
| 变更一句话 | 「渠道创建时编号由运营手填改为系统生成」 |
|
||||
| 涉及系统/仓库 | `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
157
plans/Plan-0abba57d.plan.md
Normal 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) 的 use(DTO 可保留文件以免大范围删除,但无引用)
|
||||
|
||||
### 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 脚本
|
||||
77
plans/Plan-214f69a5.plan.md
Normal file
77
plans/Plan-214f69a5.plan.md
Normal 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_no(required_wager=该 Lot 所需)"
|
||||
status: pending
|
||||
- id: "register-group"
|
||||
content: "RegisterService:fee>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 = 单成员退化组)。
|
||||
- **Cash(withdraw)有效投注必须计入当前 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 计入用例。
|
||||
|
||||
## 风险 / 待确认
|
||||
- 退化组 PlayedOut:deposit-only 组资金耗尽未达标时 Deposit 成员状态如何置(现 `markGroupBonusPlayedOut` 只处理 Bonus)。
|
||||
- 存量回填在分表上的批量与幂等(按 `uid` 分片,`createGroupIfAbsent` 幂等)。
|
||||
- 是否一并采纳 V2「整笔只计 progress_group」(成员投注也归 FIFO 组而非自身组)——本方案默认**不改**,保留现有「成员计自身组 + Cash 计 FIFO 组」,单组场景下二者等价。
|
||||
- 是否同时调整扣款顺序为 V2 的「Cash 最后扣」——**默认不在本次范围**。
|
||||
67
plans/Plan-4459211d.plan.md
Normal file
67
plans/Plan-4459211d.plan.md
Normal 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
122
plans/Plan-8f4c095a.plan.md
Normal 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
162
plans/Plan-95f2e196.plan.md
Normal 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 课文 API:content_json 直出 TypeWords Article DTO(nameList 供默写/听写跳过)"
|
||||
status: pending
|
||||
- id: "article-vocab-api"
|
||||
content: "课文生词 API:course_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
240
plans/Plan-b5e97267.plan.md
Normal file
@@ -0,0 +1,240 @@
|
||||
<!-- b5e97267-b1d5-4ef9-9217-ebd91392307c -->
|
||||
---
|
||||
todos:
|
||||
- id: "ddl-mail-tables"
|
||||
content: "在 slot-notification 新增 mail_config(SMTP 直字段)/ 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/senderId,Ant/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
197
plans/Plan-feefd0f2.plan.md
Normal 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_wallet:UpdateWagerTask 解析 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(兼容无 rate),pwa 再发带 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 写入 MQ;wallet 消费端做范围 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 仍 +100(fee=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
106
plans/Plan-ffd1b7b6.plan.md
Normal 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 + WalletPhaseGameStatService(inc + 首充标记)"
|
||||
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
138
plans/RTP-ffd1b7b6.plan.md
Normal 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_stat(slot-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 '充值前RTP,payout / 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 '充值后RTP,payout / 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 Redis,key 规则与 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
|
||||
115
plans/Reward Pool V1-2a311123.plan.md
Normal file
115
plans/Reward Pool V1-2a311123.plan.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<!-- 2a311123-9045-4246-b265-7e37ecfbf924 -->
|
||||
---
|
||||
todos:
|
||||
- id: "ddl"
|
||||
content: "编写 reward_pool / reward_pool_item 建表 DDL(AUTO_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/admin:RewardPool(Item)Controller/Logic/Validate(saithink 范式)+ 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.4(reward_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]
|
||||
```
|
||||
121
plans/SR-3 internal-23416728.plan.md
Normal file
121
plans/SR-3 internal-23416728.plan.md
Normal file
@@ -0,0 +1,121 @@
|
||||
<!-- 23416728-def1-424f-ba2f-16a110d1f084 -->
|
||||
---
|
||||
todos:
|
||||
- id: "internal-layer"
|
||||
content: "新建 internal DeviceController + 迁移 logic/dto/validate(DTO 含 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 通过
|
||||
136
plans/Trial MQ PWA-b36a4d72.plan.md
Normal file
136
plans/Trial MQ PWA-b36a4d72.plan.md
Normal 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 新增 TrialRewardNotifyService(valid_bet / fs_consume / task_completed + 共享配置与 Redis 去重)"
|
||||
status: pending
|
||||
- id: "pwa-hook-wallet"
|
||||
content: "WalletService::bet/win 成功后调用 NotifyService;WalletEntity 补充 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` 发 MQ(PWA 侧用 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 删 MQ;bet 响应加 `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 余额达 $50:PWA 发 `trial_task_completed`(48h 去重)
|
||||
5. 注册 `type=register`:行为不变,仍无 trial MQ
|
||||
175
plans/Trial config by activity_code-d451269e.plan.md
Normal file
175
plans/Trial config by activity_code-d451269e.plan.md
Normal file
@@ -0,0 +1,175 @@
|
||||
<!-- d451269e-1f41-4d6f-ad1f-e7ac88a4925f -->
|
||||
---
|
||||
todos:
|
||||
- id: "constants"
|
||||
content: "TrialRewardConstants:ACTIVITY_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 表 patch(trial_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` 等,与试玩业务解耦。
|
||||
222
plans/TypeWords Element Plus MVP-95f2e196.plan.md
Normal file
222
plans/TypeWords Element Plus MVP-95f2e196.plan.md
Normal file
@@ -0,0 +1,222 @@
|
||||
<!-- 95f2e196-ce33-4253-ace0-0a2ba260a0c2 -->
|
||||
---
|
||||
todos:
|
||||
- id: "backend-catalog-api"
|
||||
content: "collin:CourseSeries/Course 列表 API + CORS 中间件"
|
||||
status: pending
|
||||
- id: "scaffold-collin-web"
|
||||
content: "新建 collin-web:Vite + Vue3 + Element Plus + Router + axios"
|
||||
status: pending
|
||||
- id: "catalog-pages"
|
||||
content: "实现系列 / 册别 / 课文列表三页(Element Plus)"
|
||||
status: pending
|
||||
- id: "integrate-typing-core"
|
||||
content: "alias 引入 @typewords/core,Article 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/Supabase;MVP 只需 [`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` 一致
|
||||
|
||||
**可选 P1(MVP 可跳过):** `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-table:lessonNo、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 → sections(TypeWords 原文逻辑)
|
||||
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` 并注入精简 store(vscode-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 补全(1–2 天)
|
||||
- `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 — 接入练习内核(2–3 天)
|
||||
- Vite alias 接 `@typewords/core`
|
||||
- `toPracticeArticle` + `PracticeView`
|
||||
- 音效、键盘、nameList 跳过逻辑验证(Lesson 5 有 skipWords)
|
||||
|
||||
### Phase 4 — 联调与部署(1 天)
|
||||
- docker:collin 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`
|
||||
48
plans/V2frozen_bonus-3895e6da.plan.md
Normal file
48
plans/V2frozen_bonus-3895e6da.plan.md
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- 3895e6da-dd34-4ce4-b39b-e1e4ccf1c60c -->
|
||||
---
|
||||
todos:
|
||||
- id: "sec2-4"
|
||||
content: "改 §2 差异 bullet 与 §4.1:deposit_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 PlayedOut:Bonus 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` 不复活比例拆分)。
|
||||
80
plans/VIP-740abbc4.plan.md
Normal file
80
plans/VIP-740abbc4.plan.md
Normal file
@@ -0,0 +1,80 @@
|
||||
<!-- 740abbc4-5a59-41be-9318-5927550e6875 -->
|
||||
---
|
||||
todos:
|
||||
- id: "sql"
|
||||
content: "新建 slot_console/db/migrate_vip_bet_bonus_rate.sql:ALTER 加列 bet_bonus_rate decimal(5,2) + UPDATE 写入 VIP1-15 初始值"
|
||||
status: pending
|
||||
- id: "console-logic"
|
||||
content: "改 UserGameStatistics::switchBonus() 按 VIP 等级取 bet_bonus_rate,VIP0/空配置不转换,补日志上下文"
|
||||
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.0,VIP15=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 输出检测报告。
|
||||
253
plans/WalletLogic-c1c00f01.plan.md
Normal file
253
plans/WalletLogic-c1c00f01.plan.md
Normal 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 + WalletRegisterLogic,WalletLogic 委托 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 1:Query + Register + 基类
|
||||
|
||||
- 新建 `AbstractWalletMutationLogic`、`WalletQueryLogic`、`WalletRegisterLogic`。
|
||||
- `WalletLogic::query/queryBizLog` 改为委托;[`GrantLogic`](slot-wallet/app/api/logic/GrantLogic.php) 可继续 `WalletLogic::query()` 不动。
|
||||
- `register` 从 WalletLogic 迁出;`run()` 分发到 `WalletRegisterLogic`。
|
||||
- **验收**:注册接口、余额查询、Grant 通知链路回归。
|
||||
|
||||
### Phase 2:BetWin(最高复杂度)
|
||||
|
||||
- 新建 `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 3:Recharge / Inc
|
||||
|
||||
- 新建 `WalletRechargeLogic`,迁移 `inc` 全链路 + `recharge`/`rechargeSign`/`reward`。
|
||||
- `runRechargeOrder` 委托给 RechargeLogic(含 `rechargeOrderDto` 生命周期)。
|
||||
- **验收**:充值主链路、首充 Trial Pool 扣减、Fund Lot 落库、签到充值 `sign`。
|
||||
|
||||
### Phase 4:Withdraw + 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 200–500 行**。
|
||||
- 改动 bet 不再误触 recharge 事务代码,Review 范围清晰。
|
||||
- 与 `GrantLogic` 风格统一,新人可按域定位代码。
|
||||
- 对外 API、`WalletLogic::run()` 签名、SDK 调用路径 **零破坏**。
|
||||
|
||||
---
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 拆分引入行为回归 | 每 Phase 独立 PR;优先复制代码再删旧代码,不做顺手重构 |
|
||||
| `$requestDTO` 可变状态 | 基类 `bindRequest()` + 子 Logic 不跨用例复用实例 |
|
||||
| Webman 构造注入 | 若无 DI,Phase 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`
|
||||
170
plans/agent_自检报告机制_bf9d9dde.plan.md
Normal file
170
plans/agent_自检报告机制_bf9d9dde.plan.md
Normal 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 写完代码后自动自检出报告:怎么配
|
||||
|
||||
## 你现在已经有什么
|
||||
|
||||
你其实**已经有一半机制**,只是还没有「结构化报告」这一层:
|
||||
|
||||
| 机制 | 文件 | 现在做什么 | 缺什么 |
|
||||
|------|------|------------|--------|
|
||||
| **Rule(alwaysApply)** | [`~/.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 **自觉对照**,脚本不检查 |
|
||||
| **Hook(stop)** | [`~/.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 diff:banlist、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` 仍会跑 verify;FAIL 会再追问一轮
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序(若你确认要做)
|
||||
|
||||
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 分钟;脚本增强约 1–2 小时。
|
||||
58
plans/channel auto transparent-0ecc7d51.plan.md
Normal file
58
plans/channel auto transparent-0ecc7d51.plan.md
Normal 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` 并附检测结果。
|
||||
118
plans/console_innerapi_架构说明_46a7f4d3.plan.md
Normal file
118
plans/console_innerapi_架构说明_46a7f4d3.plan.md
Normal 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 调 console;C 端直接打 `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` 可以共用同一套 Logic(invite 回调与 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` MQ,console 消费 | 解耦、可重试;需 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**,再单独立项做方案 B(MQ)或方案 A(拆服务);当前代码与文档是一致的。
|
||||
118
plans/console_innerapi_架构说明_8bdcea3a.plan.md
Normal file
118
plans/console_innerapi_架构说明_8bdcea3a.plan.md
Normal 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 调 console;C 端直接打 `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` 可以共用同一套 Logic(invite 回调与 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` MQ,console 消费 | 解耦、可重试;需 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**,再单独立项做方案 B(MQ)或方案 A(拆服务);当前代码与文档是一致的。
|
||||
107
plans/console_redis_在线推送_cf0daa26.plan.md
Normal file
107
plans/console_redis_在线推送_cf0daa26.plan.md
Normal 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/RPC,07 文档一期未接 |
|
||||
| **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 在线 gate,Logic 内**不再**单独查在线
|
||||
|
||||
---
|
||||
|
||||
## 若将来需要 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`)
|
||||
192
plans/free_credits_部署文档_149ece64.plan.md
Normal file
192
plans/free_credits_部署文档_149ece64.plan.md
Normal 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 档变 ready;baseline 再次前移
|
||||
4. 尾档:面额 $8.50 时 unlock 为 $8(8000 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),本文档专述**瀑布分档 + 增量解锁**增量变更,二者并存。
|
||||
160
plans/invitebind_规范与弹窗_23c1cd10.plan.md
Normal file
160
plans/invitebind_规范与弹窗_23c1cd10.plan.md
Normal 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: 新增 InviteBindProcessContextDTO;fetchUserProfile 改为返回 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`(内部已 WS);Logic 不重复调 `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 不双份拷贝
|
||||
279
plans/is_new_user recommend-game-09c08dbd.plan.md
Normal file
279
plans/is_new_user recommend-game-09c08dbd.plan.md
Normal file
@@ -0,0 +1,279 @@
|
||||
<!-- 09c08dbd-5e66-4d08-b29e-090a18ddb10d -->
|
||||
---
|
||||
todos:
|
||||
- id: "user-is-new-user"
|
||||
content: "slot-user:AbstractRegisterService 增加 isNewUser 标志,Mobile/Imei/Name RegisterService 赋值,UserController::register 返回 is_new_user"
|
||||
status: pending
|
||||
- id: "console-ggame-query"
|
||||
content: "slot-console:GGame 新增按 game_code 查可展示单条游戏方法"
|
||||
status: pending
|
||||
- id: "console-recommend-logic"
|
||||
content: "slot-console:OnboardingRecommendLogic(center 取池 → 随机 → g_game 组装 → 降级)"
|
||||
status: pending
|
||||
- id: "console-recommend-api"
|
||||
content: "slot-console:OnboardingController + 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: "lobby:AuthResponse 增加 is_new_user;auth.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 1:slot-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 2:slot-console 实现 recommend-game BFF(03 子需求)
|
||||
|
||||
**涉及仓库**:[`/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 3:lobby 前端接入(游戏大厅 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() // 已 setAuth,request 拦截器会自动带 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`)
|
||||
64
plans/lobby welcome popup prd-bc83d927.plan.md
Normal file
64
plans/lobby welcome popup prd-bc83d927.plan.md
Normal 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.md:game 增加 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 发放仍属父活动(钱包)。
|
||||
285
plans/lucky_reward_双弹窗分析_58c583c3.plan.md
Normal file
285
plans/lucky_reward_双弹窗分析_58c583c3.plan.md
Normal file
@@ -0,0 +1,285 @@
|
||||
---
|
||||
name: Lucky Reward 双弹窗分析
|
||||
overview: 被邀请人 Support Invite 应从 HomeEvent 迁到 lucky_reward_invite_bind 成功时 WS 即时推送(只弹一次);HomeEvent 仅保留邀请人 Claim Now(pending_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::notifyClientPOP;HomeEvent 移除被邀请人逻辑
|
||||
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 抽 resolveHomePopups,HomeEvent 只 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 Now,B 只有 Support Invite(见 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php))。
|
||||
|
||||
**同一 uid 双角色时会出现**,典型场景:
|
||||
|
||||
1. **互邀 / 双链**:A 邀 B,B 也邀 A(同一 `cycle_id`,均新注册有效助力)
|
||||
- A:`helper(invitee=A)` → Support Invite;`pending_spin_count>0` → Claim Now
|
||||
- B:同理
|
||||
2. **先被邀、后又成功邀别人**:用户 C 邀了 A(A 为被邀请人);之后 A 又邀了 D(A 为邀请人且未 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 Now;Support 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 链路,再谈弹窗互斥。
|
||||
|
||||
212
plans/lucky_reward_奖池迁移_efaf709a.plan.md
Normal file
212
plans/lucky_reward_奖池迁移_efaf709a.plan.md
Normal 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_console:RewardPoolItemModel + 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 = 3,Cash 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) fixture:pool 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 落地。
|
||||
60
plans/method_type code-3c4f5bff.plan.md
Normal file
60
plans/method_type code-3c4f5bff.plan.md
Normal file
@@ -0,0 +1,60 @@
|
||||
<!-- 3c4f5bff-252e-46d9-942c-7d9b3f38c0c4 -->
|
||||
---
|
||||
todos:
|
||||
- id: "hash-code"
|
||||
content: "§5/§6.5:account_hash 前缀与示例改用 code(usdt/paypal/cashapp),保留 USDT 地址不转小写"
|
||||
status: pending
|
||||
- id: "schema"
|
||||
content: "§7.1/§7.2/§7.3:method_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 来源等既有决策保持不变。
|
||||
119
plans/monthly cashback-215d598c.plan.md
Normal file
119
plans/monthly cashback-215d598c.plan.md
Normal 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→0,2 位小数)/待定(仅当筛选含本月)。
|
||||
- 菜单 `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": "MonthlyRebateLogic:info/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>
|
||||
92
plans/ota-i18n-middleware-22f17591.plan.md
Normal file
92
plans/ota-i18n-middleware-22f17591.plan.md
Normal 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(已选自定义头)。
|
||||
- 英文译文先给准确直译,文案润色后续可调。
|
||||
99
plans/quanfuxialog-27a4a527.plan.md
Normal file
99
plans/quanfuxialog-27a4a527.plan.md
Normal 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 示例)。
|
||||
47
plans/refer-earn_doc_update_ea5b425e.plan.md
Normal file
47
plans/refer-earn_doc_update_ea5b425e.plan.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: refer-earn doc update
|
||||
overview: 基于浏览器实测(数字随汇率浮动、佣金计算器拆解、分享弹窗细节),更新 docs/推荐返佣需求文档.md,补全「1498.87 的来源与性质」「邀请奖金解锁机制」「佣金计算器」「立即邀请分享弹窗」四块内容。
|
||||
todos:
|
||||
- id: invite-bonus
|
||||
content: 更新 §3.3/§3.4:1498.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 / 老虎机 / 存款 0,USD):下注 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`)、输入项(我的代理级别 Lv1–Lv9、朋友货币类型、朋友存款金额、朋友赌注分类、朋友下注金额)、输出(预期奖金 `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)清晰。
|
||||
78
plans/register-bonus-decouple-254c9e72.plan.md
Normal file
78
plans/register-bonus-decouple-254c9e72.plan.md
Normal 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-open:catch `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`)。
|
||||
155
plans/slotMysql-82f6bca9.plan.md
Normal file
155
plans/slotMysql-82f6bca9.plan.md
Normal 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) 第 41–53 行。
|
||||
|
||||
## 目标架构
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
轮询直到可连接(约 30–60 秒):
|
||||
|
||||
```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 数据约 15–45 分钟,视磁盘 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` 等现有配置
|
||||
150
plans/slot_agent-06741b7f.plan.md
Normal file
150
plans/slot_agent-06741b7f.plan.md
Normal 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`。
|
||||
123
plans/spin_响应补全字段_484d4102.plan.md
Normal file
123
plans/spin_响应补全字段_484d4102.plan.md
Normal 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`
|
||||
157
plans/spin去spin_id加锁_0e00ca05.plan.md
Normal file
157
plans/spin去spin_id加锁_0e00ca05.plan.md
Normal 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: LuckyRewardLogic:SET NX 用户锁 fail-fast + 服务端生成 spin_id + 移除客户端幂等分支
|
||||
status: completed
|
||||
- id: tests-docs
|
||||
content: RedisKeyManagerService 锁 key + 单测/文档/YApi 更新
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Spin 去掉前端 spin_id(Redis 锁 + 次数校验)
|
||||
|
||||
## 产品约定(已确认)
|
||||
|
||||
- **防并发**: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 文档
|
||||
165
plans/stats_init_重复分析_3fc8ad4b.plan.md
Normal file
165
plans/stats_init_重复分析_3fc8ad4b.plan.md
Normal 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` | 开宝箱自动首次 Spin(Record 展示) |
|
||||
| `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% |
|
||||
145
plans/support_invite_离线补推_847de6ef.plan.md
Normal file
145
plans/support_invite_离线补推_847de6ef.plan.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: Support Invite 离线补推
|
||||
overview: 用户提出的「离线写 Redis + WS 连接后补推」方案可行;项目已有 `user_websocket_connect` 事件与在线 Redis Set,可在 `deliverInviteeSupportInvitePopup` 与连接回调中实现,无需回退 HomeEvent。
|
||||
todos:
|
||||
- id: pending-service
|
||||
content: 新增 LuckyRewardSupportInvitePendingService:Redis key/TTL、enqueue、flush、可选 sent 标记
|
||||
status: completed
|
||||
- id: deliver-branch
|
||||
content: deliverInviteeSupportInvitePopup:在线即时推,离线 enqueue
|
||||
status: completed
|
||||
- id: ws-connect-flush
|
||||
content: WebsocketConnectEventService::run 连接后 flush pending(sAdd 之后)
|
||||
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**:建议 **30~60 分钟**(覆盖注册加载 + 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 内仍未连 WS:connect 时除读 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 时 GETDEL;TTL 过期则 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 rebuild(helper + opened box) |
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
**可行,且是正确补洞方向**;实现上建议:**离线 Redis pending + WS connect flush**,并加 **DB fallback / sent 标记** 防止 TTL 丢弹。在线判定复用现有 `USER_ONLINE_SET`,接入点放在已存在的 `WebsocketConnectEventService`。
|
||||
92
plans/symfony-lock-distributed-lock-5511b223.plan.md
Normal file
92
plans/symfony-lock-distributed-lock-5511b223.plan.md
Normal 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_BUSY,release(),删除手写 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.4(PHP ≥ 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` 行为。
|
||||
230
plans/tenant_code-9333b12a.plan.md
Normal file
230
plans/tenant_code-9333b12a.plan.md
Normal 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` 保持不变)。
|
||||
136
plans/tobalance_清零分析_8a4b21aa.plan.md
Normal file
136
plans/tobalance_清零分析_8a4b21aa.plan.md
Normal 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: buildStateEntity:playerStatus=已提取时,myAmountDisplay 取自 lucky_reward_cashout_record.amount_qf(大单位)
|
||||
status: completed
|
||||
- id: block-spin-after-withdrawn
|
||||
content: executeManualSpin:status=已提取时拒绝 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)(约 788–806 行)
|
||||
|
||||
```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,与是否清零无关 |
|
||||
320
plans/wallet bet success MQ-8500b436.plan.md
Normal file
320
plans/wallet bet success MQ-8500b436.plan.md
Normal 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` | 上游原始游戏 ID(POP 回调 `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 解析后透传
|
||||
106
plans/webman-log standalone library-2b9f6c44.plan.md
Normal file
106
plans/webman-log standalone library-2b9f6c44.plan.md
Normal 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: "复制 tests(bootstrap、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'
|
||||
```
|
||||
98
plans/webmanlog-4a3bab11.plan.md
Normal file
98
plans/webmanlog-4a3bab11.plan.md
Normal 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 错误日志仍正常
|
||||
108
plans/weekly cashback-215d598c.plan.md
Normal file
108
plans/weekly cashback-215d598c.plan.md
Normal file
@@ -0,0 +1,108 @@
|
||||
<!-- 215d598c-9837-4e65-a3e9-850d78cd5f00 -->
|
||||
## 周返水(Weekly Cashback)实现方案
|
||||
|
||||
### 已定决策(来自本轮确认)
|
||||
- 二、三:按评审意见修订需求文档、计费/配置与每日返水解耦。
|
||||
- 四:方案 B —— 周有效投注每日落库,规避 `user:profit:Ymd` 的 7 天 TTL。
|
||||
- 五.1:VIP 费率存活动配置 `ext_config.vip_rates`(type=13),不放 VIP 表。
|
||||
- 五.2:VIP 取「周末最后一刻」(结算下周一读当前 VIP 即可)。
|
||||
- 五.3:结算上周的同时把「上上周」未领取的置过期。
|
||||
- 五.4:VIP0 不进页面,不处理。
|
||||
- 五.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": "WeeklyRebateLogic:info/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>
|
||||
68
plans/weekly rebate stats-215d598c.plan.md
Normal file
68
plans/weekly rebate stats-215d598c.plan.md
Normal 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()`;金额已由后端格式化,前端直接展示。
|
||||
|
||||
### 菜单 SQL(backend/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": "WeeklyRebateStatsLogic:buildQuery(周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>
|
||||
111
plans/充值后下注返水_c358d34a.plan.md
Normal file
111
plans/充值后下注返水_c358d34a.plan.md
Normal 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`(宁可少算不可多算) |
|
||||
236
plans/全局活动轮次_0e1173f0.plan.md
Normal file
236
plans/全局活动轮次_0e1173f0.plan.md
Normal file
@@ -0,0 +1,236 @@
|
||||
---
|
||||
name: 全局活动轮次
|
||||
overview: Lucky Rewards 活动配置与轮次均改为全平台唯一:`lucky_reward_config`、`lucky_reward_tier_config`、`lucky_reward_cycle` 移除 `source`;转盘奖项已迁至 `reward_pool_item`(无 source,legacy `lucky_reward_prize_config` 确认删除);C 端仍用 `lucky_reward_player.source` 记录用户注册渠道。
|
||||
todos:
|
||||
- id: ddl-migration
|
||||
content: 新增 lucky_reward_drop_source.sql(config/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/maxCycleNo;CycleRolloverService 去 source;TierConfig 去 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` 玩家渠道筛选
|
||||
137
plans/渠道编号自动生成_ef6f98ea.plan.md
Normal file
137
plans/渠道编号自动生成_ef6f98ea.plan.md
Normal 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: GameServerValidate:save/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 update(source 只读或不传)
|
||||
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`
|
||||
167
plans/移除_pending_popup_表_8fea9a4d.plan.md
Normal file
167
plans/移除_pending_popup_表_8fea9a4d.plan.md
Normal 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` 的断言(约 61–78、96–99 行)
|
||||
- 新增用例:`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) §5:Claim 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
|
||||
140
plans/移除被邀请人spin配置_a6751847.plan.md
Normal file
140
plans/移除被邀请人spin配置_a6751847.plan.md
Normal 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 + 重构 buildSupportInvitePopup;HomeEvent 接入
|
||||
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` 给被邀请人预发 Spin(grant_type=3 / `claim-invitee-spin`)。
|
||||
- **产品本意**:被邀请人 Spin 来自**开宝箱**既有流程([`LuckyRewardLogic::openBox`](slot_console/app/api/logic/LuckyRewardLogic.php) 内 `createOpenBoxAutoFirstSpinRecord` + `grantDailySpinIfNeeded`),**不**单独配置「被邀请人 Spin 次数」。
|
||||
- **Support Invite(PRD 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 grant;invitee `spin_available` 仍为 0 |
|
||||
| invitee HomeEvent(helper + 有效 cycle) | 自动 `openBox` 一次;`spin_available` 来自 daily/openBox 逻辑;Support Invite 有 `my_amount_qf` |
|
||||
| 过期短码 invite_bind | `skipped_expired_cycle`;无 helper;HomeEvent 无开箱、无 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` 数据。
|
||||
119
plans/精简_spin_响应_210342c6.plan.md
Normal file
119
plans/精简_spin_响应_210342c6.plan.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: 精简 Spin 响应
|
||||
overview: 调整 `POST /api/lucky-reward/spin` 的 `SpinResultEntity`:去掉 spinId/prizeType/prizeAmountQf/myAmountDisplay,新增 poolItemId;myAmountQf 改为大单位 float(3 位小数向下取整)。
|
||||
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 14,cat_115 内 spin 对应 ID,与 #553–561 同组)
|
||||
|
||||
### 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 不变
|
||||
178
plans/转盘与agent分享归因_d2e94766.plan.md
Normal file
178
plans/转盘与agent分享归因_d2e94766.plan.md
Normal 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=wheel;Logic 仅 wheel 计助力;invite-link/createShareUrl 走 wheel 短码"
|
||||
status: completed
|
||||
- id: user-mq
|
||||
content: "slot_user: shouldNotifyAgentByInviteOrigin 含 wheel;MQ 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_console:wheel 独立短码 + 助力判定
|
||||
|
||||
**[`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_agent:console 回调粗筛(建议)
|
||||
|
||||
[`LuckyRewardInviteCallbackGatewayService::canNotifyInviteBind()`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php) 增加:
|
||||
|
||||
- 仅 `share_origin === 'wheel'` 时 HTTP 调 console(agent 绑定、奖励、关系同步**不受影响**,仍处理全部 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 额外字段
|
||||
172
plans/转盘分享周期绑定_d7eeeacf.plan.md
Normal file
172
plans/转盘分享周期绑定_d7eeeacf.plan.md
Normal 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 '业务ID,wheel填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 复用/新建**,非永久一条
|
||||
- 过期短码:注册 OK,Lucky 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 拦截)
|
||||
179
plans/邀请回调改mq_de5dd9d4.plan.md
Normal file
179
plans/邀请回调改mq_de5dd9d4.plan.md
Normal 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 | 单测 Gateway:wheel 发 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/*`
|
||||
Reference in New Issue
Block a user