diff --git a/docs/agent-architecture-report.html b/docs/agent-architecture-report.html new file mode 100644 index 0000000..1fe9383 --- /dev/null +++ b/docs/agent-architecture-report.html @@ -0,0 +1,549 @@ + + + + + +xlyAi Agent 架构报告 + + + + +
+
+

xlyAi Agent 架构报告

+

印刷 ERP 智能助手 —— 单 ReAct Agent · 技能驱动 · 模型全只读 · 人在环写入

+

依据 agent-main 分支当前代码编写,与设计文档不一致处以代码为准。

+
+ Java / LangChain4j + 本地 27B 模型(数据不出网) + MySQL + Redis + 7 个工具 · 5 项技能 + 2026-07-29 +
+
+
+ + + +
+ + +
+
+
PART 1

Agent 的设计

+

xlyAi 是 ERP 之上的「理解与编排」层:听懂业务人员的话,替他们在 1700 多张表单里查数、看单、发起操作。它是一个智能体(无路由、无意图分类),且模型本身没有任何写入能力——改数据的永远是人的一次点击。

+ +

1.1 四条设计立场

+
+
+

① 单智能体,自己决定怎么做

+

所有对话进同一个 ReAct 循环,模型自行决定查数、澄清、载入流程还是出卡。不设意图门——实测无准确率收益,反而在分类环节丢失原话信息。

+
+
+

② 模型全只读,写入是人的动作

+

模型最多把写操作渲染成一张带按钮的卡片,然后停下;真正写入走用户点按钮后的确定性路径,不经过模型。幻觉在结构上碰不到数据。

+
+
+

③ 知识外置:流程是数据,不是代码

+

业务流程写成技能文本存在数据库里,模型按需载入照做;改流程不用改代码发版。领域结构(域、表单、字段)来自离线物化的知识图谱。

+
+
+

④ 薄层:业务权威永远在 ERP

+

读走 ERP 与网页相同的 API,写只投递到一张队列表由 ERP 执行。AI 层不复制业务规则,越薄越不会与 ERP 真实行为漂移。

+
+
+ +

1.2 Agent 解剖

+
+
大 脑 · 知 识 · 能 力 · 记 忆 · 边 界
+
+
+
知识
+
业务域地图11 个域及流转,常驻提示词
+
技能库流程 playbook,按需载入
+
知识图谱表单目录 · 字段字典
+
+
+
+
大脑 + 循环
+
本地 27B 模型(低温度)
+
ReAct 工具循环自选工具与时机,最多 8 步
+
唯一 system prompt角色+域地图+技能索引+硬规则
+
确定性护栏反编造重试 · 谎报纠正
+
+
+
+
能力(全部只读/只渲染)
+
查:找表单 · 读数据 · 查记录
+
问:澄清小问题(可点选项)
+
请求核准:弹表单 / 出预览卡出卡即停,等人点按钮
+
+
+
+
+
记忆
+
会话事件账本(唯一事实源)
+
按 token 预算投影成上下文(见 2.3)
+
+
+
边界
+
身份:ERP token 透传 + 服务端内省,失败即拒
+
权限:表单级白名单 + 租户隔离,随每次调用注入
+
+
+

权限不是模型要遵守的规则,而是注入在每个工具里、它无法越过的边界。

+
+ +

1.3 一轮对话的生命周期

+
+
+
用户发话自由文本
+
+
确定身份token 换真实身份与权限
失败即拒
+
+
组装上下文提示词 + 流程卡 + 记忆投影
+
+
ReAct 循环载技能 / 查数 / 澄清 / 出卡
+
+
护栏检查没查数报数字→重试
谎称已保存→纠正
+
+
收尾给出答复,或出卡即停
+
+

全程流式推送;每个事件按序落入账本,成为下一轮记忆。用户点了卡片按钮,则进入第二部分的确定性写路径。

+
+ +

1.4 能力面:七个工具,四种角色

+
工具集固定且极小:所有轮次共用同一组 7 个工具与同一份提示词。能力面越小,小模型的工具选择越稳,提示前缀稳定也利于推理缓存。
+ + + + + + + + + +
角色工具做什么 / 为什么
载入流程useSkill载入某技能全文。提示词只放一行索引,全文按需加载;载入后钉在流程卡上跨轮生效。
感知查数findForms「某类单据」→ 具体哪张表单。模型不必背 1748 张表结构。
readFormData读某表单一页数据(可过滤、翻页)。答案里的每个数字都必须出自这里,输出截断分页。
lookupRecord某条命名记录的完整字段。多条命中报候选让用户挑,不猜。
对话交互askUser一个澄清问题 + 可点选项。缺关键信息才问、一次问全、问完即停。
请求核准collectForm新增:弹一张表单让用户一次填齐;已说的值预填,客户/产品从真实数据下拉选。
previewChange改/作废/审核等:出预览卡(当前值 + 变化高亮 + ERP 同名按钮)。人核对的就是将要写入的。
+ +

1.5 知识体系三层

+
+
+

域地图(常驻)

+

11 个业务域及上下游流转,每域一行常驻提示词——给模型一张「问题落在哪」的粗地图。

+
+
+

技能(按需载入)

+

每篇只写步骤与停止条件,不写错例枚举。内置五篇:查询、新建报价、新建单据、修改记录、单据状态操作。

+
+
+

知识图谱(工具背后)

+

从 ERP 元数据物化的表单目录与字段字典(中文名⇄技术列⇄外键)。模型不直接看它,但每次定位与解析都踩在它上面。

+
+
+ +

1.6 环境

+
+
+
+
浏览器
+
聊天流 + 卡片/表单/选项片
+
多命名会话侧栏
+
+
流式对话
ERP 登录态透传
+
+
xlyAi(本报告主角)
+
单 ReAct Agent
+
确定性保存端点(人在环写)
+
授权层(补 ERP 缺失的表单级权限)
+
+
只读 API
+ 共享队列表
+
+
ERP 后端(业务权威)
+
表单读 API · 身份内省 · 字段候选
+
写操作执行器 + AI 待办 + 状态回执
+
+
+

支撑设施:MySQL(与 ERP 共库,多租户)、Redis(缓存/暂存)、本地 27B 模型(OpenAI 兼容通道)。接口细节见第三部分,实现映射见附录。

+
+
+
+ + +
+
+
PART 2

核心机制

+

支撑上述设计的四条机制:写路径的层层闸门、事件账本记忆、以及针对小模型不可靠性的确定性护栏。

+ +

2.1 编排:技能文本代替路由与流水线

+
+
    +
  • 规则只有一条:全部自由文本进唯一 ReAct agent,流程知识由模型按需 useSkill 载入。
  • +
  • 不做意图门:分类器不掌握字段定义,抽取必有损(「大16开」→「16开」);原话只应在字段定义存在的地方被解释一次。
  • +
  • 跨轮续办靠流程卡:激活的技能全文钉在提示词尾部,流程终结才摘下——旧轮被压缩也不丢步骤。
  • +
+
+ +

2.2 写路径:三道闸

+
LLM 说什么都改不了数据库。能写的只有两个确定性端点,且最多写一行待办——执行在 ERP 侧。
+
+
以「修 改 记 录」为 例
+
+
用户「把 XX 单数量改成 3000」
+
+
模型(只读)载技能 → previewChange
+
+
闸① 预览卡定位 + 校验
解析产物暂存服务端
+
+
模型停手等用户核对
+
+
+
用户点【保存】
+
+
闸② 确定性端点归属/权限重查 → 重读记录重校验
→ 原子抢占防重复提交
+
+
闸③ 入队ai_op_queue 一行 pending
+
+
ERP 执行报价自动执行
其余进待办
+
+
+
+
+

所见即所写

+

保存时重读记录:被他人改过的值、变了的审核/作废状态一律拒绝,请重新预览。卡上显示的 FK 是实际解析绑定的记录名、数字是规范化后的值——人核对的就是入库的。

+
+
+

状态合法性硬检查

+

审核要求未审核、作废要求未作废等前置条件在预览与保存各查一次,ERP 执行时刻再查一次,三层防竞态。

+
+
+

新增走表单

+

用户填完点保存后由端点构建载荷:中文名→列、FK 名称→id、类型强转(解析不了的数字报错而非写 0)、补必填默认、生成主键与单号。报价单构建主表+从表+多数量三张表,价格留给 ERP 核价。

+
+
+

防重复防并发

+

previewId 一次性、原子抢占,并发点击只有一个能入队;多字段先全部过校验再单事务入队,绝不写一半。

+
+
+ +

2.3 记忆:事件账本 + 四段投影

+
+

会话没有可被并发覆盖的「聊天记录数组」:每句话、每次工具调用、每次按钮点击都是一条追加事件,落 MySQL(Redis 热缓存)。前端历史与 LLM 上下文是同一账本的两种投影,永不对不上。喂给模型的上下文按 token 预算分段组装:

+
+
① system prompt永不让位
+
② 进行中的流程卡激活技能 + 在办单据状态永不让位
+
④ 往事摘要区预算外旧轮 → 每轮一行确定性摘要小额预算
+
⑤ 近期原文区整轮纳入,旧轮工具结果压成短摘要预算内尽量装
+
当前轮原样保真,超限时先压最早的工具结果永不让位
+
+

发送前自检总量,超了继续收缩——绝不让推理端从头部静默截断(那样 system prompt 先死)。估算故意偏高,并与模型返回的实际 token 数持续对账。

+
+ +

2.4 针对小模型的确定性护栏

+
+

反编造

零工具调用却答出数字 → 注入纠正话术自动重试一次;复发则标注「未经核实」。

+

谎报纠正

声称「已保存/已完成」但本轮没出过卡 → 追加提示「没有卡片即没有任何写入」。

+

确定性解析

尺寸拆分(「50*30*5」→长/宽/高)、数字/日期强转、FK 消歧、单号生成全在代码层——模型负责理解意图,代码负责解释取值。

+

资源护栏

工具循环最多 8 步、超时 180 秒、所有工具输出分页/截断。

+
+ +

2.5 安全红线

+ + + + + + + + + +
不变量一句话
身份 fail-closed身份只来自 ERP 服务端内省;token 无效即 401,绝不回落管理员身份。
token 不进模型用户 token 仅单次请求持有,绝不进提示词、账本、暂存。
表单级白名单读/写/下拉共用同一权限边界;鉴权参数用服务端反查值,不信模型自报。
租户隔离所有查询强制品牌谓词,拿不到租户宁可返回空。
会话隔离会话 id 强制用户命名空间——伪造他人 id 只会落到自己名下。
注入防护URL id 白名单校验、SQL 全参数化、CORS 白名单。
读不变写读接口只传分页/过滤参数,绝不透传 ERP 存在的写类参数。
+
+
+ + +
+
+
PART 3

AI 侧 ⇄ ERP 侧:协作与接口

+

分界线一句话:xlyAi 负责理解、定位、校验、请求核准;ERP 负责执行、审计、权属、回执。交接靠同步的薄 HTTP 接口 + 异步的共享队列表。

+ +

3.1 HTTP 接口(xlyAi → ERP,全部透传用户 token)

+ + + + + + + +
接口用途
GET /ai/whoamitoken → 服务端认定的身份与租户,所有端点的身份唯一来源。
POST /business/getBusinessDataByFormcustomId表单数据读取,与网页前端同一接口,天然带租户与行级权限。
GET /getUuid新增载荷的主键由 ERP 发号。
POST /business/checkBusinessDatadry-run 预检:复用 ERP 真实校验链、强制回滚,把「保存了才发现不合法」提前。
POST /ai/fieldOptionsFK 下拉候选,按 ERP 下拉配置取——带行级权限、级联过滤、联动回填;不可用时回落本地字典。
+ +

3.2 ai_op_queue:交接面与状态协议

+
+

用户点按钮后 xlyAi 写一行操作请求(类型 + 目标 + 载荷 + 描述),状态恒为 pending+100;领取、执行、幂等、审计、回写全在 ERP 侧。现行策略:报价自动执行,其余进「我的 AI 待办」。xlyAi 只把状态只读展示在流程卡上,绝不重复提交。

+
+
pending100 已提交(xlyAi 唯一写入)101 ERP 处理中
+
+
success200 成功 · 201 幂等命中
+
/
+
fail400 载荷 · 401 权限 · 404 不存在 · 409 状态冲突422 业务校验 · 423 锁定 · 450 取消 · 500/504
+
+
    +
  • xlyAi 只写 pending+100,其余状态只由 ERP 回写。
  • +
  • 一次保存多字段 = 多行待办(单事务);ERP 执行时刻再验状态,冲突回 409。
  • +
+
+ +
+
职 责 分 界
+
+
+
xlyAi
+
理解意图 · 载入技能 · 定位表单与记录
+
校验:字段/类型/FK/权限/状态/租户
+
渲染卡片,等人点按钮
+
入队 pending —— 到此为止
+
+
ai_op_queue
+
+
ERP 侧
+
执行器以用户身份复用 ERP 原生写入/审核逻辑
+
幂等 · 执行时刻校验 · 单号执行前重新生成
+
审计留痕 · 状态回写 · AI 待办入口
+
+
+
+ +

3.3 身份与授权协作

+
+
+

凭证透传,不自造身份

+

xlyAi 不签发、不存储、不续期任何用户凭证:前端逐请求带 ERP token,xlyAi 原样转发。会话按稳定身份存,重登录换 token 后历史接得回。

+
+
+

授权互补

+

ERP 后端只强制租户 + 行级两层,表单级权限校验处于关闭状态(仅前端 UI 用)。xlyAi 用同一份权限数据在自己侧补回这层,作为纵深防御;根因整改待业务决策。

+
+
+
+
+ + +
+
+
PART 4

质量保障 · 残余风险 · 设计取舍

+ +

4.1 质量保障

+
+

基准评测

50 题冻结基线 + 多轮轨迹基准(消歧/接续/夹带/回指),与生产读同一份提示词和技能文件。当前:扩展集 12/12,全轨迹 84%。

+

单元测试

8 个测试类钉住不变量:fail-closed、会话归属、投影正确性、并发不丢事件、强转拒非法值等。

+

对抗性审计

多智能体并行审计 + 独立复核证否,最近一次全量复审 CRITICAL 0 / HIGH 3,HIGH 已全部修复回归。

+
+

可观测性

每次 LLM 调用记录耗时/token/错误,估算与实际 token 持续对账;可选导出自托管 Langfuse(数据不出网)。业务审计随执行权在 ERP 侧。

+ +

4.2 残余风险(交付说明)

+ + + + + + + + + + + +
级别事项一句话
安全技能表是提示词注入面谁能写 ai_skill 谁就能改 agent 行为,需收紧表写权限。
安全dry-run 失败放行刻意取舍:预检挂了不挡用户,ERP 执行时仍会真校验。
安全token URL 通道 / 生产前置校验壳层嵌入的 token 传递与 nginx 前置会话校验属环境决策项。
运维账本永久保留append-only 永不删除,需定保留/归档策略。
协作队列旧列待删 / 生产迁移待执行等 ERP 去掉兼容读后删列;4 份迁移 SQL 待上生产。
协作「处理中」卡死无恢复ERP 领取后异常退出会停在 101,需 ERP 侧超时补偿。
根因ERP 权限根因未整改xlyAi 的白名单是纵深防御,不替代后端重开表单级校验。
功能无语义召回表单定位全靠关键词匹配,模糊说法要换词重试。
功能策展字段仅报价有其他复杂主-从单据的新增走字典启发式,体验有限。
+ +

4.3 贯穿全局的取舍

+
    +
  • 不引编排框架:人在环用「出卡即停 + 确定性端点」实现,比中断/续跑机制更简单,也更安全——模型根本没有写工具。
  • +
  • 可靠性放代码不放提示词:不写逐案纠错规则,靠确定性解析 + 廉价恢复 + 回归基准。
  • +
  • 执行权交出去:AI 止步于入队,写入发生在 ERP 原生路径——代码量与攻击面同时缩小。
  • +
  • 处处幂等:所有交接点都假设会被重复触发(一次性抢占、追加原子、幂等键、单号重生成)。
  • +
+ +

附录:设计概念 → 代码映射

+ + + + + + + + + + + + +
设计概念主要代码位置
ReAct 循环 / 按请求组装agent/ReActAgent · config/AgentFactory · agent/AgentIdentity
对话入口 / SSE / 护栏web/AgentChatController
7 个工具tool/:UseSkillTool · KgQueryTool · ErpReadTool · FormCollectTool · PreviewChangeTool · InteractionTool
提示词 / 技能service/SystemPromptServiceprompts/system.txt)· service/SkillServiceai_skill / skills/*.md
表单/字段/FK 解析校验service/FormResolverService · service/FormRenderServiceviw_ai_useful_forms / viw_kg_field_dict
预览/保存闭环 / 唯一写入口service/PreviewService · web/PreviewController · service/OpServiceai_op_queue
账本与记忆投影service/LedgerServiceai_chat_event)· agent/EventLogChatMemory · service/EventProjectionService
身份与授权 / ERP 客户端service/AuthzService · service/ErpClient
会话 / FK 选项service/ConversationService · web/ConversationController · web/FormController
可观测性config/TracingChatModelListener
+
+
+ +
+ + + + + diff --git a/docs/agent-architecture.md b/docs/agent-architecture.md index c424bea..1ef276b 100644 --- a/docs/agent-architecture.md +++ b/docs/agent-architecture.md @@ -373,7 +373,12 @@ ai_op_queue( - **确定性表单提交**:`POST /api/agent/form/submit` 结构化字段直达 `proposeWrite(action=create)`,不经 LLM 再编码 (旧的 NL 标记路径已删)。 -- `GET /api/agent/form/options`:多列(名称+按使用度选 3 个展示列)+ 名称搜索 + 分页,FK 二级选择器数据源。 +- `GET /api/agent/form/options`:FK 二级选择器数据源。**优先走 ERP 的下拉控件配置** + (`POST /ai/fieldOptions`,带行级数据权限、级联过滤、联动回填映射;契约见 + `docs/erp-tasks-field-options.md`),端点未上线/该字段无配置/异常时回落本地字典查询 + (多列 = 名称 + 按使用度选 3 个展示列,名称搜索 + 分页 + 强制租户谓词)。 + 用户点中某一行后,其 id 随保存回传(`boundIds`)并被直接采信——同名记录不再有歧义, + 权限判定以 ERP 返回的候选为准;未经选择、仅凭名称的路径仍保持同名 fail-closed。 - `askUser` 返回 `allowFree=true`:前端选项片之外永远保留自由输入(Claude Code 风格)。 - `chat.html` 整页重写:原生 JS(去 jQuery)、全视口、可折叠会话侧栏、单滚动区、markdown 表格、提议卡片、 collectForm 网格布局 + 类型化控件 + FK 选择器模态。 diff --git a/docs/erp-tasks-field-options.md b/docs/erp-tasks-field-options.md index 67f1f15..1cd8724 100644 --- a/docs/erp-tasks-field-options.md +++ b/docs/erp-tasks-field-options.md @@ -154,7 +154,14 @@ Authorization: <用户 token> // 与现有 /ai/* 一致,@Authorization ## 我方对应改动(供你们了解,不用你们做) -拿到这个端点后,xlyAi 的 `/api/agent/form/options` 会改成: -优先按 `(formId, 字段)` 走你们的新端点;`mode:"none"` 或网络异常时回落到现有的字典直查(保底不影响可用性)。 -`need_context` 会转成"请先选客户"的提示。`assign` 会并入创建载荷。 -涉及文件:`FormController` / `FormResolverService.fkOptionPage` / `FormRenderService.buildQuote`。 +已实现(2026-07-29):`/api/agent/form/options` 优先按 `(formId, 字段)` 走你们的新端点, +`mode:"none"`/端点未上线/网络异常时回落到本地字典直查(保底不影响可用); +`need_context` 转成"请先选择客户"的提示;FK 选择器把表单上已绑定的记录 id 作为 `context` 带上。 + +**用户在选择器里点中某一行后,我们直接采信该行的 id 写入载荷**(不再按名称反查)—— +即候选的权限、租户、级联判定完全以你们返回的结果为准。因此**候选列表里出现的记录, +就等于允许该用户绑定的记录**,请确保这两件事在你们那边是同一回事。 + +`assign` 联动回填尚未接(等端点上线后做)。 +涉及文件:`FormController` / `ErpClient.fieldOptions` / `FormRenderService.normalize|buildCreate` / +`PreviewService.save|saveCreate`。 diff --git a/src/main/java/com/xly/service/FormRenderService.java b/src/main/java/com/xly/service/FormRenderService.java index 3ccfec5..19436bc 100644 --- a/src/main/java/com/xly/service/FormRenderService.java +++ b/src/main/java/com/xly/service/FormRenderService.java @@ -251,6 +251,10 @@ public class FormRenderService { public String shown; } + private static String boundIdOf(Map boundIds, String col) { + return boundIds == null ? null : boundIds.get(col); + } + /** * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、 * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在 @@ -258,12 +262,27 @@ public class FormRenderService { * 预览与保存共用同一份逻辑;FK 的 {@code shown} = **实际解析到的记录名**("所见即所写")。 */ public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) { + return normalize(table, col, fk, label, value, null, identity); + } + + /** + * 同上;{@code boundId} 非空时表示用户是在**选择器里点中了具体一行**(候选由 ERP 按其下拉配置给出, + * 权限/租户/级联均已在那里裁定),此时直接采信该 id,不再按名称反查——同名记录因此不再有歧义。 + */ + public Normalized normalize(String table, String col, String fk, String label, String value, + String boundId, AgentIdentity identity) { Normalized n = new Normalized(); Map types = resolver.columnTypes(table); if (!types.containsKey(col)) { n.error = "「" + label + "」不是「" + table + "」的可写字段,请换个字段名。"; return n; } + if (fk != null && !fk.isBlank() && boundId != null && !boundId.isBlank()) { + n.stored = boundId.trim(); + String name = fkDisplayName(fk, boundId.trim()); + n.shown = name == null || name.isBlank() || name.equals(boundId.trim()) ? value : name; + return n; + } if (fk != null && !fk.isBlank()) { List> cands = resolver.resolveFkCandidates(fk, identity.brandsId(), value, 5); if (cands.isEmpty()) { @@ -358,6 +377,12 @@ public class FormRenderService { * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。 */ public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map fields) { + return buildCreate(identity, entityKeyword, fields, null); + } + + /** 同上;{@code boundIds}(技术列名→id)来自选择器里点中的行,FK 列据此直接绑定,不按名称反查。 */ + public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map fields, + Map boundIds) { CreateBuild out = new CreateBuild(); if (isBlank(entityKeyword)) { out.error = "缺少单据类型。"; @@ -376,7 +401,7 @@ public class FormRenderService { return out; } if ("quoquotationmaster".equalsIgnoreCase(out.table)) { - return buildQuote(identity, entityKeyword, fields, out); + return buildQuote(identity, entityKeyword, fields, boundIds, out); } Map types = resolver.columnTypes(out.table); @@ -404,7 +429,7 @@ public class FormRenderService { continue; // 制单人/单据日期/租户/单号…由 ERP 注入 } String fk = fm.get("fk") == null ? null : str(fm.get("fk")); - Normalized n = normalize(out.table, colName, fk, zh, v, identity); + Normalized n = normalize(out.table, colName, fk, zh, v, boundIdOf(boundIds, colName), identity); if (n.error != null) { out.error = n.error + (fk != null ? "(**不要**因此代建" + zh.replace("名称", "") + ")" : ""); return out; @@ -445,7 +470,7 @@ public class FormRenderService { * 价格由 ERP【核价】计算,只落主-从明细。 */ private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword, - Map fields, CreateBuild out) { + Map fields, Map boundIds, CreateBuild out) { Map masterTypes = resolver.columnTypes(out.table); Map slaveTypes = resolver.columnTypes("quoquotationslave"); Map> labelMap = new LinkedHashMap<>(); @@ -495,7 +520,7 @@ public class FormRenderService { Map tt = "slave".equals(tgt) ? slaveTypes : masterTypes; Normalized n; if (fk != null && !fk.isBlank()) { - n = normalize(out.table, colName, fk, zh, v, identity); + n = normalize(out.table, colName, fk, zh, v, boundIdOf(boundIds, colName), identity); if (n.error != null) { out.error = n.error + "(请从下拉里选真实存在的记录;**不要**代建,也不要把产品/规格当成客户)"; return out; diff --git a/src/main/java/com/xly/service/PreviewService.java b/src/main/java/com/xly/service/PreviewService.java index 2e0de50..1814ad0 100644 --- a/src/main/java/com/xly/service/PreviewService.java +++ b/src/main/java/com/xly/service/PreviewService.java @@ -290,6 +290,12 @@ public class PreviewService { * 返回 {queued,opIds,description,message} 或 {error}(校验失败时预览保留,可修正后重试)。 */ public Map save(AgentIdentity who, String previewId, Map editedFields) { + return save(who, previewId, editedFields, null); + } + + /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id),有则直接绑定不按名称反查。 */ + public Map save(AgentIdentity who, String previewId, Map editedFields, + Map boundIds) { Map out = new LinkedHashMap<>(); JsonNode draft = unstash(previewId); if (draft == null) { @@ -314,7 +320,7 @@ public class PreviewService { return out; } return "update".equals(action) - ? saveUpdate(who, draft, editedFields, cur, convId, previewId) + ? saveUpdate(who, draft, editedFields, boundIds, cur, convId, previewId) : saveStateOp(who, draft, cur, convId, action, previewId); } @@ -322,7 +328,8 @@ public class PreviewService { private record Change(String col, String label, String wasShown, Object stored, String shown) { } private Map saveUpdate(AgentIdentity who, JsonNode draft, Map editedFields, - FormRenderService.Located cur, String convId, String previewId) { + Map boundIds, FormRenderService.Located cur, + String convId, String previewId) { Map out = new LinkedHashMap<>(); String table = draft.path("table").asText(""); String recordName = draft.path("recordName").asText(""); @@ -377,7 +384,8 @@ public class PreviewService { + (fk != null ? render.fkDisplayName(fk, rawNow) : rawNow) + "」),已拒绝保存,请重新预览。"); return out; } - FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, who); + FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, + boundIds == null ? null : boundIds.get(col), who); if (n.error != null) { out.put("error", n.error); return out; @@ -482,8 +490,14 @@ public class PreviewService { /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */ public Map saveCreate(AgentIdentity who, String convId, String entity, Map fields) { + return saveCreate(who, convId, entity, fields, null); + } + + /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id)。 */ + public Map saveCreate(AgentIdentity who, String convId, String entity, + Map fields, Map boundIds) { Map out = new LinkedHashMap<>(); - FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields); + FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields, boundIds); if (b.error != null) { out.put("error", b.error); return out; diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 952ec83..ef7c072 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -115,6 +115,7 @@ public class AgentChatController { public static class FormSubmitReq { public String entity; public Map fields; // 字段中文名 -> 值 + public Map boundIds; // 外键字段技术名 -> 选择器里点中的记录 id public String conversationId; public String authorization; } @@ -152,7 +153,7 @@ public class AgentChatController { ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()), identity); try { - Map r = previews.saveCreate(identity, convId, entity, fields); + Map r = previews.saveCreate(identity, convId, entity, fields, req.boundIds); if (r.containsKey("error")) { ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity); } diff --git a/src/main/java/com/xly/web/PreviewController.java b/src/main/java/com/xly/web/PreviewController.java index ca5bc33..bc9c5f9 100644 --- a/src/main/java/com/xly/web/PreviewController.java +++ b/src/main/java/com/xly/web/PreviewController.java @@ -39,6 +39,8 @@ public class PreviewController { public static class SaveReq { /** 可编辑预览(update)上用户最终确认的 字段技术名->值(空值=放弃该字段的改动);状态类操作可空。 */ public Map fields; + /** 外键字段在选择器里点中的记录 id(字段技术名->id):有则直接绑定,不按名称反查。 */ + public Map boundIds; } @PostMapping("/{id}/save") @@ -49,6 +51,6 @@ public class PreviewController { if (me == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); } - return previews.save(me, id, req == null ? null : req.fields); + return previews.save(me, id, req == null ? null : req.fields, req == null ? null : req.boundIds); } } diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index e12e91b..19bb64b 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -666,7 +666,7 @@ function addPreviewCard(ev) { const fields = {}; controls.forEach(c => { fields[c.name] = c.get(); }); const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save", - { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) }); + { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields, boundIds: bound }) }); const d = await r.json(); if (d.queued) { result.className = "result ok"; @@ -820,6 +820,7 @@ function addFormCard(ev) { body: JSON.stringify({ entity: ev.entity, fields: fields, + boundIds: bound, conversationId: conversationId }) }); diff --git a/src/test/java/com/xly/service/FormRenderNormalizeTest.java b/src/test/java/com/xly/service/FormRenderNormalizeTest.java index a1b4178..010d7b4 100644 --- a/src/test/java/com/xly/service/FormRenderNormalizeTest.java +++ b/src/test/java/com/xly/service/FormRenderNormalizeTest.java @@ -92,6 +92,28 @@ class FormRenderNormalizeTest { } @Test + void pickedRowIdWinsOverNameLookup() { + // 用户在选择器里点中了具体一行(候选由 ERP 按其下拉配置给出,权限/租户/级联已在那里裁定): + // 直接采信该 id —— 同名多条不再有歧义,也不再走名称反查 + FormResolverService r = resolverWith(Map.of("sProductId", "varchar"), List.of( + Map.of("sId", "prodA", "name", "普通彩盒"), + Map.of("sId", "prodB", "name", "普通彩盒"))); + FormRenderService.Normalized n = svc(r).normalize( + "t", "sProductId", "eleproduct", "产品名称", "普通彩盒", "prodB", WHO); + assertNull(n.error, "点中了具体一行就不该再报同名歧义"); + assertEquals("prodB", n.stored); + } + + @Test + void pickedRowIdStillRejectsPhantomColumn() { + FormResolverService r = resolverWith(Map.of("dQty", "decimal"), List.of()); + FormRenderService.Normalized n = svc(r).normalize( + "t", "sGhostCol", "eleproduct", "产品名称", "普通彩盒", "prodB", WHO); + assertNotNull(n.error, "幻影列硬闸在 id 直绑路径上同样生效"); + assertNull(n.stored); + } + + @Test void numericColumnStillCoerces() { FormResolverService real = mock(FormResolverService.class); when(real.columnTypes(anyString())).thenReturn(Map.of("dQty", "decimal"));