## 新入库(`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 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
39 KiB
代码库全面审查报告
审查日期: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)
核对过程(我做了什么来确认它真的没有兜底):
- 读
trading.py全文 —— 9 个端点均无Idempotency-Key头参数。 - 读
app/api/middleware.py全文(33 行)—— 只有一个attach_trace_id中间件,没有全局幂等层。 - 对比
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)是用了行锁的,说明团队知道这个模式。
触发场景(两类):
- 突破持仓上限:客户已接近上限,同时提交两笔买入。两个请求都读到旧的
holding.total_quantity,都判"未超限",都通过 → 最终突破single_investor_max_holding_ratio。 - 超卖:持仓 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
核对过程(三步):
- 全仓
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(清单)。零个生产调用点。 - 真实链路:
base.py:114-116的execute()直接调governance.review(..., agent_type=self.definition.agent_type)—— 绕过了check_compliance。 - 确认"不传 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 处):
app/service/memory_recall_service.py:175(async def _vector内)app/service/knowledge_search_service.py:207-213, 224, 229app/core/knowledge_schema.py:205app/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:23vspyproject.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 的取舍需业务确认 |
八、未核实 / 需进一步确认(完整清单)
- P1-8 的 RBAC 种子 scope 组合 —— 需读
tools/seed_test_rbac.py与sys_role_permission实际内容,逐角色确认是否出现"高 scope + 低 scope 权限并存"。这决定 P1-8 是否可实际越权。 - P2-10 场外最低申购金额口径 ——
<= 1 元是业务要求,还是应读fin_product.min_amount?产品表有该列但未被规则引擎使用。 - P2-16 / P3-10 的产品取舍 —— 限流 fail-open、游标无密钥指纹,两处代码注释都明确自述为"有意取舍"。是否需要加固属产品决策。
check_compliance是否被仓库外调用 —— 仓库内 0 引用已确认;但docs/09:124把它列为"业务代码不得覆盖"的公开 API,删它前需确认是否有外部消费者或历史契约。.env历史是否曾泄露密钥 ——.gitignore已忽略.env,但需git log --all -- .env确认历史。Bash 工具在本环境不可用(见下),未能验证。- 部署拓扑对限流的影响 ——
enforce_login_rate_limit用request.client.host(rate_limit.py:98),不读X-Forwarded-For。若部署在网关后且未配信任链,所有登录请求会共享代理 IP(限流有效但粗糙)。需确认部署拓扑。 openai是否被动态导入 ——Grep未发现,但若有字符串形式的import_module("openai")会漏。frozen_quantity是否有写入点 —— 当前无撮合队列故冻结恒为 0;P0-3 的"超卖"路径需确认是否有后台任务写frozen_quantity。worker_retry_limit/worker_lease_seconds实际配置值 —— 若 lease 小于最慢 Agent 耗时,正常执行会被误判RUN_LEASE_LOST。需查 settings 默认值。ModelRouterService/KnowledgeRetrievalService是否为预留实现 —— 两者在生产代码中均无实例化点(仅单测引用),是死代码。且KnowledgeRetrievalService用的正是正确的await异步 Milvus —— 它可能就是 P1-2 的现成修法,需确认设计意图。- 40+ 个 Alembic 迁移的
upgrade()体 —— 我只核对了 revision 拓扑(确认单一 head),未逐条阅读是否有两个迁移改同一列/加冲突约束。建议在真实库上跑tools/foundation_migration_preflight.py+tools/audit_constraints.py。 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的碰撞概率,理论上成立但未实测。鉴于两者都在资金/画像主路径上,建议按"已成立"处理而非等实测。
本报告只做静态审查,未修改任何代码。