Files
group_fqcd_jr/docs/演示用/代码库全面审查报告-2026-09-14.md
T
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00

39 KiB
Raw Blame History

代码库全面审查报告

审查日期:2026-09-14 审查范围:app/ 下 251 个 Python 文件(661 个 .py 含测试与工具),重点覆盖认证鉴权、并发与资源、金额与边界、异常与依赖四大横切面 审查方式:4 路并行深度审计(安全 / 并发 / 逻辑边界 / 异常与依赖)+ 关键结论逐条人工复核 重要声明:本次审查只读分析,未修改任何代码。


〇、如何读这份报告

0.1 严重度定义

级别 含义
P0 资金错账、数据损坏、重复处理,或无需凭证即可被大规模利用。上线前必须修。
P1 高概率的功能错误、事件循环阻塞、连接/资源泄漏、合规门禁失效。应尽快修。
P2 中等风险:边界瑕疵、降级语义混乱、结构性隐患、维护陷阱。排期修。
P3 低风险 / 加固建议 / 代码卫生。可顺手改。

0.2 ⚠️ 关于"确定性缺陷"与"疑点"的区分

本报告已按你的要求区分两者:

  • 【确定】:我已亲自读源码逐行确认,给出了真实行号。
  • 【疑点】:逻辑上可疑,但需要业务口径确认、或在当前数据/部署条件下无法触发。我明确标注了为什么无法确认。

0.3 一条方法学提醒

审查由 4 路子代理并行完成,但我没有直接采信它们的结论。逐条复核中,我推翻了 1 条、修正了 2 条的严重度(见 §5)。凡涉及计数与"是否可达"的判断,均以我本人的直接阅读为准。


一、P0 级(上线前必须修)

P0-1 场内下单接口完全没有幂等保护 —— 重复下单 = 重复扣款 + 重复建仓

【确定】

  • 位置:app/api/controllers/trading.py:61-69(T002)、app/service/trade_service.py:288-455
  • 类别:幂等 / 资金安全

证据:

# app/api/controllers/trading.py:61-69
@router.post("/orders", status_code=status.HTTP_201_CREATED)
async def submit_order(
    payload: OrderCreateRequest,
    context: RequestContext = Depends(build_request_context),
    session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
    await _authorize(context, "trade:order:create")
    data = await _service(session, context).submit_order(payload, context)
    return envelope(data, context)

核对过程(我做了什么来确认它真的没有兜底):

  1. 读 trading.py 全文 —— 9 个端点均无 Idempotency-Key 头参数。
  2. 读 app/api/middleware.py 全文(33 行)—— 只有一个 attach_trace_id 中间件,没有全局幂等层。
  3. 对比 risk.py / admin.py / promotion_material.py 等写接口 —— 它们都走 ApiTransactionService.execute* 并写 api_request_receipt。T002 是唯一的例外。

触发场景: 用户点"买入 500 份",网关超时,客户端按标准重试策略重发。两次请求各自生成新的 order_no(uuid4,trade_service.py:341),各自执行 account.available_cash -= net_amount(:407)、_upsert_holding 再加一次仓(:412)。同一笔意愿成交两次。

建议修复方向: 接入已有的 ApiTransactionService.execute_in(session, context, scope="trade:order:create", key=<Idempotency-Key>, body=payload, action=...);或至少给 fin_sim_order 加客户端可传的幂等键唯一约束。


P0-2 _next_id() 用 SELECT MAX(id)+1 发主键,并发下必然主键冲突

【确定】

  • 位置:app/service/trade_service.py:111-123
  • 类别:并发 / 数据损坏
  • 调用点(4 处,均为金融主表)::344(FundSimOrder)、:371(FundTransaction)、:430(FundCashLedger)、:471(FundHolding)

证据:

# app/service/trade_service.py:111-123
async def _next_id(self, model: Any) -> int:
    """返回 ``model`` 表的下一个可用主键。
    ...底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿
    存在偏差),但 AGENTS.md 禁止修改既有列类型/可空性/含义。...
    并发与单测场景下够用;后续若需要严格序数,再独立 PR 引入发号器。
    """
    result = await self._session.execute(select(func.max(model.id)))
    max_id = result.scalar()
    return int(max_id or 0) + 1

我如何确认"未配 AUTO_INCREMENT"是真的(这决定了本项是否成立): 读 alembic/baseline_generated.sql:270-271:

CREATE TABLE `fin_sim_order` (
  `id` BIGINT UNSIGNED PRIMARY KEY,     -- ← 无 AUTO_INCREMENT,确认

fin_transaction(:310)同形。

触发场景:两个不同客户在同一事务窗口内各自调用 _next_id(FundTransaction),都读到 MAX(id)=100,都返回 101。第二次 flush() 触发主键冲突 → 事务回滚。表现是"第二个客户下单直接 500"。submit_order 一次要发 3 个 id(订单/成交/流水),冲突面更大。

建议修复方向:优先改 AUTO_INCREMENT(需迁移,但这是设计稿原意);若坚持不改基线,需引入独立发号器(INSERT ... ON DUPLICATE KEY 重试循环,或 Redis INCR)。


P0-3 submit_order 读账户/持仓未加锁 —— 并发下单可突破持仓上限、可把可用份额扣成负数

【确定】

  • 位置:app/service/trade_service.py:296-328(校验)、:407-425(扣减)、:457-525(持仓变更)
  • 类别:并发 / 边界

证据:

# app/service/trade_service.py:294-298
product = await self._load_tradable_product(payload.product_code)
await self._check_suitability(customer_id, product, context)
quote = await self._fetch_quote(product)
account = await self._load_account(customer_id)          # ← 无 with_for_update
holding = await self._load_holding(customer_id, product.id)  # ← 无 with_for_update

核对过程:Grep with_for_update 在 整个 trade_service.py 中零命中。而项目其它地方(如 offsite_fund_service.confirm_document)是用了行锁的,说明团队知道这个模式。

触发场景(两类):

  1. 突破持仓上限:客户已接近上限,同时提交两笔买入。两个请求都读到旧的 holding.total_quantity,都判"未超限",都通过 → 最终突破 single_investor_max_holding_ratio。
  2. 超卖:持仓 1000 份,同时两笔各卖 1000 份。两笔都读到 available_quantity=1000,都通过检查 → 最终 available_quantity 变负。

建议修复方向:对 fin_sim_account 与 fin_holding 行加 SELECT ... FOR UPDATE(按 customer_id / customer_id+product_id,注意加锁顺序避免死锁),在锁内完成"校验 + 扣减"。


P0-4 访客令牌端点零认证、零限流 —— 可无限量铸造身份并免费消耗模型额度

【确定】

  • 位置:app/api/controllers/visitor_tokens.py:1-14
  • 类别:认证 / 资源滥用

证据(全文 14 行,APIRouter 无任何 dependencies):

router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"])

@router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED)
async def issue_visitor_token() -> VisitorTokenResponse:
    settings = get_settings()
    token, _expires_at = VisitorTokenIssuer(settings).issue()
    return VisitorTokenResponse(access_token=token, expires_in=settings.visitor_token_ttl_seconds)

对照:app/api/controllers/auth.py:26-28 给登录端点专门挂了 enforce_login_rate_limit,并在注释里写明"这是全平台最需要限流的端点(密码爆破的入口)"。访客令牌端点没有同等保护。

触发场景: 攻击者脚本循环 POST /api/v1/visitor-tokens 毫秒级铸造海量有效 JWT。每个都能过 build_request_context,进而调 /api/v1/agent-runs 触发 LLM 调用。而 /api/v1/agent-runs 的限流是按 user_id 计的,访客 sub 每次都是新随机值 —— 限流被天然绕过。结果:免费刷模型额度 + 灌爆 agent_run / conversation 表。

建议修复方向:给该端点加 IP 维度限流;限流键在访客场景叠加 IP;考虑对访客身份做 IP/指纹绑定,而非纯随机 sub。


二、P1 级

P1-1 check_compliance() 是死代码,且它一旦被调用会静默关闭 F5 免责声明门禁

【确定】 —— 这是本次审查最隐蔽的一处,因为它不报错、不失败测试。

  • 位置:app/service/agent/base.py:162-165
  • 类别:死代码 / 合规门禁

证据:

# app/service/agent/base.py:162-165
async def check_compliance(self, result: AgentResult, context: RequestContext) -> AgentResult:
    if self._governance is None or self.config is None:
        raise RecoverableAgentError("缺少合规治理依赖")
    return await self._governance.review(result, context, self.config, self.memories)
    #                                                                 ^^^^^^^^^^^^
    #                                              注意:没有传 agent_type

核对过程(三步):

  1. 全仓 Grep check_compliance → 仅 4 类命中:base.py:89(禁止覆写名单)、base.py:162(定义)、tests/unit/service/test_agent_governance.py:20(断言不可覆写)、tests/unit/service/test_customer_service_agent.py:170(清单)。零个生产调用点。
  2. 真实链路:base.py:114-116 的 execute() 直接调 governance.review(..., agent_type=self.definition.agent_type) —— 绕过了 check_compliance。
  3. 确认"不传 agent_type"的后果:读 governance.py:219 与 :281:
    customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES   # 空串 → False
    ...
    if customer_facing and not content.text.endswith(appended_shape):
        content = content.model_copy(update={"text": f"{content.text}{appended_shape}"})
    
    空 agent_type → customer_facing=False → 免责声明不追加。 即:若有人按方法名的语义调用 check_compliance(),客服回复的 F5 门禁(面向客户输出 100% 附固定话术)会静默失效。

⚠️ 附带发现(文档与代码矛盾):governance.py:211-212 的 docstring 写的是

"空串按'调用方未声明'处理,保守照旧追加,避免漏加"

而代码(:219 + :281)的实际行为是空串不追加。docstring 与代码相反,这正好会误导维护者认为"忘记传 agent_type 是安全的"。

背景佐证:docs/06-底座代码测试报告.md:307 把"接线 check_compliance"列为 P1 —— 至今仍未接线(该文档整体已属 D 类历史文档,但这一条是真实的未完成项)。

建议修复方向:二选一——① 删除该方法(并从 :88-92 的禁止覆写名单移除);② 改为 governance.review(..., agent_type=self.definition.agent_type) 并补契约测试。同时修正 governance.py:211-212 的 docstring 使其与代码一致。


P1-2 异步路径中直接调用同步 Milvus 客户端,阻塞整个事件循环

【确定】

  • 位置(4 处):
    1. app/service/memory_recall_service.py:175(async def _vector 内)
    2. app/service/knowledge_search_service.py:207-213, 224, 229
    3. app/core/knowledge_schema.py:205
    4. app/service/projection_cleanup_service.py:115, 123, 127
  • 类别:异步阻塞

证据:

# app/service/memory_recall_service.py:153 / 175
async def _vector(self, ...):
    ...
    found = self.vector.search(embedding, limit=limit)   # ← 同步阻塞网络 I/O

调用的是同步 MilvusClient(app/infrastructure/vector_memory.py:34 def search(...),非 async def)。

对照:项目在其它 10+ 处都用了 asyncio.to_thread(如 app/infrastructure/fund_market_adapter.py:273,382、app/service/offsite_fund_service.py:1542),说明规范已建立 —— 这 4 处是遗漏。

触发场景:Milvus 网络抖动导致单次 search 耗时 3 秒。此时同一进程内所有并发请求、所有其它协程全部停摆 3 秒(含 worker 心跳 —— 可能触发 lease 误判)。客服的 search_knowledge 工具超时是 10s,期间整个 FastAPI 进程无响应。

建议修复方向:优先改用已有的异步客户端 app/infrastructure/milvus_adapter.py(内部 AsyncMilvusClient,await client.search(...));最小改动方案是全部包进 await asyncio.to_thread(...) 并外层加 asyncio.timeout。

附带发现:app/service/knowledge_retrieval_service.py 用的正是正确的 await self.client.search(...),但全仓无生产实例化点(只有单测引用)。它可能就是为此准备的替代实现,却从未接线。


P1-3 Milvus 客户端从不关闭:lru_cache 单例永生 + 局部变量泄漏

【确定】

  • 位置:app/service/agent/bootstrap.py:117-129、:194-210、app/service/projection_cleanup_service.py:115-127
  • 类别:资源泄漏

证据:

# app/service/agent/bootstrap.py:117-129
@lru_cache(maxsize=1)
def get_vector_memory_adapter():
    ...
    client = MilvusClient(uri=..., token=...)   # 进程级永生对象
    return VectorMemoryAdapter(client, settings.milvus_collection)
# app/service/projection_cleanup_service.py:115-127
client = MilvusClient(uri=..., token=...)   # 局部变量,每次新建
...
client.list_collections()
...
client.delete(...)                          # 从未 close()

核对过程:Grep lifespan / dispose / close_singletons 在 app/main.py 无命中 —— 即无 shutdown 钩子。MilvusKnowledgeClient.close() / aclose() 有定义但全仓无调用点。

触发场景:批量记忆清理触发 _cleanup_vector 100 次 → 100 个未关闭的 MilvusClient 与底层 gRPC 通道泄漏。

建议修复方向:在 app/main.py 加 FastAPI lifespan,shutdown 时关闭单例;_cleanup_vector 改 try/finally: client.close() 或复用单例。


P1-4 依赖声明双源漂移:requirements.txt 与 pyproject.toml 不一致

【确定】

  • 位置:requirements.txt:23 vs pyproject.toml:10-39
  • 类别:依赖一致性

证据:

  • requirements.txt:1 自称 "Runtime dependencies (source of truth: pyproject.toml)"。
  • openai>=1.0,<2(requirements.txt:23)在 pyproject.toml 中完全不存在。
  • aiosqlite 在 pyproject.toml 中重复声明两次(:45 与 :51)。
  • requirements.txt:54-59 把 dev 依赖(pytest/ruff/mypy/aiosqlite)混进了运行时文件。

核对过程:Grep "import openai" / from openai / import_module("openai") 在全仓零命中 —— 即 openai 是声明但从未使用的重依赖。

影响:用 requirements.txt 装的环境会多一个无人使用的 openai;两文件将持续漂移。

建议修复方向:由 pip-compile / uv export 从 pyproject.toml 生成 requirements.txt;至少先删 openai、去重 aiosqlite。


P1-5 卖出时手续费可能超过成交金额,账户余额反被扣减

【确定】

  • 位置:app/service/trade_service.py:280-284(_compute_fee)、:416-425(卖出分支)
  • 类别:金额 / 边界

证据:

# app/service/trade_service.py:280-284
def _compute_fee(self, gross: Decimal, rule: _FeeRule) -> Decimal:
    fee = gross * rule.fee_rate + rule.fixed_fee
    if fee < rule.minimum_fee:
        fee = rule.minimum_fee          # ← 最低手续费无上界保护
    return fee.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)

# app/service/trade_service.py:417-422(卖出分支)
account.cash_balance = (account.cash_balance + net_amount).quantize(...)   # ← net_amount 可能为负
account.available_cash = (account.available_cash + net_amount).quantize(...)

net_amount = gross_amount - fee_amount(:329-332),全程没有 fee >= gross 或 net_amount < 0 的拒绝逻辑。

触发场景:卖出 100 份 × 0.01 元 = gross = 1.00;若命中最低手续费 5.00,则 net_amount = 1.00 − 5.00 = −4.00 → 卖出反而从账户扣 4 元,且无任何提示。

建议修复方向:卖出时若 fee_amount >= gross_amount 应拒绝或明确提示;产品侧应校验"最低卖出金额"。


P1-6 _distribution 用 str(key) 排序却用原 key 索引 —— 非字符串键会 KeyError

【确定】

  • 位置:app/service/risk_daily_report_service.py:356-370
  • 类别:类型 / 空值

证据:

# app/service/risk_daily_report_service.py:361-369
counts = Counter(item.get(field) for item in items if item.get(field) is not None)
keys = [key for key in preferred_order if key in counts]
keys.extend(sorted(str(key) for key in counts if key not in preferred_order))  # ← 转成了 str
return [
    {
        "name": f"{key}风险" if field == "risk_level" else key,
        "count": counts[key],       # ← 却用原 key 索引
    }
    for key in keys
]

触发场景:若 risk_level 被存为整数 1(不在 preferred_order 的 ("高","中","低") 中),keys 追加 "1",随后 counts["1"] → KeyError(Counter 里的键是 int 1)。

建议修复方向:keys.extend(sorted((k for k in counts if k not in preferred_order), key=str)) —— 保留原 key,只在排序时取 str()。


P1-7 NL2SQL 结果无截断标记,命中 LIMIT 上限时无法区分"全量"与"被截断"

【确定】

  • 位置:app/service/financial_nl2sql_service.py:329、:261-265
  • 类别:截断 / 统计正确性

证据:

# app/service/financial_nl2sql_service.py:329
return f"{sql} LIMIT {plan.limit}", params
...
result["data"] = {"total": len(rows), "rows": rows}    # ← 无 truncated 标志

触发场景:limit 默认 50。查询恰好返回 50 行时,调用方无法判断是被 LIMIT 50 截断还是真的只有 50 行。风控/投顾据此统计(如"共 50 笔大额交易")会低估。

对照:risk_repository._capped 已用 limit + 1 正确实现了截断探测 —— 这里缺同一手法。

建议修复方向:取 limit + 1 行,多出即置 truncated=True 并写入 data。


P1-8 data_scope 取"所有权限中的最高范围",与逐权限口径并存导致横向越权隐患

【疑点】 —— 需确认 RBAC 种子数据的实际 scope 组合后才能定性。

  • 位置:app/repository/identity_repository.py:33-55
  • 类别:RBAC / 数据范围

证据:

data_scope = max(scopes.values(), key=lambda value: rank[value]) if scopes else "self"

问题:用户的 data_scope 是其所有权限中最高的一条,而不是"本次操作对应权限的 scope"。项目内两种口径并存:

  • 正确样板:public_platform_service.py:276 用 context.permission_scopes.get(permission)(逐权限)。
  • 隐患口径:risk_evidence_archive_service._scope_condition、financial_nl2sql_service.py:419 用全局 data_scope。

为什么标为疑点:能否真正越权,取决于 sys_role_permission 种子里各角色的 scope 组合。若某角色同时持有 memory:read:self(scope=self)与 risk:alert:read(scope=all),其全局 data_scope 就是 all —— 此时若某接口只看全局 data_scope 而不看具体权限的 scope,就可能"用 A 权限的高 scope 放开 B 权限"。我未逐条比对种子数据(该文件需与 sys_role_permission 表实际内容交叉验证)。

建议修复方向:统一改为按 context.permission_scopes[本次校验的权限码] 判定;或把全局 data_scope 的用途收窄并加注释禁止用于单权限判定。


P1-9 接口 curl/分页参数用裸 int(cursor),绕过 parse_cursor 的契约校验

【确定】

  • 位置:app/api/controllers/trading.py:81、:135、:163
  • 类别:边界 / 契约

证据:

# app/api/controllers/trading.py:81
cursor_id = int(cursor) if cursor else None

触发场景:?cursor=abc → int("abc") 抛 ValueError,未包装为 400 INVALID_CURSOR,很可能冒泡成 500。?cursor=-5 或 ?cursor=0 不报错但查询 id < -5 返回空列表,客户端误以为"翻到底"。而 app/core/cursor.py 已提供严格的 parse_cursor(含 ASCII 数字校验、拒绝 bool)。

建议修复方向:统一改用 parse_cursor(cursor, field="cursor")。


P1-10 场外规则:total_fund_shares 只拦 == 0 不拦负数,同一脏数据在两条规则上给出相反结论

【确定】

  • 位置:app/service/offsite_fund_rules.py:83-84、:91、:95、:107、:111
  • 类别:除零 / 边界

证据:

# app/service/offsite_fund_rules.py:83-91
if (amount_yuan is None or nav is None or nav <= 0
        or total_fund_shares is None or total_fund_shares == 0):   # ← 只拦 0
    return decisions + [ ... _unknown ... ]
current_shares = amount_yuan / nav
ratio = (before + current_shares) / total_fund_shares
# :95 / :99 / :107 / :111
result="异常" if ratio > TWENTY_PERCENT else "正常",          # ratio 为负 → 恒"正常"
limit = total_fund_shares * TEN_PERCENT                       # limit 为负
result="异常" if current_shares > limit else "正常",           # 恒"异常"

触发场景:若查询返回 total_fund_shares = -1000(脏数据/符号错误):

  • 持有比例规则:ratio 为负 → ratio > 0.20 为 False → 判 "正常"
  • 单笔份额上限规则:limit 为负 → current_shares > limit 为 True → 判 "异常"

同一脏数据在两条规则上结论相反,且都不报"数据异常"。

建议修复方向:total_fund_shares <= 0 统一改为"无法判断"。


三、P2 级(摘要)

# 位置 问题 类别
P2-1 app/service/agent/base.py:145-149 classify_intent 在依赖缺失时静默返回 None,与"该 Agent 不需要分类"无法区分 → 装配漏注会让意图分流全部失效而不报错 静默降级
P2-2 app/core/errors.py:74-165 错误码有意复用(SESSION_NOT_FOUND × 4、RUN_NOT_CANCELLABLE × 4、IDEMPOTENCY_CONFLICT × 2)。前端无法区分"用户非法操作"与"Worker 租约丢失" 错误分类学
P2-3 app/service/offsite_fund_service.py:123 God Class:约 2,820 行、60+ 方法,邮件/识别/NL2SQL/规则/通知/统计全塞一个类 可维护性
P2-4 app/service/offsite_fund_service.py(17 处) 自造 _permission_error 样板,不走统一的 AuthorizationService.require(后者带统一审计) 重复 / 审计口径
P2-5 app/worker/runtime.py:505-516;app/infrastructure/rate_limiter.py:92-96 每次调用新建 Redis 客户端。RedisCounterBackend._client 的懒加载缓存挂在每次新建的对象上 → 缓存形同虚设,每请求一次握手 资源 / 性能
P2-6 app/infrastructure/db.py:34 create_async_engine 未配置 pool_size/max_overflow/pool_timeout/pool_recycle → 默认 5+10;单次 Agent 运行可有数十次模型+工具调用,叠加并发易打满 连接池
P2-7 app/service/model_gateway.py:103-123, 174-200, 225-246 每次模型调用新建 httpx 客户端并关闭(丢 keep-alive);每次新建 session 资源
P2-8 app/service/model_gateway.py:271-291;app/worker/risk_scan_scheduler.py:117-152 重试无退避、无 jitter → 上游 503 持续 5 秒时毫秒级耗尽全部重试;多副本惊群。(对照:runtime.py:901-904 有指数退避) 重试正确性
P2-9 app/service/risk_daily_report_service.py / app/repository/agent_run_repository.py SELECT 后再 INSERT/UPDATE 无行锁(TOCTOU)。对照:runtime.py:_failure 是加了 with_for_update() 的 竞态
P2-10 app/service/offsite_fund_rules.py:170 "最低申购金额"阈值是硬编码的 <= 1 元,而产品表有 min_amount 列 —— 疑似字段未接线 口径/疑点
P2-11 app/service/risk_judgement_service.py:161-178 距阈值仅差一丝(如 499999.99 / 0.7999)也判 "疑似误报 + 高置信",直接引导专员关闭预警 边界
P2-12 app/service/model_gateway.py:238-239 连续两行 return endpoints,第二行不可达 死代码
P2-13 app/service/offsite_fund_service.py:2374 / promotion_performance.py:133;profile_graph_projection_service.py:267 / offsite_fund_service.py:2643 _json_safe / _text 重复定义在两处 重复
P2-14 app/service/agent/implementations/customer_service.py:148-165 HIGH_SCORE=0.75 / MID_SCORE=0.55 / MIN_GAP=0.07 / TOP_K=5 为调参阈值却硬编码(注释自述"gap 从 0.090 掉到 0.076,几乎跌破 0.07")→ 每次调参要发版 硬编码
P2-15 app/core/customer_service_rules.py:11 docstring 说走 query_knowledge,实际登录客户走 search_knowledge。该类不一致已造成过真实事故(docs/40 记录:发布配置只发 search_knowledge → 访客工具白名单抛 ForbiddenAgentError) 文档与代码矛盾
P2-16 app/api/controllers/visitor_tokens.py + app/api/dependencies/auth.py:26 限流在所有端点 fail-open(rate_limiter.py:87-89)。是有意的产品取舍,但对登录/访客签发这类敏感端点应为 fail-closed 限流语义
P2-17 .workdir/zsy_v2/z_pyproject.toml:24 陈旧副本,把已被明确否决的 milvus-lite 列进运行时依赖(主 pyproject.toml:52-56 注释说明刻意排除,因它正是 MILVUS_LOCAL_URI 坑的来源) 仓库卫生

四、P3 级(摘要)

# 位置 问题
P3-1 app/service/knowledge_publication_service.py:153-155 except Exception: pass 真静默(全仓唯一一处)。向量补偿删除失败无任何日志
P3-2 app/service/profile_assembly_service.py:64-69 _fact_id() 用微秒时间戳做主键(float→int 有精度损失;循环内批量调用同一微秒会碰撞),docstring 断言"不可能发生"过于自信
P3-3 app/service/trade_service.py:200-205 raise ... from None(全仓唯一一处)丢弃根因 —— 恰在交易拒绝路径上,排障时看不到原始 risk_level 值
P3-4 app/service/trade_service.py:341-342 order_no 用秒级时间戳+8 位 hex,无 DB 唯一约束兜底
P3-5 app/service/offsite_fund_service.py:2527-2532 _next_mail_id() 用"当日 count()+1"发号,并发/硬删除下会重复或断号(超 999 后 :03d 格式错位)
P3-6 app/service/trade_service.py:142-143 MAX_QUOTE_AGE 用 > 严格比较(恰好 15:00 放行);source_updated_at 若为未来时间(时钟偏差)差值为负 → 永远放行
P3-7 app/service/risk_natural_language.py:89-96 "今天"的上界是当前时刻而非当日 24:00;time.max(23:59:59.999999)在 MySQL fsp=0 列上可能因舍入归入次日
P3-8 app/worker/runtime.py:794-796 租约续期失败的 logger.warning 缺 exc_info=True,真实原因(DB 抖动?连接池耗尽?)不可见
P3-9 app/api/middleware.py:29 X-Trace-ID 完全由客户端提供并原样回显,无格式校验 → 日志投毒 / 追踪串扰
P3-10 app/core/risk_cursor.py:16-17 游标指纹用无密钥 SHA-256,docstring 已诚实自述"防无意复用、不防篡改"。攻击者可离线重算指纹(服务端仍有 user_id 过滤,故越权受限)
P3-11 app/service/agent/implementations/risk_agent.py:342-373;customer_service.py:189/551 提示注入面:用户文本(含正则提取的 alert_no)直接 f-string 进 system prompt,无分隔符/转义包裹。缓解靠"模型自律 + 输出白名单过滤",非结构化隔离。属提升面(未发现可直接越权的链路,因工具权限由 ToolExecutor 独立校验、不信任模型)
P3-12 app/service/agent/implementations/customer_service.py:464-465 _product_risk_level 把"工具异常""向量库降级""知识库确实无此条"三种情况统一压成 None,用户看到同一句话术,故障与无数据无法分辨。(同文件另两处 :300/:408 是正确降级)
P3-13 app/worker/graph_projection_worker.py processed_event_ids 是无界内存 set,长期运行会持续增长
P3-14 app/worker/runtime.py:77, 102-107 装配关键路径用 _UNSET: Any + 多个 Any 参数绕过 mypy strict —— 传错对象不会有类型报错,只会在运行时静默降级
P3-15 app/service/trade_service.py:568-572, 649-653 scalar_one() 在产品主数据被删时抛 NoResultFound → 500(历史订单应仍可查)
P3-16 app/core/fund_contracts.py:13-43 nav 等金额字段无 gt=0 / allow_inf_nan=False 约束
P3-17 全项目(trade_service.py:142,176,251,292 等) datetime.now(UTC).replace(tzinfo=None) 手工重复,而 app/core/timeutil.py 已提供 to_utc_naive —— 未统一,时区 bug 有重演空间
P3-18 alembic/ 迁移图为单一 head + merge revision 齐全(干净)—— 但无 CI 断言"恰好 1 个 head",靠人工维护

五、我复核后修正 / 推翻的子代理结论

这一节是本报告的诚信部分 —— 说明我没有直接采信子代理。

5.1 推翻:worker "双重领取 run"(子代理标为 P0)

子代理报告:runtime.py:542-547 的候选 SELECT 无行锁 → 两 worker 可同时领取同一 run → P0 数据损坏。

我的复核(读 runtime.py:733-780):

run = await session.scalar(select(AgentRun).where(
    AgentRun.run_id == run_id).with_for_update())     # ← 重新加行锁
...
claimed = await AgentRunRepository(session).claim(
    run_id, worker_id, self.settings.worker_lease_seconds)
if not claimed:
    return False                                      # ← 竞争失败者正确退出

execute() 在第二个事务内重新加锁并调 claim(),claim() 返回布尔值。落后方会正确返回 False 并退出,不会重复执行。

结论:这是 P3 效率问题(落后方白等一次锁),不是 P0 正确性缺陷。子代理的定级被高估。

5.2 修正:check_compliance 的失效方向(子代理结论正确,但我的核实更有力)

子代理说"空 agent_type → 不注入话术"。我进一步发现:governance.py:211-212 的 docstring 明确写"空串…保守照旧追加",而代码(:219 + :281)实际是空串不追加。docstring 与代码相反 —— 这使该缺陷比子代理描述的更危险(维护者会被文档误导)。

5.3 修正:errors.py 错误码复用不是"重复码的低级错误"

子代理描述为"一个码承载 4 种含义"。我核实 errors.py 顶部与 tests/unit/core/test_errors.py 后确认:这是为对齐 docs/05 §3.6 主表的刻意设计,并有 AST 测试防基类被实例化。真实代价是"业务语义丢失"(P2),而非实现缺陷。


六、明确判定为"干净"的部分(正向确认)

为避免"只列问题"的偏颇,以下经逐行核对确认无缺陷,可作为团队基线:

组件 位置 确认点
JWT 验证 app/core/security.py algorithms=[单一算法] 锁定;options={"require":["sub","iss","aud","exp","nbf","jti"]} 强制声明;iss/aud/leeway 全查;sub 有 ASCII+十进制+长度+上界校验。无 alg=none、无算法混淆、无未签名接受
HMAC 引用令牌 app/service/knowledge_service.py 密钥仅来自环境、缺失失败关闭;hmac.compare_digest 常数时间;前 4 段全被签名覆盖;token_user != context.user_id 拒绝跨用户;SQL 全参数化;对外统一 _not_found() 不泄露原因
登录接口 app/api/controllers/auth.py、auth_service.py bcrypt 常数时间 + _DUMMY_HASH 防时序枚举;失败原因不区分;成功失败均审计且不记录密码;失败审计独立 commit 不被回滚
路径穿越防护 app/infrastructure/document_storage.py:58-66 _resolve 显式拒绝空 key、反斜杠、盘符、绝对路径、..、保留前缀
证据附件归档 app/service/risk_evidence_archive_service.py:155-214 取 basename + 扩展名白名单 + 魔数校验 + _safe_root 用 relative_to(project_root) 拒绝写出项目外
SQL 生成 app/service/financial_nl2sql_service.py plan 来自纯代码 RuleBasedFinancialPlanner(不调 LLM);表/列过 TABLE_COLUMNS 白名单;operator 枚举校验;值走 :param 绑定。当前不可注入(LIMIT 插值仅由 Pydantic ge=1,le=200 兜住 —— 见 P2 加固建议)
Cypher 生成 app/service/relationship_service.py:18-35、graph_model.py:39-48 关系名过 ALLOWED_RELATIONSHIPS(8 个固定值)才拼接;节点标签/属性名来自 NODE_SPECS 常量。当前不可注入
Outbox 领取 app/repository/outbox_repository.py、app/infrastructure/db.py:42-68 FOR UPDATE SKIP LOCKED + worker_id fencing token + 跨进程 GET_LOCK/RELEASE_LOCK 配对正确
Worker 健壮性 app/worker/__main__.py:48-66、outbox_worker.py:107-120、runtime.py:518-549 单轮异常不杀循环;单 handler 失败转 dead/failed + 指数退避;心跳续租失败正确 cancel() 子任务
游标语义 app/core/cursor.py、app/core/risk_cursor.py limit+1 探测 + 绑定指纹 + 严格 ASCII 数字校验 + 拒绝 bool。分页无 off-by-one
金额精度 app/service/trade_service.py 全程 Decimal + ROUND_HALF_UP,无 float 混用
启动安全 app/core/config.py、.gitignore .gitignore 正确覆盖 config/jwt/ 与 .env;无硬编码密钥命中;graph.py:53 在 uri/密码缺失时整体禁用图能力(正确失败关闭)
生产代码卫生 全仓 app/ 下零 print、零 TODO/FIXME/HACK 注释;except Exception 约 105 处,仅 1 处真静默(P3-1),其余均带日志或明确降级语义
迁移图 alembic/versions/*.py 单一 head,merge revision 齐全,无同表不兼容改动

七、修复优先级建议

顺序 事项 理由
1 P0-1 幂等、P0-3 行锁 资金错账,且 T002 是全项目唯一的幂等缺口 —— 修法有现成样板
2 P0-2 发号器 并发下必然冲突;AUTO_INCREMENT 是设计稿原意
3 P0-4 访客限流 无凭证即可被大规模刷额度
4 P1-1 check_compliance 最隐蔽 —— 不报错不失败测试,且 docstring 会误导人保留它
5 P1-2 同步 Milvus 阻塞 一处慢查询冻结整个进程,影响面最大
6 P1-4 依赖双源漂移 环境不一致的定时炸弹
7 P1-3 连接泄漏 + P2-6 连接池 一起做,都补 lifespan
8 P1-5 手续费上界、P1-6 KeyError、P1-7 截断标记 单点小改,收益明确
9 P1-8 数据范围口径统一 需先确认种子数据(见下)
10 P2 / P3 按排期收敛 其中 P2-16 与 P3-10 的取舍需业务确认

八、未核实 / 需进一步确认(完整清单)

  1. P1-8 的 RBAC 种子 scope 组合 —— 需读 tools/seed_test_rbac.py 与 sys_role_permission 实际内容,逐角色确认是否出现"高 scope + 低 scope 权限并存"。这决定 P1-8 是否可实际越权。
  2. P2-10 场外最低申购金额口径 —— <= 1 元 是业务要求,还是应读 fin_product.min_amount?产品表有该列但未被规则引擎使用。
  3. P2-16 / P3-10 的产品取舍 —— 限流 fail-open、游标无密钥指纹,两处代码注释都明确自述为"有意取舍"。是否需要加固属产品决策。
  4. check_compliance 是否被仓库外调用 —— 仓库内 0 引用已确认;但 docs/09:124 把它列为"业务代码不得覆盖"的公开 API,删它前需确认是否有外部消费者或历史契约。
  5. .env 历史是否曾泄露密钥 —— .gitignore 已忽略 .env,但需 git log --all -- .env 确认历史。Bash 工具在本环境不可用(见下),未能验证。
  6. 部署拓扑对限流的影响 —— enforce_login_rate_limit 用 request.client.host(rate_limit.py:98),不读 X-Forwarded-For。若部署在网关后且未配信任链,所有登录请求会共享代理 IP(限流有效但粗糙)。需确认部署拓扑。
  7. openai 是否被动态导入 —— Grep 未发现,但若有字符串形式的 import_module("openai") 会漏。
  8. frozen_quantity 是否有写入点 —— 当前无撮合队列故冻结恒为 0;P0-3 的"超卖"路径需确认是否有后台任务写 frozen_quantity。
  9. worker_retry_limit / worker_lease_seconds 实际配置值 —— 若 lease 小于最慢 Agent 耗时,正常执行会被误判 RUN_LEASE_LOST。需查 settings 默认值。
  10. ModelRouterService / KnowledgeRetrievalService 是否为预留实现 —— 两者在生产代码中均无实例化点(仅单测引用),是死代码。且 KnowledgeRetrievalService 用的正是正确的 await 异步 Milvus —— 它可能就是 P1-2 的现成修法,需确认设计意图。
  11. 40+ 个 Alembic 迁移的 upgrade() 体 —— 我只核对了 revision 拓扑(确认单一 head),未逐条阅读是否有两个迁移改同一列/加冲突约束。建议在真实库上跑 tools/foundation_migration_preflight.py + tools/audit_constraints.py。
  12. docs/06 的 P1/P2 欠债清单未逐条核对 —— 我只抽验了"接线 check_compliance"一条(确认仍存在)。完整清单需对照该文档逐项 verify。

九、审查环境说明(影响可靠性)

本次审查有一个必须说明的环境限制:

  • Bash 工具不可用:每次调用返回 exit 127,报 shell-runtime-bash-env.sh: line 3: dirname: command not found。
  • PowerShell 工具返回空 stdout:报 Command completed with exit code 0 但无输出。

因此所有分析均通过 Read / Glob / Grep 静态阅读完成,未执行任何动态验证。这意味着:

  • 能确认的:代码逻辑、字段类型、调用链、是否存在锁、是否传参 —— 这些静态可判。
  • 不能确认的:上表 §8 中所有需要"跑一次"才能定论的项(如并发碰撞概率、_fact_id 实际是否重复、Redis 连接数增长、迁移在真实库的表现)。
  • 另注:trade_service._next_id 与 profile_assembly_service._fact_id 的碰撞概率,理论上成立但未实测。鉴于两者都在资金/画像主路径上,建议按"已成立"处理而非等实测。

本报告只做静态审查,未修改任何代码。