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
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
"""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()
|