From 576ed95133853091303e6ea84f03a33372bb4374 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:27:13 +0800 Subject: [PATCH] docs: keep 2026-07-29 full re-audit report (69-agent adversarial review; CRITICAL 0 / HIGH 3 all fixed @e6886ee+@4aacc3a) --- AUDIT-agent-main-20260729.md | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+), 0 deletions(-) create mode 100644 AUDIT-agent-main-20260729.md diff --git a/AUDIT-agent-main-20260729.md b/AUDIT-agent-main-20260729.md new file mode 100644 index 0000000..d785bdc --- /dev/null +++ b/AUDIT-agent-main-20260729.md @@ -0,0 +1,238 @@ +# xlyAi 代码审计报告 — 分支 `agent-main` @ `9f1f7fc`(审计基线 `2c0afde` + 状态协议提交) + +**范围**:`src/main`(44 个源/资源文件,Java ≈5.9k LOC)+ `sql/`(12 份 schema/视图)+ `docs/agent-architecture.md §21` + `src/test`(8 个测试类)+ `bench/` +**方法**:8 个维度并行审计(auth-tenant / write-path / ledger-memory / form-resolve / skills-prompt / resilience-ops / frontend-sse / regression-drift)→ 每条发现交由独立复核者做对抗性证否(逐行复读、上下游调用链、DB 实证、langchain4j 1.14.0 源码核对)→ gap-critic 补漏 → 本人对进入前 10 的条目再次逐行复验。 +**计数**:对抗性复核后存活 50 条 → 去重合并(同一缺陷的不同视角)后 **34 条独立缺陷**:HIGH 3 / MEDIUM 17 / LOW 14。**CRITICAL 0**。 + +## 结论 + +> 默认 profile 下没有未授权入口,也没有任何 LLM 能绕过「人点确认 + previewId 重校验 + 只写 ai_op_queue」这条红线——rearch3 的核心安全目标是达成的。但**不建议按现状合并**:写路径上的字段/记录解析层(`resolveColumn` / `resolveFk` / `resolveNameField`)全部只信任由表单控件名构建的 `viw_kg_field_dict`,既不与 `information_schema` 交叉验证,也不在多义时报错,于是「人在确认卡上看到的字段/客户」与「写进 `ai_op_queue` 的列/记录 id」可以是两个东西——本地库实测 5604/18764 个 (表,列) 对指向不存在的列,47/108 个外键表解析到不存在的名称列。人在环这道闸还在,但闸门上写的字是错的。 + +## 与上一版审计的差异 + +上一版审计(`docs/audit-agent-main-20260728.md` @ `f3086c3`)的 blocker 我逐条复读了当前代码,结论如下。 + +**真正修好了(给足信用,本报告不再重复)** + +| 旧编号 | 修复证据 | +|---|---| +| #1 空 token → sysadmin | `AuthzService.resolveIdentity:55-70` 改为 ERP `/ai/whoami` 反查;token 无效一律返回 null;`application.yml:80` `dev-login.enabled=false`;四个控制器全部 fail-closed(`AgentChatController:92`、`PreviewController:49`、`FormController:45`、`ConversationController:67`)。`ErpClient.canRelogin` 明确拒绝为用户 token 走 dev-login 续期,无静默提权 | +| #2/#12 `OpController` 直执行 + confirm CAS | 整个类在 `541e004` 删除;执行权移交 ERP,xlyAi 只入队 | +| #3 明文口令 | `application.yml:29-46` 全部换成 `${DB_URL}/${DB_PASSWORD}/${REDIS_PASSWORD}` | +| #4 `/form/options` 越权翻名录 | `FormController:49` `fkTableAccessible` + `fkOptionPage:240` 品牌谓词强制 | +| #5 会话 IDOR | `ConversationService.scopedId` 强制 `{userId}:` 命名空间 + `owns()`,有 `ConversationScopeTest` 覆盖 | +| #6/#7 客户端自报 usertype / 前端硬编码 sysadmin | 请求体身份字段全部移除;`chat.html:362` 只传 token | +| #8 ERP URL 注入 + 授权检查用错参数 | `ErpClient.safeId:109`;`ErpReadTool:128-134` 改用目录派生的 moduleId 授权 | +| #10 `doUpdate` 裸字符串 | 更新走 `resolveColumn`/`isSystemColumn`/`normalize` | + +**只修了一半** + +- 旧 #9/#11:`resolveFk` 的**租户谓词补上了**(`FormResolverService:406-421`),数值强转也补了校验并有单测;但「多义静默取最短」与「卡片显示用户原话、payload 存另一条记录」两半原封不动 → 本报告 HIGH-1。 +- 旧 #13:`sBillNo` 仍是构建期 `MAX+1` 冻结进 payload;而且 rearch3 **删掉了唯一的补偿**——`OpController.refreshBillNo`(执行前重算单号,`c059258` 引入)随 `541e004` 一并删除且无替代 → MEDIUM-8(这是一个回归)。 +- 旧 #26/#27:actuator `show-details: always`、markdown-it CDN 无 SRI 原样保留 → LOW-21 / LOW-33。 + +**rearch3 新引入的问题** + +1. `previewId` 的原子 `claim`(`PreviewService:395/462`)只覆盖 update/state 两条路径,**create 路径(`/form/submit`)完全没有 claim / 幂等键**,而 `docs/agent-architecture.md:485-489` 声称两个端点共用同一套「归属校验 → 权限重查 → 重读重校验 → 原子 claim」流程 → MEDIUM-19(文档与代码不符)。 +2. 事件账本(`ai_chat_event`)是纯 append-only,但 langchain4j 先 `addToMemory` 再抛错/再回调,rearch3 没有补偿事件类型 → 孤儿 `tool_call` 与被丢弃的编造答案永久留在账本并被回放(MEDIUM-14 / MEDIUM-16)。 +3. Redis 热缓存 + MySQL 冷账本的两级结构,把「key 存在」当作「数据完整」,回填窗口由调用方给(最小 6 条)→ HIGH-3。 +4. `docs/agent-architecture.md:487` 声称保存端点会在「值/状态被他人改过」时拒绝——对 update 路径**不成立**:`saveUpdate` 从不比较 `bCheck/bInvalid`(MEDIUM-9)。 +5. `AgentChatController.java:122-124` 的 javadoc 仍写 `sStatus=confirmed`,实际 `OpService.insert:88` 写 `'pending',100`(`9f1f7fc` 的新协议)——注释陈旧,不影响行为。 + +## 合并前必须修(Blockers) + +- `src/main/java/com/xly/service/FormRenderService.java:260` — 外键列把 `shown` 设成用户原话,确认卡与 `ai_op_queue` 描述都不显示真正被绑定的记录。 +- `src/main/java/com/xly/service/FormResolverService.java:420` — `resolveFk` 用 `LIKE '%name%' ORDER BY CHAR_LENGTH ASC LIMIT 1`,多条命中不报错,静默取最短。 +- `src/main/java/com/xly/service/FormRenderService.java:264` — `columnTypes(table).get(col)` 只取类型不查存在性,`resolveColumn` 解析出的幻影列一路写进队列(本地库 5604/18764 个字典对不存在于物理表)。 +- `src/main/java/com/xly/service/FormRenderService.java:223` — `ORDER BY SUM(iFormUses) DESC` 没有精确匹配优先键,模糊命中可压过精确命中。 +- `src/main/java/com/xly/service/LedgerService.java:211` — 冷读回填用调用方窗口(最小 6)无条件 `rightPushAll` 并使 key 存在,之后永不回源 MySQL。 +- `src/main/java/com/xly/web/FormController.java:52` — FK 读取与解析只带 `brandsId`,丢掉 `sSubsidiaryId`(107/108 个 FK 目标表都有该列),同品牌跨子公司数据可读可绑。 +- `src/main/resources/templates/chat.html:658` — 确认时回传卡片上全部字段,服务端把任何与快照不等的值当成用户编辑并额外入队。 +- `src/main/java/com/xly/service/PreviewService.java:112` — update 预览只看 `bInvalid` 且仅给软提示,全程不看 `bCheck`;`saveUpdate` 无任何状态重校验。 + +## 系统性根因 + +1. **「字典即真相」——解析层从不与物理 schema 交叉验证。** + `viw_kg_field_dict` 由表单控件名 `gdsconfigformslave.sName` 构建(`sql/viw_kg_field_dict.sql:36-46`),含大量 join/别名列。`resolveColumn`、`resolveNameField`、`businessFields` 全部只查这张视图,`columnTypes()` 明明就在手边却只用来取类型。→ HIGH-2、MEDIUM-6、MEDIUM-18。代码库自己在 `FormResolverService.java:165-167` 写下了「报价主表 `dProductQty`/`sProductName` 并非真实列」,却只在 create 路径用 `curatedFields` 绕开,update 路径直连 `resolveColumn`。 +2. **「模糊 + LIMIT 1 + 吞异常」代替「歧义即报错」。** 三个解析器都以使用度/长度排序取一条,零命中报错、多命中静默;失败则被 `catch (Exception ignore)`(`FormResolverService.java:89/109/296/317/394/471`,全类无 logger)降级为空结果。同一文件里的 `locateRecord:168-176` 却正确地在 n>1 时拒绝——规则存在但没有贯彻。→ HIGH-1、HIGH-2、MEDIUM-6、MEDIUM-13。 +3. **「所见即所写」只被实现为字符串相等,而非实体同一。** 卡片展示的是入参原文,落库的是二次解析结果;保存端点的「重校验」又重跑同一份解析(`PreviewService:376`),因此复现缺陷而非发现缺陷;旧值比较(`:371`)只看旧值,永远看不见新值绑错了谁。→ HIGH-1、HIGH-2、MEDIUM-7、MEDIUM-11、MEDIUM-12。 +4. **租户键只有一半。** 授权用 `(sBrandsId, sSubsidiaryId)`(`AuthzService:93/97`),数据层只带 `sBrandsId`(`FormResolverService:271/420/436`)——比 ERP 自身的 `BrandSubsidUtil` 弱一级。→ MEDIUM-4。 +5. **「先落账,后判定」。** langchain4j 在 `AiServiceStreamingResponseHandler:283` 先 `addToMemory` 再做上限检查(:287)/再回调应用层(:422);而账本 append-only 且 `clear()` 是空实现,没有 `retracted`/`tool_error` 之类的补偿事件。→ MEDIUM-14、MEDIUM-16。 +6. **「缓存 key 存在 == 数据完整」。** `LedgerService.read:161` 以 `hasKey` 为唯一判据,回填窗口由调用方决定且不加锁不判空。→ HIGH-3、LOW-28。 +7. **失败沉默:错误被降级成空结果或 HTTP 200。** `persist()` 的 boolean 被丢弃、`catch (Exception ignore)`、`GlobalExceptionHandler` 返回裸 Map(Spring 序列化为 200)。运维与前端都无法区分「没有匹配」「库挂了」「入队成功」。→ MEDIUM-6、MEDIUM-20、LOW-28、LOW-30。 + +## 全部发现 + +### CRITICAL + +无。默认 profile 下不存在未授权入口;LLM 可见的 6 个工具全部只读;`ai_op_queue` 的两个写入端点都要求有效 ERP token + 人工点击,`/preview/{id}/save` 还会重查权限、按 sId 重读记录、原子 claim previewId。 + +### HIGH + +**1. 外键值按模糊 LIKE 绑定,确认卡与队列描述显示的却是用户原话** +`src/main/java/com/xly/service/FormRenderService.java:260` · write-integrity +影响:租户内同时存在「裕同包装」和「天津裕同包装」时,用户说「客户改成裕同」→ `resolveFk` 两条都命中,按 `CHAR_LENGTH ASC` 静默返回其中一条的 `sId`;卡片渲染 `客户名称 → 裕同`,`ai_op_queue.sNewValue` 是某条记录的 id,`sDescription` 又是「裕同」。人批准的名字与写入的实体在任何一个界面上都对不上,ERP 待办里也看不出来。保存时重跑同一次模糊解析(`PreviewService.java:376`),若期间插入了更短的同名记录,绑定的 id 与预览期不同而不报错。 +证据:`FormRenderService.java:259-260` `n.stored = id; n.shown = value;`(同文件 `:277` 已有 `fkDisplayName(fk,id)`,只用于旧值);`FormResolverService.java:417-421` `... WHERE sBrandsId=? AND \`name\` LIKE ? ORDER BY CHAR_LENGTH(\`name\`) ASC LIMIT 1`,`%`/`_` 未转义、无精确优先、无多命中检测;`PreviewService.java:143/150` 卡片与摘要都取 `n.shown`;`:406` 队列描述取 `c.shown()`、`sNewValue` 取 `c.stored()`;`chat.html:594/739` FK 选择器只回传显示名,`fkOptionPage` 已经查出的 `sId` 被丢弃(`FormResolverService.java:266`)。对照 `FormRenderService.java:169-176` `locateRecord` 在 n>1 时是明确报错的。 +修复:`n.shown = resolver.fkDisplayName(fk, id)`;`resolveFk` 改为 `LIMIT 2`,>1 返回歧义错误并列出候选,精确匹配优先;FK 选择器回传 `{id,name}` 并直接用 id。 + +**2. 中文字段名解析出的列可能不存在于目标表,`normalize` 随即跳过类型校验照样入队** +`src/main/java/com/xly/service/FormRenderService.java:264`(解析于 `:223`) · write-integrity +影响:「把报价单 BJD202607001 的数量改成 5000」→ 本地库实测 `resolveColumn('quoquotationmaster','数量')` 返回 `dProductQty`(SUM(iFormUses)=8)而非真实列 `dQty`(3),而 `dProductQty` 在 `information_schema` 中不存在于该表。`columnTypes().get()` 返回 null → `coerce` 落到 varchar 分支,连「五千」都会被接受。`ensureFieldPresent`(`PreviewService.java:118`)把幻影字段补进卡片,用户看到正常的「数量 → 5000」高亮;快照两侧都是空串,保存时的「所见即所写」比对必然通过;最终 `ai_op_queue(sTargetTable='quoquotationmaster', sField='dProductQty')`,ERP 执行时列不存在,用户却已收到「已提交待办」。同一机制还会在有精确匹配时被模糊匹配压过(`mftworkordermaster` 的 `类型→sNoFormat` 胜 `sType`、`成本→dProductCarryOverQty` 胜 `dAllMoney`)。 +证据:`FormRenderService.java:220-224` 仅查 `viw_kg_field_dict`,`ORDER BY SUM(iFormUses) DESC LIMIT 1`,精确与 LIKE 同桶;`:264` 拿到真实列表却只 `.get(col)`;`FormResolverService.java:329-340` `dataType==null → "varchar" → return v`;`sql/viw_kg_field_dict.sql:36-38` 字段来源是 `gdsconfigformslave.sName`;本地库统计:BASE TABLE 上 5604/18764 个 (表,列) 对不存在,`quoquotationmaster` 152 个中文名里 38 个解析到幻影列(含 数量/产品名称/单位/颜色/销售员/汇率/付款方式)。create 路径先查 `labelMap`(`:350-354`)因而躲过,update 路径(`PreviewService.java:97`)直连 `resolveColumn`。 +修复:`resolveColumn` 与 `businessFields` 的候选集与 `columnTypes(table).keySet()` 求交;`ORDER BY (sChinese=?) DESC, SUM(iFormUses) DESC`;`buildUpdatePreview` 优先走 `curatedFields`;卡片与描述用解析出的列自身的字典中文名,而不是用户原词。 + +**3. 冷读回填用调用方窗口重建热缓存并置为"完整",Redis 一次驱逐即永久截断会话历史** +`src/main/java/com/xly/service/LedgerService.java:211`(配合 `:131`、`EventLogChatMemory.java:121`) · silent-context-loss +影响:确定性、无需并发。`chat:ledger:{conv}` 因 30 天 TTL/重启/驱逐消失后,用户在该会话发下一条消息:`append` 见 key 不存在直接跳过 push(`:131`),随后 langchain4j 调 `EventLogChatMemory.add` → `alreadyLoggedPrefixOf` → `log.events(convId, 6)` → 冷读 **只取最后 6 条**并 `rightPushAll` + `expire`。此后 `hasKey==true`,`read()` 永不回源 MySQL:LLM 的 400 条窗口、前端 `GET /conversations/{id}/messages` 都只能看到 6 条,`ai_chat_event` 里其余全部记录对系统不可见(含在办预览/已入队等流程状态)。并发变体:两个读者同时未命中,各推一份 → 历史整体重复,前端每条消息显示两遍,模型每轮看两遍。 +证据:`LedgerService.java:161`(`hasKey` 即视为完整)、`:187` `window = lastN<=0 ? 1000 : min(lastN,1000)`、`:209-212` 无锁无存在性判断的 `rightPushAll`+`expire`、`:131` `if (persisted && !hasKey) return;`;`EventLogChatMemory.java:121` `log.events(convId, 6)`,`:139` `log.events(convId, 400)`;`ConversationService.java:148` 无参调用(1000)。 +修复:冷读一律取 `COLD_READ_MAX` 全窗口,与 `lastN` 解耦;回填走 Lua/`SETNX` 保证「仅当仍缺失时重建」;用哨兵元素或伴随 key 标记「本列表是完整窗口」,`cache()` 在 key 缺失时不得凭一条事件建立列表。 + +### MEDIUM + +**4. FK 读取与解析只按 `sBrandsId` 限定,丢掉 `sSubsidiaryId`** +`src/main/java/com/xly/web/FormController.java:52`(谓词在 `FormResolverService.java:271` / `:420`) · auth-tenant +影响:品牌 B 的子公司 S1 用户 `GET /api/agent/form/options?table=elecustomer` 会翻到整个品牌(含 S2)的客户名录,`total` 还泄露品牌级总数;随后 `resolveFk` 同样只按品牌匹配,S2 的客户 `sId` 被写进创建/更新 payload,而卡片只显示用户输入的名字。`nextBillNo`(`:436/:453`)也会读到兄弟子公司的单号序列。 +证据:`FormController.java:52` 只传 `id.brandsId()`(`AgentIdentity.subsidiaryId()` 就在手边);`fkTableAccessible`(`FormResolverService:74-90`)只判模块权限不判子公司;`AuthzService.java:93/97` 授权键是 `(sBrandsId, sSubsidiaryId)`;ERP 自身 `BrandSubsidUtil:34-64` 两个谓词都加。本地库:108 个 FK 目标表中 107 个有 `sSubsidiaryId` 列。 +修复:把 `identity.subsidiaryId()` 贯通到 `fkOptionPage`/`resolveFk`/`nextBillNo`,当 `columnTypes(fkTable)` 含 `sSubsidiaryId` 时追加谓词,值为空时按现有品牌谓词的方式 fail-closed。 + +**5. `saaslocal` profile 关闭鉴权,叠加通配 CORS 后任意网页可驱动 agent 并入队 ERP 写操作** +`src/main/resources/application-saaslocal.yml:31` + `src/main/java/com/xly/config/CorsConfig.java:29` · auth-tenant +影响:以文档指定的 `saaslocal` 启动时(唯一自带 DB/Redis/ERP 凭据的 profile),无 `Authorization` 头的请求经 `AuthzService.java:69 → devIdentity()` 得到 `usertype` 默认 `sysadmin` 的全权身份(`grantedIds` 返回 null = 全部模块),ERP 调用走 admin/666666 dev 会话。`CorsConfig` 对 `/**` 反射任意 Origin 并允许任意请求头,于是开发者浏览的任何网页都能跨域 POST `/api/agent/chat` 读走 ERP 数据、POST `/api/agent/form/submit` 写入一条 `ai_op_queue` 行(`PreviewService:492 → OpService:84`)。默认 profile 是 fail-closed 的(`application.yml:80` `enabled: false`),故限于该 profile。 +证据:`application-saaslocal.yml:30-35`(`enabled: true`、admin/666666、无 `usertype` 键);`AuthzService.java:39-40` `@Value("${erp.dev-login.usertype:sysadmin}")`、`:69`、`:85`;`CorsConfig.java:29/35/47` 与 `:60-66` 的重复注册;`pom.xml` 无 spring-security,`src/main/java` 无任何 Filter/Interceptor。注:`allowCredentials(true)`(`:38`)在此不是机制——本服务无 cookie/session 鉴权,token 由 `chat.html:362-370` 显式加头;起作用的是 Origin 通配。 +修复:`allowedOriginPatterns` 收敛到 ERP 外壳域名并删掉重复注册;`erp.dev-login.usertype` 在该 profile 显式设为非管理员并去掉 `AuthzService:40` 的 `:sysadmin` 默认值;dev-login 仅对回环地址生效。 + +**6. `resolveNameField` 按单行 `iFormUses` 取名称列且不校验存在性,47/108 个 FK 表解析到幻影列,异常被整段吞掉** +`src/main/java/com/xly/service/FormResolverService.java:118`(吞异常在 `:296`) · name-resolution +影响:本地库实测 `resolveNameField('elesupply')` 返回 `sParentName`(`iFormUses=5`,压过真实列 `sSupplyName`),而该列不存在 → `SELECT sId FROM elesupply WHERE sBrandsId=? AND \`sParentName\` LIKE ?` 抛 1054 → `queryOne` 吞掉 → 返回 null → 用户看到「「三只松鼠」不是系统里已有的供应,请从已有记录里选一个」,而该供应商确实存在。同一表的 `GET /form/options` 也在 COUNT 阶段抛 1054 被 `:296` 吞掉,返回 200 + 空下拉,输入框是 `readOnly`,该必填 FK 字段**永远填不上**,服务端一行日志也没有。`eleemployee → sDepartName`(被 120 处字典引用)同理。共 47/108 解析到不存在的列,8 个解析到 null。 +证据:`FormResolverService.java:115-119` 无 `GROUP BY`/`SUM`、无存在性校验;消费者 `:249-290`(`fkOptionPage` 把列名拼进 WHERE/ORDER BY)、`:410-421`(`resolveFk`)、`FormRenderService.java:283`(`fkDisplayName`,退化为在卡片上显示裸 GUID);全类无 logger。 +修复:候选集与 `columnTypes(table).keySet()` 求交后再 `GROUP BY sField ORDER BY SUM(iFormUses) DESC`;`catch (Exception ignore)` 改为 `log.warn` 并在响应里给出 `error` 字段,让 UI 能区分「无匹配」与「查询失败」。 + +**7. 可编辑预览卡回传全部字段,`` 的有损往返把未触碰的字段变成真实更新** +`src/main/resources/templates/chat.html:658` + `src/main/java/com/xly/service/PreviewService.java:361` · write-integrity +影响:update 预览会把主表全部业务字段(上限 40)渲染成可编辑输入框。若「备注」现值含换行,`` 赋值时按规范剥掉 CR/LF,getter 再 `.trim()`;用户只改了数量就点保存,服务端见 `want != wasShown` 即视为用户编辑,TOCTOU 检查只比旧值故通过,于是**额外**入队一条把备注改成压平文本的更新(且经 `OpService:91` 截断到 500 字)。卡片上该字段既无 `changed` 高亮也无「原值」行,用户无从判断保存会重写它。date/select 类型因浏览器置空 + `PreviewService:343` 丢弃空值而 fail-safe。 +证据:`chat.html:658` `controls.forEach(c => { fields[c.name] = c.get(); })`;`:611-616` 默认分支建裸 ``、`getter = () => inp.value.trim()`;`PreviewService.java:341-345` 任何非空回传值进入 `desired`;`:361` 唯一抑制是完全相等;`:371` 冲突检查只比旧值;`:402-407` 每条幸存变更各成一行队列。 +修复:客户端按 `input/change` 事件维护 per-field dirty 标记,只回传脏字段;服务端对未标记为已编辑的字段,忽略仅由空白/换行归一化产生的差异。 + +**8. `sBillNo` 在入队时按目标表 `MAX+1` 预分配并冻结进 payload(rearch3 回归)** +`src/main/java/com/xly/service/FormRenderService.java:384`(报价路径 `:499`) · write-integrity +影响:ERP 侧待办是异步/人工触发的,目标表在此期间不会前进,因此第一条创建执行前提交的所有同表创建拿到**同一个单号**。对 `quoquotationmaster`/`salsalesordermaster`/`purpurchaseordermaster` 这类 `sBillNo` 上有唯一索引的表,第二条在用户已被告知「已提交待办」之后执行失败;对本地库中 20 张有 NOT NULL `sBillNo` 但无唯一索引的可创建目标表(`ComComplaint`、`cahfinancialadjust`、`CahFinancialTransfer`、`hrsinjurymaster` 等)则静默产生两张同号单据。`YYYYMM` 段也被冻结:7/31 入队、8/1 执行仍是 7 月号。ERP 侧只有在 payload 带 `maxBillNo` 键时才会重生成(`BusinessCharacterFormatServiceImpl:139-171`),xlyAi 从不带该键。 +证据:`FormRenderService.java:382-386`、`:496-501`;`FormResolverService.java:428-459` 只读目标表、不查 `ai_op_queue`、无锁;`git show 541e004 -- .../OpController.java` 显示执行前重算单号的 `refreshBillNo`(`:242-248`)随文件一并删除,`docs/agent-architecture.md:395`「单号在执行前重新生成」已不再描述代码。 +修复:payload 里不写 `sBillNo`(和 `sId`),交由 ERP 执行事务内分配;或在 payload 中带上 ERP 已支持的 `maxBillNo` 标记走其原有的取号+查重逻辑。 + +**9. update 写路径全程不做单据状态合法性校验** +`src/main/java/com/xly/service/PreviewService.java:112`(保存端 `:320-424`) · write-integrity +影响:`buildUpdatePreview` 只在 `bInvalid==1` 时加一句软提示,**从不读 `bCheck`**;已审核单据的修改卡片对人完全不提示审核状态。`saveUpdate` 没有任何状态重校验(对比 `saveStateOp:431-447` 会存 `expected` 并在漂移时拒绝),且 `bCheck/bInvalid` 被 `isSystemColumn`(`FormResolverService:154`)排除在快照之外,通用漂移检查结构上不可能覆盖它们。预览后 30 分钟内被他人审核/作废,保存照样入队。`docs/agent-architecture.md:487` 声称保存端点会在「状态被他人改过」时拒绝——对该路径不成立。 +证据:`PreviewService.java:111-114` 是 update 路径唯一的状态检视;`:320-424` 无 `stateIllegalReason`、无 `bCheck` 比对;`:117-137` 快照来源 `businessFields` 已剔除 b*/t* 列。 +修复:预览时把 `bCheck/bInvalid` 单独快照进 draft,已审核/已作废单据的 update 预览直接拒绝或强提示;`saveUpdate` 在 claim 之前重读并比对这两位。 + +**10. `readState` 无法区分「payload 里没有该列」与「值为 0」,状态硬校验对部分表单整体失效** +`src/main/java/com/xly/service/PreviewService.java:587` · write-integrity +影响:`readState` 对缺失/空节点返回 0,而调用方是否采信这个 0 取决于 `resolver.columnTypes(table)`(`information_schema`)。ERP 的 `getBusinessDataByFormcustomId` 投影列表来自表单控件配置,本地库实测:在 `resolveMasterForm` 可选中的 743 张表单里,63 张不投影 `bCheck`、35 张不投影 `bInvalid`,而底表有这两列(例:`udfvouchermaster` 会计凭证主表)。于是「列存在 + 值 0」被同时成立,`stateIllegalReason` 恒返回 null:对已审核单据发起审核、对已作废单据发起作废都不会被拦;`expected` 存的是 `{bCheck:0}`,保存端重读同样为空,TOCTOU 比对也空过。卡片会向用户断言「未审核 → 已审核」这一错误事实。ERP 侧 `Sp_Calc_*` 存过多数有 `bCheck=1` 前置判断,故重复审核会在执行期失败而非覆盖审核人;作废侧无对应前置判断。 +证据:`PreviewService.java:586-594`;采信判据在 `:193-198` 与 `:430-447`(`types.containsKey`);`docs/agent-architecture.md:547` 明确 ERP 侧校验仍在补做,xlyAi 这层是当前唯一硬检查。 +修复:三态区分——`types.containsKey(col) && !rec.has(col)` 时拒绝状态操作(「无法确认该单据的审核/作废状态,请重新预览」),或直接按 sId 从底表读这两列而不依赖表单投影。 + +**11. 队列新值静默截断到 500 字符** +`src/main/java/com/xly/service/OpService.java:91` · write-integrity +影响:`sOldValue/sNewValue/sDescription` 均为 `varchar(500)`,`insert` 用 `trunc()` 静默截断——恰好把本会抛出的 "Data too long" 变成无声的数据丢失。目标列可以是 `text`(本地库确认 `elecustomer.sMemo`、`eleproduct.sMemo`、`eleprocess.sProductionMemo`、`quoquotationmaster.sMemo` 均为 text/65535),用户在卡片上看到并批准 900 字,队列里只有前 500 字,返回给用户的成功消息(`PreviewService:417-422`)用的是未截断值。若开启 dry-run,`:530` 发给 ERP 校验的也是未截断值,注释 `:526`「与真实入队完全同口径」不成立。只影响 update 路径(create 走 `sPayload text`,state 写固定串)。 +证据:`OpService.java:91` + `:95-97`;`sql/ai_op_queue.sql:30-31`;`PreviewService.java:406` 传入的是完整值。对照 `FormResolverService.java:322-328`——数值的 best-effort 截断已因「人看到的和写进库的是两份东西」被移除,字符串这一份留着。 +修复:把 `sOldValue/sNewValue` 改为 `text`(`sPayload` 已经是),或在保存时超长即报可见错误;绝不静默截断已展示给用户的值。 + +**12. 保存时按 sId 重读不校验返回行确实是目标记录** +`src/main/java/com/xly/service/FormRenderService.java:210` · write-integrity +影响:`locateById` 用 `sId` 走 ERP 的 `like` 过滤,请求 `pageSize=2` 却无条件取 `data.get(0)`,既不断言 `sId` 相等也不拒绝多行(对比同文件 `locateRecord:168-176` 会拒绝 n>1)。本地库中 sId 是变长数字串,历史导入产生了前缀族:`elematerials` 有 104 对前缀包含关系(442 行)、`elemachine` 有 1046 对(1584 行),两表都带 `bCheck/bInvalid`。此时保存端的全部重校验——旧值相等(`PreviewService:370`)、`bCheck/bInvalid` 漂移(`:431-432`)、状态合法性(`:444`)——都是在一条可能不是目标的记录上做的;入队用的仍是 `draft.billId`,所以不是写错记录,而是**防 TOCTOU 的重校验整体失效**。另一可观察后果:该前缀族里的记录每次保存都可能收到虚假的「在预览之后被其他人修改过」。 +证据:`FormRenderService.java:195`(`readForm(..., 1, 2, "sId", billId)`);`ErpClient.java:181-183` 过滤条件硬编码 `"like"`;`:206-211` 仅判空。 +修复:`locateById` 断言 `data.get(0).path("sId").asText().equals(billId)` 且 `data.size()==1`,否则返回「记录重读失败,请重新预览」;主键过滤改用等值条件。 + +**13. 目标表无可解析名称列时退化为无过滤读取,卡片标题用调用方原话** +`src/main/java/com/xly/service/FormRenderService.java:149`(标题 `:183`) · write-integrity +影响:`searchField` 返回 null 时仍调 `readForm(..., filterField=null)`,`ErpClient.doRead:178-185` 只在 filterField 非空时加 `bFilter`,于是退化成「取第 1 页 5 条」。若该表单当前恰好只有 1 行,`n==1` 分支接受这条与关键词无关的记录,`recordName` 置为用户原话,卡片标题渲染成「作废「张三的报价单」」。本地库中 223 张可选主数据源里有 17 张没有任何可解析名称列(`saldelivernotifymaster` 送货通知单主表、`udfvouchermaster`、`reimbursementapplicationmaster` 等)。卡片仍会展示该记录的真实单号与若干真实字段值,故人有机会察觉;`ErpReadTool:163-168` 也只是附一句说明并照样返回未过滤结果,不是守卫。 +证据:`FormRenderService.java:149/152/177/183`;`FormResolverService.java:122-127`。 +修复:`nameField == null` 时直接返回错误(与 `ErpReadTool` 的文案对齐),并且 `recordName` 一律取自记录本身而非关键词。 + +**14. 中断的工具轮次在 append-only 账本里留下孤儿 `tool_call`,之后每轮都回放** +`src/main/java/com/xly/agent/EventLogChatMemory.java:99` + `src/main/java/com/xly/service/EventProjectionService.java:290` · message-shape-invariant +影响:langchain4j 1.14.0 在 `AiServiceStreamingResponseHandler:283` 先 `addToMemory(aiMessage)`(→ 写入 `tool_call` 事件),才在 `:287` 检查 `maxSequentialToolsInvocations(8)` 并抛错;参数畸形与工具名幻觉两条默认错误处理器也是直接 rethrow。此时 `tool_result` 永远不会写入,账本永久保留一条无应答的 `tool_call`。`project()` 只清理**开头**的孤儿 `tool_result`(`:105-115`,注释里点名了非法序列问题),对尾部孤儿 `tool_call` 无任何检查(`:290-295` 无条件渲染),于是该轮在验证区(zone ⑤)内的每一次请求都会产出「assistant(tool_calls) 紧跟 UserMessage」。当前 Ollama `/v1` 端点容忍此序列(表现为模型看到自己调过却没结果的工具、可能重复调用);若按 `application.yml:69` 的注释切到严格 OpenAI 兼容网关,则整条会话在该轮滑出 400 条窗口前持续 400。 +证据:`EventLogChatMemory.java:83-99`、`:156` `clear()` 是刻意空实现;`EventProjectionService.java:103-115` 与 `:290-295` 的不对称;`AgentFactory.java:98`;`AgentChatController.java:207-211` 的 onError 只发 SSE 帧,无补偿写入。 +修复:`project()` 渲染前索引本轮的 `tool_result` tcId,对无配对的 `ToolExecutionRequest` 丢弃或合成一条 `{"error":"interrupted"}` 结果,与既有的孤儿 `tool_result` 裁剪对称。 + +**15. 无会话级串行化:第二次提交会打断在途 ReAct 轮次** +`src/main/java/com/xly/web/AgentChatController.java:101` · concurrency +影响:`/chat` 先 append `user` 事件再把 agent 循环丢进共享线程池,无 per-conv 锁与在途检查。shipped UI 挡住了同 tab 二次发送(`chat.html:493`),但**不挡表单卡的保存按钮**(`:794-805` 不看 `sending`):流式回答进行中点击已渲染表单的保存 → `/form/submit` append 一条 `form_submit`,而它在 `LedgerService:44` 与 `EventProjectionService:484` 都是 turn opener。在途循环的下一次 `messages()` 于是把自己这一轮降级为「旧轮」(刚取回的 ERP 工具结果被压成 120 字摘要,`EventProjectionService:276`),并把新事件当成当前轮的最后一条 user 消息——SSE 流回答的是另一件事。侧栏切换同会话双开、任意 API 客户端并发同样触发。另外 `alreadyLoggedPrefixOf`(`:120-135`)遇到 `tool_call` 即返回 false,交错时会把同一条用户问题重复写进 `ai_chat_event`。 +证据:`AgentChatController.java:100-111`;`EventLogChatMemory.java:139`;`EventProjectionService.java:479-494`;全 `src/main` 仅 `ErpClient.login()` 有 `synchronized`。影响限于同一用户自己的会话(`scopedId` 命名空间),且 LLM 工具全只读,不会造成未确认写入。 +修复:`chat:lock:{convId}` 的 `SETNX`+TTL 串行化(或拒绝第二次),事件带 runId,投影按 run 而非位置分组。 + +**16. 反编造重试把被丢弃的编造答案永久留在账本,历史与后续上下文都会重放** +`src/main/java/com/xly/web/AgentChatController.java:189-196` · ledger-integrity +影响:langchain4j 在 `:283` 先把最终 AiMessage 入 memory(→ `EventLogChatMemory:101` 写 `ai` 事件、`LedgerService` 同步落 MySQL),控制器才在 `:189` 判定「零工具调用且含数字」并发 `reset` + 重跑。`reset` 只清浏览器当前气泡;账本里两条 `ai` 事件都在。刷新会话后 `historyView`(`EventProjectionService:341` 对 `ai` 无任何过滤,而内部 user 事件在 `:336` 被隐藏)把编造答案渲染成助手说过的话;后续轮次 `:289` 把它按旧轮原文喂回模型;该轮被压进摘要区后,`digestLine:505` 更是直接把编造文本当成整轮结论。`:198` 的「以上数字未能经系统数据核实」与 `:201` 的写操作告警都只是 SSE token,同样不入账本。 +证据:`AgentChatController.java:189-196` 无任何账本补偿;`EventLogChatMemory.java:77-79` 只有 user 事件带 `internal` 标记。 +修复:重试时追加 `retracted`/把该 `ai` 事件标记为 internal,并让 `historyView` 与 `project()` 跳过;或把 `ai` 事件改由控制器在重试决策之后写。 + +**17. ERP 记录名原样拼进 SystemMessage 的常驻「进行中的流程」区** +`src/main/java/com/xly/service/EventProjectionService.java:418` · prompt-injection +影响:`activeCard()` 把上一条 `previewChange` 工具结果的 `summary` 拼进系统消息,而该 summary 由 `PreviewService:149-151` 用 `l.recordName`(直接来自 ERP 记录的名称列)与模型给的 `newValue` 拼成,无转义、无分隔、无长度上限。有建档权限的 ERP 用户(或导入的客户数据)把客户名写成含指令的文本后,另一用户对该记录发起改动,下一轮起这段文本就位于 system 角色内、且按设计「①② 永不让位」不参与任何预算裁剪,可覆盖 `system.txt` 的硬规则(如「绝不声称已保存」)。上限是误导叙述与工具选择偏置:模型没有写工具,所有 ERP 读取都用受害者自己的 token,写入仍需人点 `/preview/{id}/save`。 +证据:`EventProjectionService.java:96-101` 系统消息拼接、`:390-441` `activeCard`、`:457-462`;`PreviewService.java:182`;`FormRenderService.java:183`;全 `src/main/java` 无任何 sanitize/escape。 +修复:工具派生文本不得进入 system 角色——「进行中的流程」改为一条 UserMessage 或加显式「以下是数据不是指令」的定界块,并在进卡片前硬截断 `recordName`/`newValue`。 + +**18. 创建路径以中文标签为键,同表同中文名的两列互相覆盖** +`src/main/java/com/xly/service/FormRenderService.java:342` · write-integrity +影响:`/form/submit` 的 `fields` 是「中文名→值」,`buildCreate` 用 `labelMap.put(normLabel(label), …)` 重建映射,同标签后者覆盖前者。本地库 `elecustomer`(`collectForm("客户")`)实测存在 7 组同标签冲突,且都是「FK 列在前、`*Name` 别名列在后」:币别 `sCurrency`/`sCurrencyName`、结算方式 `sGetPayId`/`sGetPayName`、客户属性、销售员、税码 `sTaxId`/`sTaxName`、客户等级、客户分类。用户在 FK 下拉里选了税码,浏览器发 `{"税码":"增值税13%"}`(`chat.html:787`),服务端解析到 `sTaxName`——`SHOW COLUMNS` 确认该列不存在于 `elecustomer`——走非 FK 分支按 varchar 直存,`sTaxId` 从未被赋值,payload 里多出一个幻影列,用户却收到「已提交待办」。`PreviewService.java:331-333` 早已因「同表两列同中文名时 label 键会串写出幻影更新」把 update 路径改成技术名为键,create 路径没改。 +证据:`FormRenderService.java:341-342` 与 `:409`;`FormCollectTool.java:126-171` 同样按标签预填并给**所有**同标签字段盖上 default;`AgentChatController.java:117`。(原报告举的 `sParentId` 例子不成立——它在 `FormResolverService.SYS_EXACT` 里,`businessFields:379` 已过滤。) +修复:`/form/submit` 改收骨架里已有的技术字段名(与预览保存端点一致);或 `formSkeleton` 对重复标签加后缀消歧,并在一个键映射到多列时直接拒绝。 + +**19. `/form/submit` 无幂等键、无 claim,客户端在传输错误后重新启用保存按钮** +`src/main/java/com/xly/service/PreviewService.java:492` + `src/main/resources/templates/chat.html:818` · write-integrity +影响:`saveCreate` 只有 `buildCreate` 里的模块权限检查,没有 previewId、没有 claim、没有幂等键(对比 update/state 路径 `:395`/`:462` 的原子 Redis DEL)。服务端插入成功但响应丢失(代理超时/断网/休眠)时,`chat.html:813/818` 重新启用按钮并提示「保存失败」,再点一次就是第二条创建:两行 `sId`/记录 uuid 不同、`sBillNo` 相同(见 MEDIUM-8)。`ai_op_queue` 也没有任何自然键唯一索引,xlyAi 侧无法检测更无法撤销(confirm/cancel 端点已下线)。`docs/agent-architecture.md:485-489` 却把这个端点也写进了「原子 claim previewId」的共用流程。 +修复:为 collectForm 卡片发一次性 formToken(服务端 stash,绑定 userId/convId),提交时原子 claim;或在 `ai_op_queue` 上加幂等列 + `INSERT ... ON DUPLICATE KEY IGNORE`。 + +**20. 所有内部错误都以 HTTP 200 返回,异常原文回显给调用方** +`src/main/java/com/xly/exception/GlobalExceptionHandler.java:50` · error-handling +影响:只有 `ResponseStatusException` 映射到真实状态码;`handleException`(`:50`)与 `handleDataAccessException`(`:102`)返回裸 `Map`,Spring 序列化为 200 + 应用级 `code`。于是 nginx 访问日志、监控、任何非浏览器客户端都无法区分「入队成功」与「数据库挂了」——用户点【审核】时 MySQL 不可用,`saveStateOp` 已在 `:462` claim(删掉 Redis key)后于 `:467` 抛出,前端只看 `d.queued`/`d.error`,显示「提交失败」,再点则「预览已过期」;该次确认 fail-closed 地丢失(无队列行、无 `queued` 账本事件),服务端只有一行 `log.error`。`:52` 还把 `e.getMessage()` 拼进响应体(未认证即可触发:`POST /api/agent/preview/x/save` 带畸形 JSON 体 → 200 + Spring 的参数错误文案;JDBC 文本已被 `:102` 的专用处理器消毒,不会外泄)。 +修复:改返回 `ResponseEntity.status(500)`(参数类 400),客户端只给固定文案、`e.getMessage()` 仅记服务端日志。 + +### LOW + +**21. actuator 未鉴权暴露且 `show-details: always`** — `src/main/resources/application.yml:64`(暴露列表 `:61`)·exposure。无 spring-security、无任何 Filter/Interceptor,`curl http://host:8099/xlyAi/actuator/health` 返回 DB 产品名与校验查询结果、Redis 版本、部署路径与磁盘余量;`/actuator/metrics/http.server.requests` 枚举已访问 URI。叠加通配 CORS 后可被浏览器跨域读取。(`info` 为空:无 build-info/git.properties。)修复:`show-details: when-authorized`、去掉 `metrics`,或把 management 端口绑到回环。 + +**22. 数值强转剥离全部逗号,逗号分隔的多个数字被拼成一个** — `src/main/java/com/xly/service/FormResolverService.java:335` ·data-corruption。`1000,3000,5000 → 100030005000` 通过 `NUM` 正则并作为 `dQty` 入队(相邻的「多数量」字段提示语恰恰教用户用逗号分隔,其自身分支 `FormRenderService:442` 是逐 token 校验的);`1,5 → 15`。差异只在提交后的描述里以「(原话:…)」回显。修复:仅接受合法千分位 `^-?\d{1,3}(,\d{3})*(\.\d+)?$` 或纯数字,并按列的 precision/scale 拒绝越界。 + +**23. 每轮首次 LLM 请求里用户消息重复一次** — `src/main/java/com/xly/agent/EventLogChatMemory.java:139` ·context-projection。控制器先写 `user` 事件(`AgentChatController:101`),投影因此已以该消息结尾;`DefaultAiServices:242/248` 又 `addAll(memory.messages())` 后 `add(userMessage)`。已用探针测试复现(system + 两条相同 USER)。该副本发生在预算自检之后,故不计入 `promptBudget()`;仅当单条用户消息 >~1024 token 且会话已占满预算时才会触发 Ollama 前截(丢掉 system prompt)。修复:控制器不预写 `user` 事件,或 `project()` 省略最新 user 事件。 + +**24. 反编造护栏被任意工具调用解除,且只对含数字的答案生效** — `src/main/java/com/xly/web/AgentChatController.java:183` ·llm-guardrail。`toolCalls` 对每个工具执行都自增,包括不读 ERP 的 `useSkill`/`askUser`,也包括返回「未找到」的读取;一次 `useSkill("查询数据")`(该技能确实存在,`skills/query.md:1`)就让 `:189` 的判据永假。项目自己的基准复刻是分类计数的(`bench/bench_ext.py:259-264`、`:436` 明言「useSkill 不计对错」),生产没有。仅影响一句提示性 ⚠️,写路径不受影响。修复:只统计 `findForms/readFormData/lookupRecord/previewChange`。 + +**25. 一次瞬时 DB 故障把降级版系统提示词永久缓存到进程结束** — `src/main/java/com/xly/service/SystemPromptService.java:43` ·resilience。`renderDomainMap()` 失败返回非 null 的「(业务域地图暂不可用)」,`prompt()` 只在字段为 null 时重建,无 TTL、无刷新端点,只有故障瞬间一行 warn。最可能的触发不是网络抖动而是部署后 `viw_kg_domain` 尚未创建:此后即使补建视图也要重启才恢复。对照 `SkillService.all()` 每次重探因而自愈。修复:只缓存成功结果,或加短 TTL。 + +**26. 被取代的预览卡在本次页面会话内仍可提交** — `src/main/java/com/xly/service/PreviewService.java:170` ·write-integrity。每次 `previewChange` 生成独立 previewId(TTL 30 分钟),不按 (user, table, billId) 索引也不作废旧稿;`claim()` 只防同一 previewId 重复提交。用户先说「改成 50000」再改口「是 5000」,两张卡的保存按钮都可用,两次点击各入队一行冲突更新;因两行都还没执行,`:377-384` 的重读比对不会触发。(历史重载不会重建卡片,故窗口止于刷新/切会话。)修复:`chat:preview:last:{userId}:{table}:{billId}` 记录最新 previewId,`stash()` 时删除旧稿;前端收到同记录新卡时禁用旧卡。 + +**27. `conversationId` 长度上限可被自带前缀的 id 绕过** — `src/main/java/com/xly/service/ConversationService.java:54` ·write-integrity。`203353c` 的 96 字符截断只加在重映射分支,以调用者自身 `{userId}:` 开头的 id 原样返回;而 `ai_chat_event.sConversationId` 是 `varchar(96)`,于是该会话的每条 MySQL 落账都失败(`LedgerService:114-117` 吞掉并返回 false,`:131-135` 照样进 Redis),30 天后历史彻底消失。`OpService.insert` 不截断该列,故保存端点会 500(fail-closed,无幽灵队列行)。影响限于调用者自己的命名空间。修复:两个分支都截断并加字符白名单。 + +**28. `delete` 的墓碑事件写失败被吞,会话可能复活** — `src/main/java/com/xly/service/LedgerService.java:243` ·write-integrity。`delete()` 忽略 `persist()` 的返回值,`ConversationController:53-55` 恒返回 `{"ok":true}`。若删除时 MySQL 短暂不可用,仅热缓存被清;恢复后再以同一 convId(省略 conversationId 会落到 `{uid}:default`)发消息,冷读找不到 `deleted` 行,整段"已删除"历史被重新加载进 LLM 上下文与 `/messages`。修复:墓碑写失败时报错/重试,或同时在 Redis 写同 TTL 的墓碑。 + +**29. ①②区与当前轮不参与压缩,超预算仍照发** — `src/main/java/com/xly/service/EventProjectionService.java:183` ·resilience。收缩循环只能把 ⑤ 降级进 ④ 再丢 ④ 行;`curAllowance = budget - sysEst - TURN_RESERVE`(`:123`)可为负,`renderCurrentTurn` 在 `keepFull==0` 时照返(`:235`),当前轮的用户文本从不截断(`:287`)。任一认证用户 POST 一条约 13k 汉字的 `text`(`AgentChatController:89` 无长度限制,`ai_chat_event.sPayload` 是 mediumtext)即可让 `:183` 打 warn 后把超预算消息列表发给模型——正是类注释 `:38` 声明绝不允许的情形。修复:`activeCard` 与当前轮用户文本各自设硬上限,`sysEst` 超限时截断而非放行。 + +**30. 流异常结束时前端静默删除整条回复** — `src/main/resources/templates/chat.html:527` ·resilience。`sendMessage` 从不检查 `resp.ok`,也不判断是否收到过 `done` 帧;只要气泡仍带 `thinking` 就整行 `remove()`。`SseEmitter` 是 180s 且无心跳(`AgentChatController:89`),而单次模型调用超时也是 180s、反编造重试还会在同一预算内再跑一整轮——超时后 Spring 优雅结束响应(客户端看到干净 EOF,无异常、无 error 帧),服务端 `:272` 吞掉 `IllegalStateException` 继续跑完并落账。用户看到自己的问题下面什么都没有,重问一遍;历史里日后会出现一条他从未见过的回答。非 SSE 的 500/502/504 同理。修复:检查 `resp.ok`,以是否收到 `done` 帧为准决定是否删除气泡,服务端加周期性心跳注释帧。 + +**31. 流式回答途中切换会话,卡片渲染进新会话并按新 convId 归档** — `src/main/resources/templates/chat.html:473` ·concurrency。`switchConv`/`newConv` 只改全局 `conversationId` 并清空 `#messages`,不中止在途 fetch(全文件无 `AbortController`),只有 `#sendBtn` 被禁用。在途流随后把卡片 append 到全局 `messagesEl`,`form_collect` 卡的保存按钮在点击时读全局 `conversationId`(`:805`),于是 `ai_chat_event` 与 `ai_op_queue.sConversationId` 记到一个从未发起该操作的会话上。(`previewChange` 卡不受影响:`ok.onclick` 只发 previewId,convId 取自服务端草稿。)修复:每次发送持有 AbortController 并在切换/删除时 abort;把 convId 捕获进闭包。 + +**32. 流未结束时点击 askUser 选项,卡片被删而消息未发出** — `src/main/resources/templates/chat.html:691` ·state-management。`askUser` 是工具调用,卡片在流仍开着时推达(`AgentChatController:251-252`,`InteractionTool` 立即返回),此时 `sending` 仍为 true;`c.onclick = () => { card.remove(); sendMessage(o); }` 先删卡再调用,而 `sendMessage` 在 `:493` 早退——问题的唯一载体消失且什么都没发生,无任何反馈。窗口持续到服务端发 `done`(含模型后续生成与更多工具轮)。同类问题:`:897-904` 的 Enter 处理先清空输入框再调用。修复:`sendMessage` 返回是否真的启动,成功后再删卡;或流进行中禁用 chip。 + +**33. markdown-it 走公网 CDN,无 SRI、无本地回退** — `src/main/resources/templates/chat.html:7`(消费点 `:374`)·resilience。唯一外部脚本无 `integrity`/`crossorigin`,`static/` 下无本地副本,无 CSP。内网/断网环境下 `window.markdownit` 未定义 → `:374` 抛 TypeError → 其后所有事件绑定与 `newConv()` 引导全部不执行,页面渲染正常但完全无响应、无报错。反向地,若该资产被篡改,注入脚本与 ERP token(`:362-363`)同域,可直接 POST `/api/agent/preview/{id}/save` 自我批准已暂存的写操作。修复:把 markdown-it 放进 `src/main/resources/static` 同源提供,至少加 SRI 与 `typeof` 回退。 + +**34. `application-saaslocal.yml` 里仍是明文口令(凭据整改的遗漏)** — `src/main/resources/application-saaslocal.yml:21`(另 `:17-18`、`:34-35`)·secrets。`c059258` 把 `application.yml` 改成环境变量占位符,但该 profile 仍硬编码 Redis 口令 `xlyXLY2015`、mysql root/local、ERP admin/666666,且 `pom.xml:108-116` 会把它打进 war 的 `BOOT-INF/classes`。`xlyXLY2015` 不是一次性口令——`git show 6263521:src/main/resources/application.yml` 显示它当时就是生产部署配置里的值,故需轮换(`docs/agent-architecture.md:399` 自己写了「删掉不等于失效」)。注意仅清理 yml 无意义:同一份仓库的 `docs/agent-architecture.md:228` 与 `docs/audit-agent-main-20260728.md:69` 里也是明文。修复:占位符化 + 轮换 `xlyXLY2015`,文档同步脱敏。 + +## 审计覆盖与盲区 + +**已实证的部分**:所有条目的文件/行号均在 `9f1f7fc` 工作树上逐行复读确认(本报告修正了若干漂移行号,如 `OpService` 的 `trunc` 在 `:91` 而非 `:90`、`resolveFk` 的两个分支在 `:417-421`)。字段字典、幻影列/幻影名称列比例、前缀冲突的 sId 家族、同标签列组、`bCheck/bInvalid` 投影缺失的表单数,均在本地 `xlyweberp_saas`(127.0.0.1:33307)上跑真实 SQL 统计得出。langchain4j 1.14.0 的 `addToMemory` 与抛错/回调次序取自本地 `.m2` 源码;用户消息重复一节用一次性 in-process 探针测试复现后删除。 + +**复核中被证否、故未写入报告的说法**(供读者对照):CORS 可读他人会话历史(`ConversationService.owns` 强制前缀 + 成员校验);`allowCredentials` 是攻击机制(本服务无 cookie 鉴权);`GlobalExceptionHandler` 会回显 JDBC/SQL 文本(`:102` 的 `DataAccessException` 处理器已消毒,且两个被点名的端点对匿名调用先 401/403);`uk_conv_seq` 冲突会导致落账失败(`LedgerService:112-113` 有 3 次重试);`fkOptionPage` 因缺 `sBrandsId` 列而报错(108 个 FK 表全部有该列,真实原因是幻影名称列);`elecustomer` 的 `sParentId` 会与 `sCustomerType` 串写(`sParentId` 在 `SYS_EXACT` 里已被过滤);`quoquotationmaster` 无可解析名称列(字典里有 13 条别名行)。同时提醒:源码与文档中多处仍写 `sStatus='confirmed'`(如 `AgentChatController.java:122-124`、`docs/agent-architecture.md`),`9f1f7fc` 后的实际写入是 `'pending', 100`。 + +**静态不可判定、需动态验证的部分**: + +1. **ERP 侧执行语义**(`xlyEntry`/`saas-8s+`,仓外):`ai_op_queue` 的消费顺序、`bAutoExecute` 是否真的分流(`OpService.insert` 从不设置该列,全部取默认 0,而 `docs/agent-architecture.md:324` 自称「建了但没有分流逻辑」,`:489` 又说报价自动执行)、失败重试与幂等策略。MEDIUM-8/19 的最终后果(两张同号单 vs 执行期失败)取决于此。 +2. **待落地的 SQL 迁移**:`sql/migrate_rearch3.sql` 是否已在生产执行、`ai_chat_event`/`ai_skill` 是否存在。若 `ai_chat_event` 不存在,账本退化为纯 Redis,HIGH-3 与 LOW-28 就是稳态而非异常态。 +3. **ERP 读取投影的真实形状**:`getBusinessDataByFormcustomId` 是否返回 `bCheck/bInvalid`、`bit(1)` 如何序列化为 JSON(本地库 947 个状态列是 bit)。MEDIUM-10 在代码层是确定的 fail-open,但真实触发率取决于此。 +4. **ERP 是否对 `bFilterValue` 做参数化**:`ErpClient.doRead:179-185` 把模型可控文本原样传入过滤条件。若 ERP 侧是字符串拼接,这是一条模型驱动的 SQL 注入;ERP 源码在仓外,未作断言。 +5. **模型端点严格度**:MEDIUM-14/15 造成的非法消息序列在当前 Ollama `/v1` 上只是上下文污染;若 `LLM_BASE_URL` 指向 vLLM/云网关会变成 400。需对目标端点实测。 +6. **多子公司数据**:本地样本库每个品牌只有一个子公司,MEDIUM-4 的跨子公司泄露由 schema + 授权键推导得出,需在真实多子公司租户上实测。 +7. **真实浏览器行为**:LOW-30/31/32/33 与 MEDIUM-7 的有损往返均按 HTML 规范推导,未在浏览器中实际运行;建议用一条带换行的 `sMemo` 记录做端到端回归。 + +**建议的动态回归清单**:(a) 造一条 `备注` 含换行的记录,走一次「改数量」预览并保存,断言 `ai_op_queue` 只多一行;(b) 同租户造两个名字互为子串的客户,走一次「改客户」,断言卡片显示被绑定记录的规范名;(c) 对 `送货通知单` 之类无名称列的表单发起作废预览,断言报错而非返回首行;(d) `redis-cli DEL chat:ledger:*` 后继续对话,断言 `/messages` 仍返回完整历史;(e) 连发两次 `/form/submit`,断言两行的 `sBillNo` 不同或第二次被幂等拒绝;(f) 让模型跑满 9 轮工具后再发一条消息,断言下一次请求里没有无应答的 `tool_call`。 -- libgit2 0.22.2