158 lines
6.8 KiB
Markdown
158 lines
6.8 KiB
Markdown
---
|
||
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 文档
|