Files
ray zhou f71a5c59af ok
2026-05-29 11:21:40 +08:00

16 lines
47 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\n按 @.cursor/rules/php-clean-code.mdc 这个规范检测一下DailyRebateLogic\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"按 `php-clean-code.mdc` 审查 `DailyRebateLogic`:先读规范并定位该类。\n\n[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/.cursor/rules/php-clean-code.mdc"}},{"type":"tool_use","name":"Grep","input":{"pattern":"DailyRebateLogic","glob":"**/*.php","output_mode":"files_with_matches"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"按 `php-clean-code.mdc` 对 `slot_console/app/api/logic/DailyRebateLogic.php` 做了一轮静态审查。整体分层和命名尚可,但有几处与规范冲突较明显。\n\n---\n\n## 总体评价\n\n| 维度 | 结论 |\n|------|------|\n| 业务流程可读性 | 中等偏上:`claim`、`info` 主流程清晰 |\n| 命名 | 多数合格;少量 `$model` / `$out`、返回类型过宽 |\n| 方法体量 | `settleDate()` 明显超标 |\n| 事务 / 幂等 | `claim` CAS 做得好;事务内调钱包需关注 |\n| PHPDoc / 返回结构 | public 返回 `array<string,mixed>` 不达标 |\n| 日志 / 异常 | 领取缺成功日志;`readTodayBetFromRedis` 吞异常 |\n\n---\n\n## 符合规范的部分\n\n**1. Logic 职责清晰** \n`info` / `claim` / `settleDate` / `expireDueRecords` 分别对应 C 端查询、领取、结算、过期,符合「一个 public 方法一个用例」。\n\n**2. 命名与 early return** \n- `$unlocked`、`$activityOpen` 等布尔语义清楚 \n- `claim()` 用连续 `throw` 做前置校验,嵌套浅 \n- 私有方法有业务语义:`resolveSource`、`syncTodayPending`、`formatTiersForClient`、`appendSettleSample` 等\n\n**3. 幂等与事务(领取)** \n`claim()` 用 `status = CLAIMABLE` 条件更新 + `$affected !== 1`,具备幂等意识,符合 §7。\n\n**4. 不直接返回 Model** \n对外返回数组未把 Model 暴露给 Controller。\n\n**5. Service 使用合理** \n`DailyRebateCalcService`、`DailyRebateConfigService` 偏公共计算/配置,不是单纯 Logic 转发。\n\n---\n\n## 问题清单(按优先级)\n\n### P0 — 正确性 / 数据一致性\n\n**1. 事务内调用 WalletService§4 Logic**\n\n```117:145:slot_console/app/api/logic/DailyRebateLogic.php\n Db::startTrans();\n try {\n $affected = DailyRebateRecordModel::where('id', $record->id)\n // ...\n $wallet = $walletService->gift(\n (int) $record->rebate_amount,\n // ...\n );\n // ...\n Db::commit();\n```\n\n规范要求事务内禁止第三方接口、远程调用、大量循环。若 `WalletService::gift` 走 HTTP/RPC会拉长事务、增加死锁与不一致风险。更稳妥的顺序是先 CAS 更新(或先入账再更新,配合补偿),把远程调用移出 DB 事务,或明确钱包为本地库且文档说明例外。\n\n**2. `syncTodayPending` 可能空指针**\n\n```394:404:slot_console/app/api/logic/DailyRebateLogic.php\n $userEntity = UserService::getUserInfoEntity($uid);\n // ...\n $model->source = $userEntity->source;\n```\n\n`$userEntity` 未判空即访问 `source`,与 `settleDate` 中对 `no_source` 的处理不一致,存在运行时风险。\n\n---\n\n### P1 — 规范硬性要求\n\n**3. `settleDate()` 过长§3建议 ≤50 行)** \n约 150 行,含 Redis 遍历、配置解析、写库、统计、日志,违反「一个方法一个动作」。建议拆成例如:\n\n- `loadProfitUidsForDate(string $statDate): array`\n- `settleUserRebate(int $uid, UserProfitEntity $entity, ...): ?SettleOutcome`\n- `logSettleSummary(...)`\n\n主方法只保留「读 key → 循环 → 打汇总日志」。\n\n**4. `buildRecordRow()` 参数 7 个§3建议 ≤3超出用 DTO**\n\n```449:457:slot_console/app/api/logic/DailyRebateLogic.php\n protected function buildRecordRow(\n int $uid,\n string $source,\n string $date,\n string $today,\n array $tiers,\n ?array $resolved,\n bool $unlocked\n ): array\n```\n\n应收敛为 `DailyRebateRecordContext` 或类似值对象,避免含糊 `array $resolved`。\n\n**5. public 返回 `array<string,mixed>`§3、§5、§8** \n`info()`、`claim()` 的 `@return` 未描述具体结构。规范要求返回 `array` 时必须写清结构,例如:\n\n```php\n/**\n * @return array{\n * unlocked: bool,\n * title: string,\n * // ...\n * }\n */\n```\n\n`formatRecord`、`formatEmptyRecord`、`formatTiersForClient` 同理。\n\n**6. 返水领取缺少业务日志§6** \n`claim()` 成功/失败均未 `LoggerService::info/error`,而规范明确:返水领取属于必须打日志的场景,且应带 `user_id`、`amount`、`stat_date` 等上下文。\n\n**7. `readTodayBetFromRedis` 吞异常§6**\n\n```426:436:slot_console/app/api/logic/DailyRebateLogic.php\n } catch (\\Throwable) {\n return 0;\n }\n```\n\n禁止吞异常至少应记 warn/error含 `uid`、redis key无法处理再抛出或按业务降级并注释原因。\n\n---\n\n### P2 — 可读性 / 命名 / 细节\n\n**8. 模糊变量名 `$model`§2** \n多处 `$model` 表示返水记录,建议改为 `$rebateRecord`,与禁止的 `$row` 同类问题。\n\n**9. 魔法数字 / 字符串§3**\n\n| 位置 | 值 | 建议 |\n|------|-----|------|\n| `info()` L44 | `6`(近 7 日) | `private const RECORD_LOOKBACK_DAYS = 6` |\n| `appendSettleSample` L327 | `5` | `SETTLE_LOG_SAMPLE_LIMIT` |\n| `settleDate` L187-188 | `+1 day` / `+2 day` | 命名常量或配置项 + 注释业务规则 |\n| `formatEmptyRecord` L541 | `'No Bets'` | 与 `statusLabel` 统一或枚举 |\n\n**10. `buildActivityMeta` 死代码§1**\n\n```556:561:slot_console/app/api/logic/DailyRebateLogic.php\n $ext = $activity->ext_config;\n if (is_string($ext)) {\n $ext = json_decode($ext, true) ?: [];\n } elseif (!is_array($ext)) {\n $ext = [];\n }\n```\n\n解析了 `$ext` 但未使用,属于「用注释/冗余代码弥补设计」的反模式,应删除或真正从 `ext_config` 取文案。\n\n**11. 业务异常类型§6** \n统一用 `\\RuntimeException` 且文案为中文短句,可以工作,但规范倾向「业务异常 vs 系统异常」区分;若有项目内 `BusinessException`,领取类错误更适合用它,并保证 `@throws` 与全局处理器一致。\n\n**12. `claim()` 用 `empty($wallet)` 判失败§6** \n语义偏弱更明确的是判返回值结构/错误码,异常信息避免笼统的「入账失败」。\n\n**13. `info()` 内联逻辑偏多** \n`claimable` 判断块L49-63可抽成 `buildClaimableYesterday(int $uid): ?array`,让 `info()` 更像流程目录。\n\n**14. `settleDate` 循环内单条 `save` 无事务** \n批量任务可接受若需「bet + rebate + status」原子性应考虑按用户小事务或 upsert并在注释说明为何允许部分成功当前 catch 单条继续符合批处理,但应在方法 PHPDoc 写清)。\n\n---\n\n## 按规范章节对照\n\n| 章节 | 状态 | 说明 |\n|------|------|------|\n| §1 核心原则 | 部分 | `settleDate` 过长;`buildActivityMeta` 有无用代码 |\n| §2 命名 | 部分 | `$model`、`$out``info` 方法名偏泛但可接受 |\n| §3 方法 | 不合 | `settleDate` 超长;`buildRecordRow` 参数过多 |\n| §4 Logic | 部分 | 事务边界有;事务内含钱包调用存疑 |\n| §5 PHPDoc | 不合 | 多处 `array<string,mixed>` 无结构 |\n| §6 异常/日志 | 不合 | 领取无日志Redis 读吞异常 |\n| §7 幂等 | 较好 | `claim` CAS`settle` 跳过已领取 |\n| §8 Agent 自查 | 见上 | 返回结构、日志、方法长度需改 |\n\n---\n\n## 建议改造顺序\n\n1. **补 `claim` 日志 + 修 `readTodayBetFromRedis` 异常处理**(低风险、立刻满足 §6 \n2. **补全 `info` / `claim` 的 `@return array{...}`**(满足 §5 \n3. **拆分 `settleDate` + 为 `buildRecordRow` 引入上下文 DTO**(满足 §3 \n4. **评估 `claim` 事务与钱包调用顺序**§4需结合 WalletService 实现) \n5. **修 `syncTodayPending` 空用户 + 删 `buildActivityMeta` 死代码**\n\n---\n\n这是只读审查未改代码。若需要我可以按上述优先级直接改一版 `DailyRebateLogic`(并顺带对齐 `php-doc.mdc`)。\n\n[REDACTED]"}]}}
{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\n改一版看看\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"按审查结论改一版 `DailyRebateLogic`:先看现有实现、相关服务和项目里的惯例。\n\n[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php"}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/.cursor/plans/dailyrebatelogic重构_50d62b71.plan.md"}},{"type":"tool_use","name":"Grep","input":{"pattern":"BusinessException|LoggerService::","path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic","glob":"**/*.php","head_limit":30}},{"type":"tool_use","name":"Glob","input":{"glob_pattern":"**/slot_console/app/entity/**/*.php"}}]}}
{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\n中断了吗\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"上次读取被中断了,没有改到代码。我现在重新读取文件并继续改 `DailyRebateLogic`。\n\n[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php"}},{"type":"tool_use","name":"Grep","input":{"pattern":"LoggerService::(info|error)","path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic","glob":"**/*.php","head_limit":15}},{"type":"tool_use","name":"Glob","input":{"glob_pattern":"**/slot_console/app/entity/**/*.php"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Glob","input":{"glob_pattern":"**/*.php","target_directory":"/Users/ray/Documents/project/www/slot/slot_console/app/entity"}},{"type":"tool_use","name":"Grep","input":{"pattern":"@return array\\{","path":"/Users/ray/Documents/project/www/slot/slot_console","glob":"**/*.php","head_limit":10}},{"type":"tool_use","name":"Grep","input":{"pattern":"function gift\\(","path":"/Users/ray/Documents/project/www/slot/slot_lib","glob":"**/*.php","head_limit":5}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_lib/src/services/WalletService.php","offset":250,"limit":40}},{"type":"tool_use","name":"Grep","input":{"pattern":"Db::startTrans|claim\\(","path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php","head_limit":20}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/entity/BaseEntity.php","limit":40}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Grep","input":{"pattern":"statusLabel|No Bets","path":"/Users/ray/Documents/project/www/slot/slot_console/app/model/common/DailyRebateRecordModel.php"}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php","offset":280,"limit":80}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/model/common/DailyRebateRecordModel.php","limit":60}}]}}
{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\n中断了呀\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"上次确实中断了,还没写入代码。我现在直接改 `DailyRebateLogic`。\n\n[REDACTED]"},{"type":"tool_use","name":"Write","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php","contents":"<?php\n\nnamespace app\\api\\logic;\n\nuse app\\entity\\command\\UserProfitEntity;\nuse app\\model\\common\\DailyRebateRecordModel;\nuse app\\model\\common\\RechargeGiftConfigModel;\nuse app\\service\\DailyRebateCalcService;\nuse app\\service\\DailyRebateConfigService;\nuse app\\service\\LoggerService;\nuse app\\service\\RedisKeyManagerService;\nuse app\\service\\user\\RechargedUidSetService;\nuse app\\service\\user\\UserService;\nuse slotLib\\common\\const\\Consts;\nuse slotLib\\services\\UserService as LibUserService;\nuse slotLib\\services\\WalletService;\nuse support\\Redis;\nuse support\\think\\Db;\n\n/**\n * 每日返水 C 端与定时任务业务编排。\n */\nclass DailyRebateLogic\n{\n /** 活动页展示近 7 日流水(含今天),向前回溯天数 */\n private const RECORD_LOOKBACK_DAYS = 6;\n\n /** 结算日志每类 skip 原因最多保留样例条数 */\n private const SETTLE_LOG_SAMPLE_LIMIT = 5;\n\n /** 统计日次日 0 点起可领取 */\n private const SETTLE_CLAIMABLE_OFFSET = '+1 day';\n\n /** 统计日次日起第 2 日 0 点过期 */\n private const SETTLE_EXPIRE_OFFSET = '+2 day';\n\n private const LOG_TAG_CLAIM = 'daily_rebate_claim';\n\n private const LOG_TAG_SETTLE = 'daily_rebate_settle';\n\n /**\n * 活动页数据:档位、近 7 日记录、可领昨日返水(须登录且已充值)。\n *\n * @param int $uid 用户 ID\n * @param string $source 渠道号,空则从用户资料读取\n * @return array{\n * unlocked: bool,\n * title: string,\n * help: string,\n * rule_desc: string,\n * countdown_seconds: int,\n * claimable: array{stat_date: string, rebate: string}|null,\n * banner_image: string,\n * tiers: list<array{min: string, max: string|null, rate: float}>,\n * records: list<array{stat_date: string, date_display: string, bet: string, rebate: string, status: string}>\n * }\n */\n public function info(int $uid, string $source = ''): array\n {\n $source = $this->resolveSource($uid, $source);\n $resolved = DailyRebateConfigService::resolveForSource($source);\n $activityOpen = $resolved !== null;\n $unlocked = $activityOpen && $this->userHasDeposit($uid);\n $tiers = $resolved['tiers'] ?? [];\n $tierDisplay = $this->formatTiersForClient($tiers);\n $activityMeta = $this->buildActivityMeta($resolved['activity'] ?? null);\n\n $today = date('Y-m-d');\n $viewContext = $this->buildInfoViewContext($uid, $source, $today, $tiers, $resolved, $unlocked);\n $records = [];\n for ($i = self::RECORD_LOOKBACK_DAYS; $i >= 0; $i--) {\n $date = date('Y-m-d', strtotime(\"-{$i} day\"));\n $records[] = $this->buildRecordRow($date, $viewContext);\n }\n\n $endOfDay = strtotime($today . ' 23:59:59');\n $bannerImage = $resolved !== null\n ? DailyRebateConfigService::bannerImageFromActivity($resolved['activity'])\n : '';\n\n return [\n 'unlocked' => $unlocked,\n 'title' => $activityMeta['title'],\n 'help' => $activityMeta['help'],\n 'rule_desc' => $activityMeta['rule_desc'],\n 'countdown_seconds' => max($endOfDay - time(), 0),\n 'claimable' => $unlocked ? $this->buildClaimableYesterday($uid) : null,\n 'banner_image' => $bannerImage,\n 'tiers' => $tierDisplay,\n 'records' => $records,\n ];\n }\n\n /**\n * 领取指定自然日返水;默认昨日。短事务 CAS 占坑后调钱包入账,失败回滚为可领。\n *\n * @param int $uid 用户 ID\n * @param string $source 渠道号\n * @param string|null $statDate 统计日期 Y-m-d默认昨天\n * @return array{\n * stat_date: string,\n * rebate_amount: int,\n * display: string,\n * balance: string\n * }\n * @throws \\RuntimeException 活动未开、未充值、状态不可领或入账失败\n */\n public function claim(int $uid, string $source = '', ?string $statDate = null): array\n {\n $source = $this->resolveSource($uid, $source);\n $statDate = $statDate ?: date('Y-m-d', strtotime('-1 day'));\n $rebateRecord = $this->findClaimableRebateRecord($uid, $source, $statDate);\n $rebateAmountLi = (int) $rebateRecord->rebate_amount;\n $recordId = (int) $rebateRecord->id;\n\n $this->markRebateRecordClaimed($recordId);\n\n try {\n $wallet = $this->creditDailyRebateToWallet($uid, $statDate, $rebateAmountLi);\n } catch (\\Throwable $e) {\n $this->revertRebateRecordToClaimable($recordId);\n LoggerService::error(self::LOG_TAG_CLAIM, [\n 'phase' => 'wallet_failed',\n 'uid' => $uid,\n 'stat_date' => $statDate,\n 'record_id' => $recordId,\n 'amount' => $rebateAmountLi,\n 'message' => $e->getMessage(),\n ]);\n throw $e;\n }\n\n LoggerService::info(self::LOG_TAG_CLAIM, [\n 'phase' => 'success',\n 'uid' => $uid,\n 'stat_date' => $statDate,\n 'record_id' => $recordId,\n 'amount' => $rebateAmountLi,\n 'balance' => $wallet->balance ?? 0,\n ]);\n\n return [\n 'stat_date' => $statDate,\n 'rebate_amount' => $rebateAmountLi,\n 'display' => getNumberFormat($rebateAmountLi),\n 'balance' => getNumberFormat($wallet->balance ?? 0),\n ];\n }\n\n /**\n * 结算指定自然日返水:读 Redis 用户日盈利哈希,按渠道活动配置写 claimable 记录。\n *\n * @param string $statDate 统计日期 Y-m-d须早于今天\n * @return int 成功写入/更新条数\n */\n public function settleDate(string $statDate): int\n {\n $profitKey = RedisKeyManagerService::getUserProfitKey(date('Ymd', strtotime($statDate)));\n $redis = Redis::connection('share');\n $uidKeys = $redis->hKeys($profitKey);\n $profitRowCount = count($uidKeys);\n\n $this->logSettlePhase('start', $statDate, $profitKey, $profitRowCount);\n\n $claimableAt = date('Y-m-d H:i:s', strtotime($statDate . ' ' . self::SETTLE_CLAIMABLE_OFFSET));\n $expireAt = date('Y-m-d H:i:s', strtotime($statDate . ' ' . self::SETTLE_EXPIRE_OFFSET));\n $settleContext = $this->createSettleContext($claimableAt, $expireAt);\n\n $settledCount = 0;\n foreach ($uidKeys as $uidKey) {\n $settledCount += $this->settleOneUserFromProfitHash(\n $redis,\n $profitKey,\n $statDate,\n (string) $uidKey,\n $settleContext\n ) ? 1 : 0;\n }\n\n $this->logSettlePhase('finish', $statDate, $profitKey, $profitRowCount, $settledCount, $settleContext);\n\n return $settledCount;\n }\n\n /**\n * 将已过期的待领取记录置为过期。\n *\n * @return int 更新行数\n */\n public function expireDueRecords(): int\n {\n return DailyRebateRecordModel::where('status', DailyRebateRecordModel::STATUS_CLAIMABLE)\n ->where('expire_at', '<=', date('Y-m-d H:i:s'))\n ->update(['status' => DailyRebateRecordModel::STATUS_EXPIRED]);\n }\n\n /**\n * 是否曾充值(共享 Redis set user:rechargedwallet 维护)。\n *\n * @param int $uid 用户 ID\n * @return bool\n */\n public function userHasDeposit(int $uid): bool\n {\n return RechargedUidSetService::contains($uid);\n }\n\n /**\n * 解析渠道号:入参为空时从用户资料读取。\n *\n * @param int $uid 用户 ID\n * @param string $source 渠道号\n * @return string\n */\n protected function resolveSource(int $uid, string $source): string\n {\n if ($source !== '') {\n return $source;\n }\n\n return (string) LibUserService::getInstance()->setUid($uid)->getUserInfo()->source;\n }\n\n /**\n * 校验并返回可领取的返水记录。\n *\n * @param int $uid 用户 ID\n * @param string $source 渠道号\n * @param string $statDate 统计日期 Y-m-d\n * @return DailyRebateRecordModel\n * @throws \\RuntimeException\n */\n protected function findClaimableRebateRecord(int $uid, string $source, string $statDate): DailyRebateRecordModel\n {\n if (!DailyRebateConfigService::resolveForSource($source)) {\n throw new \\RuntimeException('活动未开启');\n }\n if (!$this->userHasDeposit($uid)) {\n throw new \\RuntimeException('请先充值解锁每日返水');\n }\n\n $rebateRecord = DailyRebateRecordModel::findByUidAndDate($uid, $statDate);\n if (!$rebateRecord) {\n throw new \\RuntimeException('返水记录不存在');\n }\n if ((int) $rebateRecord->status !== DailyRebateRecordModel::STATUS_CLAIMABLE) {\n throw new \\RuntimeException('当前状态不可领取');\n }\n if ((int) $rebateRecord->rebate_amount <= 0) {\n throw new \\RuntimeException('无可领取返水');\n }\n if (strtotime((string) $rebateRecord->expire_at) <= time()) {\n throw new \\RuntimeException('返水已过期');\n }\n\n return $rebateRecord;\n }\n\n /**\n * 短事务内 CAS 将记录置为已领取。\n *\n * @param int $recordId daily_rebate_record.id\n * @return void\n * @throws \\RuntimeException CAS 未命中\n */\n protected function markRebateRecordClaimed(int $recordId): void\n {\n Db::startTrans();\n try {\n $affected = DailyRebateRecordModel::where('id', $recordId)\n ->where('status', DailyRebateRecordModel::STATUS_CLAIMABLE)\n ->update([\n 'status' => DailyRebateRecordModel::STATUS_CLAIMED,\n 'claimed_at' => date('Y-m-d H:i:s'),\n ]);\n if ($affected !== 1) {\n throw new \\RuntimeException('领取失败,请重试');\n }\n Db::commit();\n } catch (\\Throwable $e) {\n Db::rollback();\n throw $e;\n }\n }\n\n /**\n * 钱包入账失败时将记录回滚为可领取,便于用户重试。\n *\n * @param int $recordId daily_rebate_record.id\n * @return void\n */\n protected function revertRebateRecordToClaimable(int $recordId): void\n {\n DailyRebateRecordModel::where('id', $recordId)\n ->where('status', DailyRebateRecordModel::STATUS_CLAIMED)\n ->update([\n 'status' => DailyRebateRecordModel::STATUS_CLAIMABLE,\n 'claimed_at' => null,\n ]);\n }\n\n /**\n * 经 WalletService 发放每日返水(远程钱包服务,须在 DB 事务外调用)。\n *\n * @param int $uid 用户 ID\n * @param string $statDate 统计日期\n * @param int $rebateAmountLi 返水金额(厘)\n * @return object 钱包入账结果,含 balance\n * @throws \\RuntimeException\n * @throws \\Throwable\n */\n protected function creditDailyRebateToWallet(int $uid, string $statDate, int $rebateAmountLi): object\n {\n $user = LibUserService::getInstance()->setUid($uid)->getUserInfo();\n $walletService = WalletService::getNoWalletDataService(\n $uid,\n $user->currency,\n $user->source,\n $user->organization\n );\n\n return $walletService->gift(\n $rebateAmountLi,\n 0,\n Consts::TRANSACTION_TYPE_DAILY_REBATE,\n '每日返水 ' . $statDate\n );\n }\n\n /**\n * 构建昨日可领返水摘要;无则返回 null。\n *\n * @param int $uid 用户 ID\n * @return array{stat_date: string, rebate: string}|null\n */\n protected function buildClaimableYesterday(int $uid): ?array\n {\n $yesterday = date('Y-m-d', strtotime('-1 day'));\n $rebateRecord = DailyRebateRecordModel::findByUidAndDate($uid, $yesterday);\n if (!$rebateRecord\n || (int) $rebateRecord->status !== DailyRebateRecordModel::STATUS_CLAIMABLE\n || (int) $rebateRecord->rebate_amount <= 0\n || strtotime((string) $rebateRecord->expire_at) <= time()\n ) {\n return null;\n }\n\n return [\n 'stat_date' => $yesterday,\n 'rebate' => getNumberFormat((int) $rebateRecord->rebate_amount),\n ];\n }\n\n /**\n * 活动页流水展示上下文(近 7 日共用)。\n *\n * @param int $uid 用户 ID\n * @param string $source 渠道号\n * @param string $today 今天 Y-m-d\n * @param list<array{sort:int,min_bet:int,max_bet:?int,rate_percent:float}> $tiers 档位\n * @param array{activity:RechargeGiftConfigModel,tiers:list<array>}|null $resolved 活动配置\n * @param bool $unlocked 是否已充值解锁\n * @return array{\n * uid: int,\n * source: string,\n * today: string,\n * tiers: list<array{sort:int,min_bet:int,max_bet:?int,rate_percent:float}>,\n * resolved: array{activity:RechargeGiftConfigModel,tiers:list<array>}|null,\n * unlocked: bool\n * }\n */\n protected function buildInfoViewContext(\n int $uid,\n string $source,\n string $today,\n array $tiers,\n ?array $resolved,\n bool $unlocked\n ): array {\n return [\n 'uid' => $uid,\n 'source' => $source,\n 'today' => $today,\n 'tiers' => $tiers,\n 'resolved' => $resolved,\n 'unlocked' => $unlocked,\n ];\n }\n\n /**\n * 初始化批量结算上下文按渠道缓存活动配置、skip 统计与样例)。\n *\n * @param string $claimableAt 可领取时间\n * @param string $expireAt 过期时间\n * @return array{\n * claimable_at: string,\n * expire_at: string,\n * resolved_by_source: array<string, array{activity:RechargeGiftConfigModel,tiers:list<array>}|null>,\n * source_resolve_diag: array<string, array<string,mixed>>,\n * skip: array<string, int>,\n * samples: array<string, list<array<string,mixed>>>\n * }\n */\n protected function createSettleContext(string $claimableAt, string $expireAt): array\n {\n return [\n 'claimable_at' => $claimableAt,\n 'expire_at' => $expireAt,\n 'resolved_by_source' => [],\n 'source_resolve_diag' => [],\n 'skip' => [\n 'invalid_uid' => 0,\n 'not_recharged' => 0,\n 'no_source' => 0,\n 'no_activity' => 0,\n 'rebate_zero' => 0,\n 'already_claimed' => 0,\n 'error' => 0,\n ],\n 'samples' => [\n 'not_recharged' => [],\n 'no_source' => [],\n 'no_activity' => [],\n 'rebate_zero' => [],\n 'already_claimed' => [],\n 'error' => [],\n 'settled' => [],\n ],\n ];\n }\n\n /**\n * 结算单用户:从 Redis 盈利哈希解析下注并 upsert claimable 记录。\n *\n * @param \\Redis|\\Predis\\Client $redis 共享 Redis 连接\n * @param string $profitKey 用户日盈利哈希 key\n * @param string $statDate 统计日期\n * @param string $uidKey 哈希 fielduid 字符串)\n * @param array $settleContext createSettleContext 返回值(引用修改 skip/samples\n * @return bool 是否成功写入/更新\n */\n protected function settleOneUserFromProfitHash($redis, string $profitKey, string $statDate, string $uidKey, array &$settleContext): bool\n {\n try {\n $uid = (int) $uidKey;\n if ($uid <= 0) {\n $settleContext['skip']['invalid_uid']++;\n return false;\n }\n\n $profitPayload = $this->decodeProfitPayload($redis, $profitKey, $uid);\n if ($profitPayload === null) {\n return false;\n }\n\n $betLi = (int) $profitPayload['entity']->bet;\n if ($betLi <= 0) {\n return false;\n }\n\n if (!$this->userHasDeposit($uid)) {\n $settleContext['skip']['not_recharged']++;\n $this->appendSettleSample($settleContext['samples']['not_recharged'], [\n 'uid' => $uid,\n 'bet' => $betLi,\n 'source' => $profitPayload['source'],\n ]);\n return false;\n }\n\n $source = $this->resolveSourceForSettle($uid, $profitPayload['source'], $settleContext);\n if ($source === null) {\n $settleContext['skip']['no_source']++;\n $this->appendSettleSample($settleContext['samples']['no_source'], ['uid' => $uid, 'bet' => $betLi]);\n return false;\n }\n\n $activityResolved = $this->resolveActivityForSettle($source, $settleContext);\n if ($activityResolved === null) {\n $settleContext['skip']['no_activity']++;\n $this->appendSettleSample($settleContext['samples']['no_activity'], [\n 'uid' => $uid,\n 'bet' => $betLi,\n 'source' => $source,\n ]);\n return false;\n }\n\n $tiers = $activityResolved['tiers'];\n $rebateLi = DailyRebateCalcService::calcRebateLi($betLi, $tiers);\n if ($rebateLi <= 0) {\n $settleContext['skip']['rebate_zero']++;\n $this->appendSettleSample($settleContext['samples']['rebate_zero'], [\n 'uid' => $uid,\n 'bet' => $betLi,\n 'source' => $source,\n ]);\n return false;\n }\n\n $saved = $this->upsertClaimableRebateRecord(\n $uid,\n $statDate,\n $source,\n $betLi,\n $rebateLi,\n $tiers,\n $settleContext['claimable_at'],\n $settleContext['expire_at']\n );\n if (!$saved) {\n $settleContext['skip']['already_claimed']++;\n $this->appendSettleSample($settleContext['samples']['already_claimed'], [\n 'uid' => $uid,\n 'bet' => $betLi,\n 'source' => $source,\n ]);\n return false;\n }\n\n $this->appendSettleSample($settleContext['samples']['settled'], [\n 'uid' => $uid,\n 'bet' => $betLi,\n 'source' => $source,\n 'rebate' => $rebateLi,\n ]);\n\n return true;\n } catch (\\Throwable $e) {\n $settleContext['skip']['error']++;\n $this->appendSettleSample($settleContext['samples']['error'], [\n 'uid' => (int) $uidKey,\n 'message' => $e->getMessage(),\n ]);\n LoggerService::error(self::LOG_TAG_SETTLE, [\n 'phase' => 'row_error',\n 'stat_date' => $statDate,\n 'uid' => (int) $uidKey,\n 'message' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n /**\n * 从 Redis 盈利哈希解码单用户 payload。\n *\n * @param \\Redis|\\Predis\\Client $redis 共享 Redis\n * @param string $profitKey 哈希 key\n * @param int $uid 用户 ID\n * @return array{entity: UserProfitEntity, source: string}|null\n */\n protected function decodeProfitPayload($redis, string $profitKey, int $uid): ?array\n {\n $raw = $redis->hGet($profitKey, (string) $uid);\n if (empty($raw)) {\n return null;\n }\n $decoded = @unserialize($raw, ['allowed_classes' => false]);\n if (!is_array($decoded)) {\n return null;\n }\n\n return [\n 'entity' => new UserProfitEntity($decoded),\n 'source' => trim((string) ($decoded['source'] ?? '')),\n ];\n }\n\n /**\n * 结算时解析渠道Redis 无 source 则查用户资料。\n *\n * @param int $uid 用户 ID\n * @param string $sourceFromRedis Redis 中的渠道\n * @param array $settleContext 结算上下文\n * @return string|null 无渠道时为 null\n */\n protected function resolveSourceForSettle(int $uid, string $sourceFromRedis, array &$settleContext): ?string\n {\n if ($sourceFromRedis !== '') {\n return $sourceFromRedis;\n }\n $userEntity = UserService::getUserInfoEntity($uid);\n\n return $userEntity !== null ? (string) $userEntity->source : null;\n }\n\n /**\n * 按渠道解析活动配置(带缓存)。\n *\n * @param string $source 渠道号\n * @param array $settleContext 结算上下文\n * @return array{activity:RechargeGiftConfigModel,tiers:list<array>}|null\n */\n protected function resolveActivityForSettle(string $source, array &$settleContext): ?array\n {\n if (!array_key_exists($source, $settleContext['resolved_by_source'])) {\n $settleContext['resolved_by_source'][$source] = DailyRebateConfigService::resolveForSource($source);\n $settleContext['source_resolve_diag'][$source] = DailyRebateConfigService::resolveDiagnostics($source);\n }\n\n return $settleContext['resolved_by_source'][$source];\n }\n\n /**\n * 写入或更新可领取返水记录;已领取则跳过。\n *\n * @param int $uid 用户 ID\n * @param string $statDate 统计日期\n * @param string $source 渠道号\n * @param int $betLi 有效下注(厘)\n * @param int $rebateLi 返水(厘)\n * @param list<array> $tiers 档位\n * @param string $claimableAt 可领取时间\n * @param string $expireAt 过期时间\n * @return bool 是否写入成功false 表示已领取跳过)\n */\n protected function upsertClaimableRebateRecord(\n int $uid,\n string $statDate,\n string $source,\n int $betLi,\n int $rebateLi,\n array $tiers,\n string $claimableAt,\n string $expireAt\n ): bool {\n $rebateRecord = DailyRebateRecordModel::findByUidAndDate($uid, $statDate);\n if ($rebateRecord === null) {\n $rebateRecord = new DailyRebateRecordModel();\n $rebateRecord->uid = $uid;\n $rebateRecord->stat_date = $statDate;\n } elseif ((int) $rebateRecord->status === DailyRebateRecordModel::STATUS_CLAIMED) {\n return false;\n }\n\n $snapshot = json_encode(DailyRebateCalcService::buildTierSnapshot($tiers), JSON_UNESCAPED_UNICODE);\n $rebateRecord->source = $source;\n $rebateRecord->bet_amount = $betLi;\n $rebateRecord->rebate_amount = $rebateLi;\n $rebateRecord->status = DailyRebateRecordModel::STATUS_CLAIMABLE;\n $rebateRecord->tier_snapshot = $snapshot;\n $rebateRecord->claimable_at = $claimableAt;\n $rebateRecord->expire_at = $expireAt;\n $rebateRecord->save();\n\n return true;\n }\n\n /**\n * 结算阶段日志。\n *\n * @param string $phase start|finish\n * @param string $statDate 统计日期\n * @param string $profitKey Redis key\n * @param int $profitRowCount 盈利哈希行数\n * @param int|null $settledCount finish 阶段写入条数\n * @param array|null $settleContext finish 阶段上下文\n * @return void\n */\n protected function logSettlePhase(\n string $phase,\n string $statDate,\n string $profitKey,\n int $profitRowCount,\n ?int $settledCount = null,\n ?array $settleContext = null\n ): void {\n $payload = [\n 'phase' => $phase,\n 'stat_date' => $statDate,\n 'profit_redis_key' => $profitKey,\n 'profit_redis_rows' => $profitRowCount,\n ];\n if ($phase === 'start') {\n $payload['recharged_redis_key'] = RedisKeyManagerService::getUserRechargedSetKey();\n $payload['recharged_scard'] = RechargedUidSetService::cardinality();\n }\n if ($phase === 'finish' && $settleContext !== null) {\n $payload['settled'] = $settledCount;\n $payload['skip'] = $settleContext['skip'];\n $payload['source_resolve'] = $settleContext['source_resolve_diag'];\n $payload['samples'] = $settleContext['samples'];\n }\n LoggerService::info(self::LOG_TAG_SETTLE, $payload);\n }\n\n /**\n * 结算日志样例桶:每类 skip 原因最多保留样例条数。\n *\n * @param list<array<string,mixed>> $bucket 样例数组(引用传递)\n * @param array<string,mixed> $item 单条样例字段uid、bet、source 等)\n * @return void\n */\n private function appendSettleSample(array &$bucket, array $item): void\n {\n if (count($bucket) >= self::SETTLE_LOG_SAMPLE_LIMIT) {\n return;\n }\n $bucket[] = $item;\n }\n\n /**\n * 同步当日待定记录(打开活动页时刷新 Redis 下注额)。\n *\n * @param int $uid 用户 ID\n * @param string $source 渠道号\n * @return void\n */\n protected function syncTodayPending(int $uid, string $source): void\n {\n $resolved = DailyRebateConfigService::resolveForSource($source);\n if ($resolved === null || !$this->userHasDeposit($uid)) {\n return;\n }\n $tiers = $resolved['tiers'];\n\n $today = date('Y-m-d');\n $betLi = $this->readTodayBetFromRedis($uid);\n if ($betLi <= 0) {\n return;\n }\n $rebateLi = DailyRebateCalcService::calcRebateLi($betLi, $tiers);\n if ($rebateLi <= 0) {\n return;\n }\n $userEntity = UserService::getUserInfoEntity($uid);\n if ($userEntity === null) {\n return;\n }\n\n $rebateRecord = DailyRebateRecordModel::findByUidAndDate($uid, $today);\n if ($rebateRecord === null) {\n $rebateRecord = new DailyRebateRecordModel();\n $rebateRecord->uid = $uid;\n $rebateRecord->stat_date = $today;\n }\n if ($rebateRecord->id && (int) $rebateRecord->status !== DailyRebateRecordModel::STATUS_PENDING) {\n return;\n }\n $rebateRecord->source = (string) $userEntity->source;\n $rebateRecord->bet_amount = $betLi;\n $rebateRecord->rebate_amount = $rebateLi;\n $rebateRecord->status = DailyRebateRecordModel::STATUS_PENDING;\n $rebateRecord->tier_snapshot = json_encode(DailyRebateCalcService::buildTierSnapshot($tiers), JSON_UNESCAPED_UNICODE);\n $rebateRecord->claimable_at = null;\n $rebateRecord->expire_at = null;\n $rebateRecord->claimed_at = null;\n $rebateRecord->save();\n }\n\n /**\n * 读取 Redis 中用户当日有效下注(厘);反序列化失败记 warn 并返回 0。\n *\n * @param int $uid 用户 ID\n * @return int 当日有效下注(厘)\n */\n protected function readTodayBetFromRedis(int $uid): int\n {\n $key = RedisKeyManagerService::getUserProfitKey(date('Ymd'));\n $raw = Redis::connection('share')->hGet($key, (string) $uid);\n if (empty($raw)) {\n return 0;\n }\n try {\n $decoded = unserialize($raw, ['allowed_classes' => false]);\n if (!is_array($decoded)) {\n return 0;\n }\n $profitEntity = new UserProfitEntity($decoded);\n\n return (int) $profitEntity->bet;\n } catch (\\Throwable $e) {\n LoggerService::error('daily_rebate_redis', [\n 'phase' => 'decode_profit',\n 'uid' => $uid,\n 'redis_key' => $key,\n 'message' => $e->getMessage(),\n ]);\n\n return 0;\n }\n }\n\n /**\n * 构建活动页单日流水展示行。\n *\n * @param string $date 统计日期 Y-m-d\n * @param array{\n * uid: int,\n * source: string,\n * today: string,\n * tiers: list<array{sort:int,min_bet:int,max_bet:?int,rate_percent:float}>,\n * resolved: array{activity:RechargeGiftConfigModel,tiers:list<array>}|null,\n * unlocked: bool\n * } $viewContext buildInfoViewContext 返回值\n * @return array{stat_date: string, date_display: string, bet: string, rebate: string, status: string}\n */\n protected function buildRecordRow(string $date, array $viewContext): array\n {\n if ($viewContext['resolved'] === null || !$viewContext['unlocked']) {\n return $this->formatEmptyRecord($date);\n }\n\n $uid = $viewContext['uid'];\n $source = $viewContext['source'];\n $today = $viewContext['today'];\n $tiers = $viewContext['tiers'];\n\n if ($date === $today) {\n $this->syncTodayPending($uid, $source);\n }\n\n $rebateRecord = DailyRebateRecordModel::findByUidAndDate($uid, $date);\n if ($date === $today) {\n $betLi = $rebateRecord ? (int) $rebateRecord->bet_amount : $this->readTodayBetFromRedis($uid);\n if ($betLi <= 0) {\n return $this->formatEmptyRecord($date);\n }\n $rebateLi = $rebateRecord\n ? (int) $rebateRecord->rebate_amount\n : DailyRebateCalcService::calcRebateLi($betLi, $tiers);\n\n return $this->formatRecord($date, $betLi, $rebateLi, DailyRebateRecordModel::STATUS_PENDING);\n }\n\n if (!$rebateRecord || (int) $rebateRecord->bet_amount <= 0) {\n return $this->formatEmptyRecord($date);\n }\n\n return $this->formatRecord(\n $date,\n (int) $rebateRecord->bet_amount,\n (int) $rebateRecord->rebate_amount,\n (int) $rebateRecord->status\n );\n }\n\n /**\n * @param string $date 统计日期\n * @param int $betLi 有效下注(厘)\n * @param int $rebateLi 返水(厘)\n * @param int $status DailyRebateRecordModel::STATUS_*\n * @return array{stat_date: string, date_display: string, bet: string, rebate: string, status: string}\n */\n protected function formatRecord(string $date, int $betLi, int $rebateLi, int $status): array\n {\n return [\n 'stat_date' => $date,\n 'date_display' => date('m/d', strtotime($date)),\n 'bet' => getNumberFormat($betLi),\n 'rebate' => getNumberFormat($rebateLi),\n 'status' => DailyRebateRecordModel::statusLabel($status),\n ];\n }\n\n /**\n * 格式化档位为 C 端展示结构(仅展示金额与比例)。\n *\n * @param list<array{sort:int,min_bet:int,max_bet:?int,rate_percent:float}> $tiers 档位(厘)\n * @return list<array{min: string, max: string|null, rate: float}>\n */\n protected function formatTiersForClient(array $tiers): array\n {\n $tierDisplayRows = [];\n foreach ($tiers as $tier) {\n $tierDisplayRows[] = [\n 'min' => getNumberFormat((int) $tier['min_bet']),\n 'max' => $tier['max_bet'] === null ? null : getNumberFormat((int) $tier['max_bet']),\n 'rate' => (float) $tier['rate_percent'],\n ];\n }\n\n return $tierDisplayRows;\n }\n\n /**\n * 构建无下注记录时的占位行(展示金额为 0。\n *\n * @param string $date 统计日期\n * @return array{stat_date: string, date_display: string, bet: string, rebate: string, status: string}\n */\n protected function formatEmptyRecord(string $date): array\n {\n return [\n 'stat_date' => $date,\n 'date_display' => date('m/d', strtotime($date)),\n 'bet' => getNumberFormat(0),\n 'rebate' => getNumberFormat(0),\n 'status' => 'No Bets',\n ];\n }\n\n /**\n * 从活动配置中提取 C 端文案字段。\n *\n * @param RechargeGiftConfigModel|null $activity 每日返水活动配置行\n * @return array{title:string,help:string,rule_desc:string}\n */\n protected function buildActivityMeta(?RechargeGiftConfigModel $activity): array\n {\n if ($activity === null) {\n return ['title' => '', 'help' => '', 'rule_desc' => ''];\n }\n\n return [\n 'title' => (string) ($activity->title ?? ''),\n 'help' => (string) ($activity->help ?? ''),\n 'rule_desc' => (string) ($activity->rule_desc ?? ''),\n ];\n }\n}\n"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Shell","input":{"command":"docker exec -w /app/www/slot/slot_console php82 php -l app/api/logic/DailyRebateLogic.php 2>&1; ~/.cursor/hooks/verify-slot-backend.sh 2>&1","description":"PHP syntax check and verify hook"}},{"type":"tool_use","name":"ReadLints","input":{"paths":["/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/DailyRebateLogic.php"]}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"是的,之前两次都中断了,**这次已经改完并校验通过**。\n\n## 主要改动\n\n1. **常量**`RECORD_LOOKBACK_DAYS`、`SETTLE_LOG_SAMPLE_LIMIT`、结算时间偏移、日志 tag \n2. **`claim()`**:短事务只做 CAS → 事务外调钱包;失败 `revertRebateRecordToClaimable`;成功/失败打日志 \n3. **`info()`**:抽出 `buildClaimableYesterday`、`buildInfoViewContext``@return` 写清结构 \n4. **`settleDate()`**:拆成 `settleOneUserFromProfitHash`、`decodeProfitPayload`、`upsertClaimableRebateRecord` 等,主方法只负责编排 \n5. **`buildRecordRow()`**7 个参数收成 `date + viewContext` \n6. **命名**`$model` → `$rebateRecord``$out` → `$tierDisplayRows` \n7. **`syncTodayPending`**`userEntity` 判空 \n8. **`readTodayBetFromRedis`**:异常记 `daily_rebate_redis` 日志,不再静默吞掉 \n9. **`buildActivityMeta`**:删除未使用的 `ext_config` 解析 \n\n## 校验\n\n```\n=== verify-slot-backend ===\nchanged: 2 file(s), 1 php\nPASS\n```\n\nPHPDoc: checked\n\n若要再收紧可以把 `formatEmptyRecord` 里的 `'No Bets'` 也统一到 `statusLabel` 体系(目前 C 端可能依赖这个文案,暂未改)。\n\n[REDACTED]"}]}}