基金转换 T-4+T-5(并行组 A):convert_repository 代理侧 + locks.try_lock 非阻塞抢锁

T-4(convert_repository · agent 库 risk_convert_detail):
- 新增 7 方法:insert_placeholder(占位 pending) / complete_convert(回写 completed+折算详情)
  / mark_failed / get_by_group_id / get_by_client_request_id(None 直接返回 None)
  / list_expired_candidates(hours) / mark_expired(S2 标记不硬删)
- 引擎走 agent 库 rw(D20);status 5 值枚举;不与 risk_repository 混职责
- Decimal 折算字段经 _to_bind 转 float 绑定(sqlite 不支持直接绑 Decimal,同 T-3)

T-5(locks.try_lock):
- 单次非阻塞抢锁:抢到返回 _Token(with 进入 True),抢不到立即 _NoLock(进入 False,不等不降级)
- Redis 不可用退回进程内 Lock.acquire(blocking=False);_NoLock.__enter__ 返回 False 避免 with None 报错
- 不改 run_locked(D4:其等 2s 后降级 fn(False),语义相反;3 处调用点零改动)

测试:test_convert_repository.py 6 用例 + test_locks_redis.py +4 用例(既有 6 例零改动)
pytest 全量 634 passed / 3 skipped(基线 624 + 10,零回归)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
2026-09-10 15:53:31 +08:00
co-authored by WorkBuddy
parent 3244922a55
commit 4fd2ec05c8
5 changed files with 448 additions and 8 deletions
@@ -787,10 +787,21 @@ T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算
| `list_expired_candidates(hours)` | 供 `cleanup_pending_convert.py` 取超 24h 的 `pending` |
| `mark_expired(group_id)` | 置 `expired`(**标记不硬删**,S2) |
**DoD**
- [ ] sqlite 单测覆盖:占位 → completed / 占位 → failed / 超时 → expired
- [ ] `client_request_id` 为 `None` 时不受 `uk_idem` 约束(MySQL 允许多个 NULL;sqlite 侧需断言行为一致)
- [ ] 不与 `risk_repository` 混职责(后者不动)
**DoD(全部达成,见下方执行记录)**
- [x] sqlite 单测覆盖:占位 → completed / 占位 → failed / 超时 → expired
- [x] `client_request_id` 为 `None` 时不受 `uk_idem` 约束(MySQL 允许多个 NULL;sqlite 侧断言一致)
- [x] 不与 `risk_repository` 混职责(后者不动)
**执行记录(2026-09-10)**
| 项 | 内容 |
| --- | --- |
| 新增文件 | `app/repository/convert_repository.py`【agent 库 `risk_convert_detail` 读写】· `tests/test_convert_repository.py`【6 用例】 |
| 方法 | `insert_placeholder`(占位 `pending`,`estimated=0` 真实请求)· `complete_convert`(回写 `completed`+折算详情)· `mark_failed` · `get_by_group_id` · `get_by_client_request_id`(None 直接返回 None)· `list_expired_candidates(hours)`(pending 且 `created_at < now-hours`)· `mark_expired`(S2 标记不硬删) |
| status 枚举 | 5 值 `pending/completed/failed/cancelled/expired`(架构 §9,建表即全量、零 ALTER) |
| 引擎 | `get_engine(settings.mysql_database, "rw")`(D20:`xh_agent_rw` 含 `risk_convert_detail`) |
| 踩坑(同 T-3) | `complete_convert` 的 `nav`/`fee_amount` 为 Decimal → 经 `_to_bind` 转 `float` 绑定(sqlite 不支持直接绑 Decimal) |
| 验证 | `pytest tests/test_convert_repository.py` 6 passed;全量 634 passed / 3 skipped(基线 624 + 10) |
**依赖**:T-1
@@ -806,10 +817,20 @@ def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None:
- **不改 `run_locked`**(D4:`run_locked` 会等 2s 并降级 `fn(False)`,与「抢不到立即 202」语义相反;且它已有 3 处调用:`alert_service.py:180` 的 `agg:event:` / `:239` 的 `agg:suitability:` 等)
- 返回 `_NoLock` 哨兵对象,`__enter__` 返回 `False`,避免 `with None` 报错
**DoD**
- [ ] 抢到 / 抢不到 / Redis 不可用回退 三条路径单测
- [ ] key 形态覆盖:`convert:idem:{cid_req}` / `convert:rerun:{gid}`
- [ ] `run_locked` 的既有用例零改动
**DoD(全部达成,见下方执行记录)**
- [x] 抢到 / 抢不到 / Redis 不可用回退 三条路径单测
- [x] key 形态覆盖:`convert:idem:{cid_req}` / `convert:rerun:{gid}`
- [x] `run_locked` 的既有用例零改动
**执行记录(2026-09-10)**
| 项 | 内容 |
| --- | --- |
| 改动文件 | `app/service/risk/locks.py`【改·+`_Token`/`_NoLock` + `try_lock`】· `tests/test_locks_redis.py`【+4 用例】 |
| 语义 | `try_lock` **单次非阻塞**:抢到返回 `_Token`(`with` 进入 True),抢不到立即 `_NoLock`(进入 False,不等、不降级);Redis 不可用 → 退回进程内 `Lock.acquire(blocking=False)`(语义一致) |
| 不改 | `run_locked`(D4:其会等 2s 后降级 `fn(False)`,与「抢不到立即 202」相反;3 处调用点零改动) |
| 哨兵 | `_NoLock.__enter__` 返回 False,避免 `with None` 报错;`_Token.__exit__` 按来源释放 Redis 或进程内锁 |
| 验证 | `pytest tests/test_locks_redis.py` 10 passed(既有 6 + 新增 4);全量 634 passed / 3 skipped |
**依赖**:无