Files
group_xinghuo_jinrong/app/utils/db.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
2026-09-10 14:45:55 +08:00

95 lines
3.9 KiB
Python
Raw 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.
"""SQLAlchemy 引擎工厂(B7 · 开发计划挂账⑥)。
按 (库名, 角色) 缓存单例 Engine:deps / risk / simulate 路由每请求实例化
Repository 时复用同一连接池,不再每次 create_engine(B6 复审 P3:实例化点
泄漏);应用 shutdown 经 dispose_engines 统一释放(B7 评审 P1-1:显式
Engine.dispose(),不依赖 GC 兜底)。测试直传 engine= 参数的用法不受影响;
monkeypatch settings 后须先 dispose_engines() 清缓存。
角色(D20 · 架构设计-基金转换交易 §11.1):ro / rw / admin 三档,
由 `get_engine` 的 `role` 参数选择账号;账号未配置时回退 mysql_user。
"""
from __future__ import annotations
import threading
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from app.config.settings import settings
_engines: dict[str, Engine] = {}
_engines_lock = threading.Lock()
_VALID_ROLES = ("ro", "rw", "admin")
def _resolve_credentials(database: str, role: str) -> tuple[str, str]:
"""解析该 (库, 角色) 应使用的 (user, password)。
- `admin`:恒用 `mysql_user`(DDL / 测试 teardown 等需 DELETE 的场景)
- `ro` / `rw` + core 库:分别用 `mysql_core_ro_*` / `mysql_core_rw_*`
- `ro` / `rw` + agent 库:统一用 `mysql_agent_*`(agent 库无独立只读账号)
- 对应账号为空串 → 回退 `mysql_user`(渐进启用,不阻塞本地开发)
"""
if role == "admin":
return settings.mysql_user, settings.mysql_password
if role not in _VALID_ROLES:
raise ValueError(f"unknown db role: {role!r}(应为 ro / rw / admin)")
if database == settings.mysql_core_database:
if role == "ro":
user, password = settings.mysql_core_ro_user, settings.mysql_core_ro_password
else:
user, password = settings.mysql_core_rw_user, settings.mysql_core_rw_password
else:
user, password = settings.mysql_agent_user, settings.mysql_agent_password
if not user:
return settings.mysql_user, settings.mysql_password
return user, password
def get_engine(database: str, role: str = "rw") -> Engine:
"""取指定 (库名, 角色) 的单例 Engine(进程内共享连接池)。
⚠️ 调用点必须显式声明角色(F-11 清单共 13 处),不要依赖默认值表达意图:
- `role="ro"`:只读账号 → `core_ro` / `core_tools` / deps 归属校验 /
risk 扫描 / **gateway 的校验读**
- `role="rw"`:读写账号 → `gateway_repository`(core 写)、
`risk_repository` / `session_repository`(agent 读写)
- `role="admin"`:`mysql_user` → 建库/DDL、`tests/conftest.py` teardown(需 DELETE)
账号未配置时回退 `mysql_user`,与单账号时代行为一致。
"""
if role not in _VALID_ROLES:
raise ValueError(f"unknown db role: {role!r}(应为 ro / rw / admin)")
cache_key = f"{database}:{role}"
with _engines_lock:
engine = _engines.get(cache_key)
if engine is None:
user, password = _resolve_credentials(database, role)
auth = f"{user}:{password}" if password else user
url = (
f"mysql+pymysql://{auth}@{settings.mysql_host}:{settings.mysql_port}"
f"/{database}?charset=utf8mb4"
)
engine = create_engine(url, pool_pre_ping=True)
_engines[cache_key] = engine
return engine
def dispose_engines() -> None:
"""释放全部缓存 Engine(lifespan shutdown / 测试隔离)。
逐个 Engine.dispose() 关闭连接池空闲连接后清空缓存;调用方仍持有的
checked-out 连接不受影响,归还时由旧池关闭。dispose 后再 get_engine
按当前 settings 重建(测试 monkeypatch 配置依赖此语义)。
"""
with _engines_lock:
for engine in _engines.values():
engine.dispose()
_engines.clear()