Commit c05925803400b42a3716a9c670353da6de1b3d5e

Authored by zichun
1 parent f3086c3c

fix(security): server-side identity introspection, fail-closed auth, op ownershi…

…p+CAS, tenant enforcement

Audit findings 1-5 confirmed real; root causes were (a) identity self-reported by
the client, (b) every failure path defaulting open.

- ERP /ai/whoami (saas-8s+ @c8e0057 follow-up) resolves token → real user/tenant/type;
  AuthzService.resolveIdentity is now the single identity entry point. Client-supplied
  userid/brandsid/usertype removed from all request bodies and from chat.html.
- dev-login is a real switch (erp.dev-login.enabled, default false) and its ERP creds
  no longer have built-in admin/666666 defaults; blank token in production → 401,
  never a silent fall back to the dev (sysadmin) account.
- conversations/op/form endpoints require login; conversation ids are namespaced by
  user id and ownership-checked (403 otherwise); /op/pending no longer returns sPayload.
- op confirm/cancel check the proposer, and confirm claims the draft via CAS so
  concurrent/repeat confirms cannot execute twice; bill numbers are regenerated at
  execution time instead of replaying the propose-time snapshot.
- FK options take the tenant from the introspected identity and return empty rather
  than dropping the sBrandsId predicate.
- secrets moved to env vars (DB_URL/DB_USERNAME/DB_PASSWORD/REDIS_*/LLM_*/ERP_BASEURL);
  allowMultiQueries=false. NOTE: the previously committed credentials must be rotated.
- ids interpolated into ERP URLs are validated (safeId) to stop query/path injection.
- update path rejects system columns, resolves FK names, and coerces by column type;
  numeric coercion now rejects unparseable input instead of writing 0/truncating, and
  proposal summaries show the value that will actually be written.
- ResponseStatusException keeps its 401/403 status instead of being wrapped as 200.
- anti-fabrication guard stays on when the intent gate itself fails.
src/main/java/com/xly/agent/Intent.java
... ... @@ -35,6 +35,7 @@ public class Intent {
35 35 }
36 36  
37 37 public String intent = OTHER;
  38 + public boolean failed = false; // true = 意图门本身失败(模型不可达/解析异常),非真实「其他」
38 39 public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单
39 40 public List<Entity> entities = new ArrayList<>();
40 41 public List<String> missing = new ArrayList<>(); // 完成该意图还缺的关键信息
... ...
src/main/java/com/xly/exception/GlobalExceptionHandler.java
... ... @@ -7,8 +7,10 @@ import lombok.extern.slf4j.Slf4j;
7 7 import org.springframework.dao.DataAccessException;
8 8 import org.springframework.dao.DataIntegrityViolationException;
9 9 import org.springframework.dao.DuplicateKeyException;
  10 +import org.springframework.http.ResponseEntity;
10 11 import org.springframework.http.converter.HttpMessageConversionException;
11 12 import org.springframework.jdbc.BadSqlGrammarException;
  13 +import org.springframework.web.server.ResponseStatusException;
12 14 import org.springframework.web.HttpMediaTypeNotSupportedException;
13 15 import org.springframework.web.HttpRequestMethodNotSupportedException;
14 16 import org.springframework.web.bind.MethodArgumentNotValidException;
... ... @@ -33,6 +35,17 @@ public class GlobalExceptionHandler {
33 35 return body;
34 36 }
35 37  
  38 + /**
  39 + * 鉴权类异常按**真实 HTTP 状态**返回(401/403),不被下面的兜底包装成 200。
  40 + * 否则客户端/网关/日志都看不出这是一次被拒绝的越权访问。
  41 + */
  42 + @ExceptionHandler(ResponseStatusException.class)
  43 + public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException e) {
  44 + log.warn("拒绝访问 {}: {}", e.getStatusCode(), e.getReason());
  45 + return ResponseEntity.status(e.getStatusCode())
  46 + .body(error(e.getStatusCode().value(), e.getReason() == null ? "拒绝访问" : e.getReason()));
  47 + }
  48 +
36 49 @ExceptionHandler(Exception.class)
37 50 public Map<String, Object> handleException(HttpServletRequest request, Exception e) {
38 51 log.error("请求地址: {}, 全局异常: ", request.getRequestURI(), e);
... ...
src/main/java/com/xly/service/AuthzService.java
... ... @@ -13,25 +13,28 @@ import java.util.Set;
13 13 import java.util.stream.Collectors;
14 14  
15 15 /**
16   - * xlyAi 侧授权层 —— 表单级白名单(架构 §7)。
  16 + * xlyAi 侧身份与授权层(架构 §7)。
17 17 *
18   - * <p>后端逐用户表单权限被关(只验登录 + 租户 + 行级),所以在 agent 侧按用户实际授权把可碰的
19   - * 表单/菜单限制住。数据源与后端一致(`sysjurisdiction`,即 `getsAuthsIdNew` 的逻辑)——不新造。
  18 + * <p><b>身份唯一来源 = {@link #resolveIdentity}</b>:透传的用户 token 经 ERP {@code /ai/whoami}
  19 + * **服务端内省**换取真实的 userId/租户/用户类型——客户端自报的 userid/brandsid/usertype 一概不信。
  20 + * token 缺失或无效时:仅本地开发({@code erp.dev-login.enabled=true})回落 dev 身份;
  21 + * 生产一律返回 null,调用方必须拒绝(fail-closed,绝不降级成 dev/管理员)。
20 22 *
21   - * <p>管理员(sysadmin/admin) = 全部权限(返回 null);否则 = 该用户有权的 id 集合(菜单/表单/按钮,
22   - * 由 `sysjurisdiction.sKey` 按 '-' 拆出)。当前本地 dev-login 以 admin 身份运行,因此实际全通;
23   - * 生产透传用户 token 时即按各用户真实授权收紧。Read / Query / Write 共用此边界。
  23 + * <p>授权 = 表单级白名单:后端逐用户表单权限被关(只验登录 + 租户 + 行级),所以在 agent 侧按用户
  24 + * 实际授权(`sysjurisdiction`,即 `getsAuthsIdNew` 的逻辑)限制可碰的表单/菜单。
  25 + * 管理员(sysadmin/admin) = 全部权限(返回 null);Read / Write 共用此边界。
24 26 */
25 27 @Service
26 28 public class AuthzService {
27 29  
28 30 private final JdbcTemplate jdbc;
  31 + private final ErpClient erp;
29 32  
30   - @Value("${erp.dev-login.username:admin}")
  33 + @Value("${erp.dev-login.username:}")
31 34 private String devUserNo;
32   - @Value("${erp.dev-login.brand:1111111111}")
  35 + @Value("${erp.dev-login.brand:}")
33 36 private String devBrand;
34   - @Value("${erp.dev-login.subsidiary:1111111111}")
  37 + @Value("${erp.dev-login.subsidiary:}")
35 38 private String devSub;
36 39 @Value("${erp.dev-login.usertype:sysadmin}")
37 40 private String devUserType;
... ... @@ -39,28 +42,41 @@ public class AuthzService {
39 42 @Value("${erp.dev-login.userid:}")
40 43 private String devUserIdOverride;
41 44  
42   - public AuthzService(JdbcTemplate jdbc) {
  45 + public AuthzService(JdbcTemplate jdbc, ErpClient erp) {
43 46 this.jdbc = jdbc;
  47 + this.erp = erp;
44 48 }
45 49  
46 50 /**
47   - * 构造 dev-login(本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析
48   - * (admin → null = 全部)。
  51 + * 服务端身份解析(所有端点的唯一入口)。token 有效 → 内省出的真实身份;
  52 + * token 无效/过期 → null(**不**回落 dev,防止过期用户被静默提权);
  53 + * token 缺失 → dev-login 开启时 dev 身份,否则 null。
49 54 */
50   - public com.xly.agent.AgentIdentity devIdentity() {
51   - String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId();
52   - Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub);
53   - return new com.xly.agent.AgentIdentity(null, uid, devBrand, granted);
  55 + public com.xly.agent.AgentIdentity resolveIdentity(String authorization) {
  56 + String token = authorization == null ? "" : authorization.trim();
  57 + if (!token.isBlank()) {
  58 + com.fasterxml.jackson.databind.JsonNode w = erp.whoami(token);
  59 + if (w == null) {
  60 + return null;
  61 + }
  62 + String userId = w.path("sId").asText("");
  63 + String brandsId = w.path("sBrandsId").asText("");
  64 + String subsidiaryId = w.path("sSubsidiaryId").asText("");
  65 + String userType = w.path("sType").asText("");
  66 + Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId);
  67 + return new com.xly.agent.AgentIdentity(token, userId, brandsId, granted);
  68 + }
  69 + return erp.devLoginEnabled() ? devIdentity() : null;
54 70 }
55 71  
56 72 /**
57   - * 构造透传的真实用户身份:token = 用户浏览器里的 ERP 登录 token(转发给 ERP),权限集按该用户
58   - * 真实授权({@code sAuthsId})解析。用于生产环境按各用户真实权限收紧。
  73 + * dev-login(仅本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析
  74 + * (admin → null = 全部)。生产不可达(resolveIdentity 已按开关拦截)。
59 75 */
60   - public com.xly.agent.AgentIdentity userIdentity(String token, String userId,
61   - String brandsId, String subsidiaryId, String userType) {
62   - Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId);
63   - return new com.xly.agent.AgentIdentity(token, userId, brandsId, granted);
  76 + public com.xly.agent.AgentIdentity devIdentity() {
  77 + String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId();
  78 + Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub);
  79 + return new com.xly.agent.AgentIdentity(null, uid, devBrand, granted);
64 80 }
65 81  
66 82 /** null = 全部(管理员);否则 = 有权的 id 集合。 */
... ...
src/main/java/com/xly/service/ConversationService.java
... ... @@ -39,13 +39,45 @@ public class ConversationService {
39 39 this.state = state;
40 40 }
41 41  
42   - /** 新建一个空会话,返回 convId。 */
  42 + /**
  43 + * 把客户端给的 conversationId 绑定到**服务端身份**:真实 id = {userId}:{客户端 id 的清洗值}。
  44 + * 于是伪造别人的 conversationId 只会落到自己命名空间下的新会话,碰不到他人记忆/账本。
  45 + */
  46 + public String scopedId(com.xly.agent.AgentIdentity identity, String rawConvId) {
  47 + String uid = identity == null || identity.userId() == null || identity.userId().isBlank()
  48 + ? "anon" : identity.userId();
  49 + String raw = rawConvId == null ? "" : rawConvId.trim();
  50 + // 已经是本人命名空间下的 id → 原样用;否则取其局部名(去掉别人的前缀)再挂到自己名下
  51 + if (raw.startsWith(uid + ":")) {
  52 + return raw;
  53 + }
  54 + String local = raw.isEmpty() ? "default" : raw.replace(':', '_');
  55 + if (local.length() > 64) {
  56 + local = local.substring(0, 64);
  57 + }
  58 + return uid + ":" + local;
  59 + }
  60 +
  61 + /** 新建一个空会话,返回 convId(已绑定该用户命名空间)。 */
43 62 public String create(String userId) {
44   - String convId = "c-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
45   - writeMeta(userId, convId, "新会话");
  63 + String uid = userId == null || userId.isBlank() ? "anon" : userId;
  64 + String convId = uid + ":c-" + System.currentTimeMillis()
  65 + + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
  66 + writeMeta(uid, convId, "新会话");
46 67 return convId;
47 68 }
48 69  
  70 + /** 会话是否属于该用户(元数据存在 + id 前缀绑定,双重判定)。 */
  71 + public boolean owns(String userId, String convId) {
  72 + if (userId == null || userId.isBlank() || convId == null || convId.isBlank()) {
  73 + return false;
  74 + }
  75 + if (!convId.startsWith(userId + ":")) {
  76 + return false;
  77 + }
  78 + return redis.opsForHash().hasKey(CONVS_KEY + userId, convId);
  79 + }
  80 +
49 81 /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */
50 82 public void touch(String userId, String convId, String firstMsg) {
51 83 Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId);
... ...
src/main/java/com/xly/service/ErpClient.java
... ... @@ -43,13 +43,16 @@ public class ErpClient {
43 43  
44 44 @Value("${erp.baseurl}")
45 45 private String baseUrl;
46   - @Value("${erp.dev-login.brand:1111111111}")
  46 + /** 仅本地开发可为 true;生产必须 false(默认)——否则空 token 会静默以 dev 账号(管理员)执行。 */
  47 + @Value("${erp.dev-login.enabled:false}")
  48 + private boolean devLoginEnabled;
  49 + @Value("${erp.dev-login.brand:}")
47 50 private String brand;
48   - @Value("${erp.dev-login.subsidiary:1111111111}")
  51 + @Value("${erp.dev-login.subsidiary:}")
49 52 private String subsidiary;
50   - @Value("${erp.dev-login.username:admin}")
  53 + @Value("${erp.dev-login.username:}")
51 54 private String username;
52   - @Value("${erp.dev-login.password:666666}")
  55 + @Value("${erp.dev-login.password:}")
53 56 private String password;
54 57  
55 58 private volatile String cachedToken;
... ... @@ -58,8 +61,16 @@ public class ErpClient {
58 61 this.mapper = mapper;
59 62 }
60 63  
61   - /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。 */
  64 + /** 是否允许 dev-login 兜底(生产 false → 所有无 token 调用 fail-closed)。 */
  65 + public boolean devLoginEnabled() {
  66 + return devLoginEnabled;
  67 + }
  68 +
  69 + /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。仅 dev-login 开启时可用。 */
62 70 private synchronized String login() {
  71 + if (!devLoginEnabled) {
  72 + throw new IllegalStateException("缺少用户登录 token(dev-login 已禁用)");
  73 + }
63 74 try {
64 75 String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary;
65 76 String body = mapper.writeValueAsString(Map.of("username", username, "password", password));
... ... @@ -90,6 +101,18 @@ public class ErpClient {
90 101 return (t != null && !t.isBlank()) ? t : login();
91 102 }
92 103  
  104 + /**
  105 + * 拼进 URL 的 id(formId/moduleId/opId)必须是纯 id 形态。这些值可能来自模型输出,
  106 + * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。
  107 + */
  108 + private static String safeId(String id) {
  109 + String s = id == null ? "" : id.trim();
  110 + if (s.isEmpty() || s.length() > 64 || !s.matches("[A-Za-z0-9_.-]+")) {
  111 + throw new IllegalArgumentException("非法的 id 参数: " + id);
  112 + }
  113 + return s;
  114 + }
  115 +
93 116 /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */
94 117 private String resolveToken(String override) {
95 118 return (override != null && !override.isBlank()) ? override : token();
... ... @@ -105,10 +128,30 @@ public class ErpClient {
105 128 return override == null || override.isBlank();
106 129 }
107 130  
108   - /** 读取某表单一页数据(dev-login token,兼容旧调用)。 */
109   - public JsonNode readForm(String formId, String moduleId, int page, int pageSize,
110   - String filterField, String filterValue) {
111   - return readForm(null, formId, moduleId, page, pageSize, filterField, filterValue);
  131 + /**
  132 + * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。
  133 + * 返回 {sId,sUserNo,sUserName,sType,sBrandsId,sSubsidiaryId};token 无效/过期返回 null。
  134 + */
  135 + public JsonNode whoami(String authToken) {
  136 + if (authToken == null || authToken.isBlank()) {
  137 + return null;
  138 + }
  139 + try {
  140 + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/ai/whoami"))
  141 + .header("Content-Type", "application/json;charset=UTF-8")
  142 + .header("Authorization", authToken)
  143 + .timeout(Duration.ofSeconds(20))
  144 + .GET()
  145 + .build();
  146 + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  147 + JsonNode root = mapper.readTree(resp.body());
  148 + JsonNode row = root.has("sId") ? root : root.path("dataset").path("rows").path(0);
  149 + String uid = row.path("sId").asText("");
  150 + return uid.isBlank() ? null : row;
  151 + } catch (Exception e) {
  152 + log.warn("whoami failed: {}", e.getMessage());
  153 + return null;
  154 + }
112 155 }
113 156  
114 157 /**
... ... @@ -129,8 +172,8 @@ public class ErpClient {
129 172 private JsonNode doRead(String formId, String moduleId, int page, int pageSize,
130 173 String filterField, String filterValue, String tok) {
131 174 try {
132   - String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + formId
133   - + "?sModelsId=" + moduleId + "&sName=";
  175 + String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + safeId(formId)
  176 + + "?sModelsId=" + safeId(moduleId) + "&sName=";
134 177 List<Map<String, Object>> bFilter = new ArrayList<>();
135 178 if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) {
136 179 Map<String, Object> f = new LinkedHashMap<>();
... ... @@ -214,7 +257,7 @@ public class ErpClient {
214 257  
215 258 private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) {
216 259 try {
217   - String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + moduleId;
  260 + String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + safeId(moduleId);
218 261 Map<String, Object> body = new LinkedHashMap<>();
219 262 body.put("sId", List.of(billId)); // 后端 sId 期望 List<String>
220 263 body.put("sTableName", table);
... ... @@ -243,7 +286,7 @@ public class ErpClient {
243 286 */
244 287 public JsonNode execStaging(String authToken, String opId) {
245 288 try {
246   - String url = baseUrl + "/ai/execStaging/" + opId;
  289 + String url = baseUrl + "/ai/execStaging/" + safeId(opId);
247 290 HttpRequest req = HttpRequest.newBuilder(URI.create(url))
248 291 .header("Content-Type", "application/json;charset=UTF-8")
249 292 .header("Authorization", resolveToken(authToken))
... ... @@ -265,7 +308,7 @@ public class ErpClient {
265 308  
266 309 private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) {
267 310 try {
268   - String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId;
  311 + String url = baseUrl + "/business/doExamine?sModelsId=" + safeId(moduleId);
269 312 Map<String, Object> paramsMap = new LinkedHashMap<>();
270 313 paramsMap.put("sFormGuid", moduleId);
271 314 paramsMap.put("sGuid", billId);
... ... @@ -287,7 +330,7 @@ public class ErpClient {
287 330  
288 331 private JsonNode doDelete(String moduleId, String table, String billId, String tok) {
289 332 try {
290   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  333 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
291 334 Map<String, Object> col = new LinkedHashMap<>();
292 335 col.put("handleType", "del");
293 336 col.put("sId", billId);
... ... @@ -359,7 +402,7 @@ public class ErpClient {
359 402 @SuppressWarnings("unchecked")
360 403 private JsonNode doCreateMulti(String moduleId, List<Map<String, Object>> tables, String tok) {
361 404 try {
362   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  405 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
363 406 List<Map<String, Object>> data = new ArrayList<>();
364 407 for (Map<String, Object> t : tables) {
365 408 Map<String, Object> col = new LinkedHashMap<>((Map<String, Object>) t.get("column"));
... ... @@ -388,7 +431,7 @@ public class ErpClient {
388 431  
389 432 private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) {
390 433 try {
391   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  434 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
392 435 Map<String, Object> col = new LinkedHashMap<>(columns);
393 436 col.put("handleType", "add");
394 437 Map<String, Object> dataItem = new LinkedHashMap<>();
... ... @@ -413,7 +456,7 @@ public class ErpClient {
413 456  
414 457 private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) {
415 458 try {
416   - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  459 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
417 460 Map<String, Object> col = new LinkedHashMap<>();
418 461 col.put("handleType", "update");
419 462 col.put("sId", billId);
... ...
src/main/java/com/xly/service/FormResolverService.java
... ... @@ -201,6 +201,10 @@ public class FormResolverService {
201 201 out.put("total", 0);
202 202 out.put("page", Math.max(1, page));
203 203 out.put("pageSize", pageSize);
  204 + // 租户谓词是强制的:拿不到 brand 宁可返回空,也绝不退化成全库查询
  205 + if (brand == null || brand.isBlank()) {
  206 + return out;
  207 + }
204 208 try {
205 209 Integer ok = jdbc.queryForObject(
206 210 "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable);
... ... @@ -280,15 +284,24 @@ public class FormResolverService {
280 284 return m;
281 285 }
282 286  
283   - /** 按列类型把用户给的字符串强转成合法值:数值取数字、日期非法则丢弃(交 ERP 默认)、bit→0/1、其余原样。 */
  287 + /**
  288 + * 按列类型把用户给的字符串强转成合法值:日期非法则丢弃(交 ERP 默认)、bit→0/1、其余原样。
  289 + *
  290 + * <p><b>数值列不做「尽力而为」的截取</b>:「1,000」曾被截成 1、「一千」曾变成 0,而确认卡片显示的仍是原文
  291 + * ——人看到的和写进库的是两份东西。现在只接受可整体解析的数字(允许千分位与空白),否则抛
  292 + * {@link IllegalArgumentException},由调用方转成给用户的报错。
  293 + */
284 294 public Object coerce(String dataType, String v) {
285 295 if (v == null) {
286 296 return null;
287 297 }
288 298 String t = dataType == null ? "varchar" : dataType.toLowerCase();
289 299 if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) {
290   - Matcher mm = NUM.matcher(v);
291   - return mm.find() ? mm.group() : "0";
  300 + String cleaned = v.replace(",", "").replace(",", "").trim();
  301 + if (!NUM.matcher(cleaned).matches()) {
  302 + throw new IllegalArgumentException("「" + v + "」不是有效数字");
  303 + }
  304 + return cleaned;
292 305 }
293 306 if (t.contains("date") || t.contains("time")) {
294 307 return v.matches("\\d{4}-\\d{2}-\\d{2}.*") ? v : null; // 非日期 → null,让 ERP 用默认值
... ...
src/main/java/com/xly/service/IntentService.java
... ... @@ -59,6 +59,7 @@ public class IntentService {
59 59 JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema());
60 60 if (n == null) {
61 61 log.warn("intent classify fell back to 其他 (model unavailable)");
  62 + out.failed = true; // 门失败 ≠ 真实闲聊:下游护栏不能因此关闭
62 63 return out;
63 64 }
64 65 String intent = n.path("intent").asText(Intent.OTHER);
... ...
src/main/java/com/xly/service/OpService.java
... ... @@ -69,4 +69,18 @@ public class OpService {
69 69 jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?",
70 70 status, resultMsg, sId);
71 71 }
  72 +
  73 + /**
  74 + * 抢占一条 draft(CAS):只有把 draft 改成 executing 的那个请求返回 true,并发/重复确认直接落空。
  75 + * 没有它,两个并发 confirm 都能通过「读到 draft」的判断,把同一张单执行两次。
  76 + */
  77 + public boolean claim(String sId) {
  78 + return jdbc.update("UPDATE ai_op_queue SET sStatus='executing', tConfirmDate=NOW() " +
  79 + "WHERE sId=? AND sStatus='draft'", sId) == 1;
  80 + }
  81 +
  82 + /** 执行失败/异常时把 executing 退回 draft 之外的终态由 setStatus 负责;这里仅用于放弃抢占。 */
  83 + public void release(String sId) {
  84 + jdbc.update("UPDATE ai_op_queue SET sStatus='draft' WHERE sId=? AND sStatus='executing'", sId);
  85 + }
72 86 }
... ...
src/main/java/com/xly/tool/ProposeWriteTool.java
... ... @@ -118,13 +118,42 @@ public class ProposeWriteTool {
118 118 if (field == null) {
119 119 return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
120 120 }
  121 + // 系统/审计列(单号、制单人、租户、主键、日期标志…)不允许由对话改写
  122 + if (resolver.isSystemColumn(field)) {
  123 + return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。");
  124 + }
121 125 String oldValue = l.rec.path(field).asText("");
122 126  
123   - // 3) 暂存 draft(不执行)
  127 + // 3) 值规范化:外键列把名称解析成 id,其余按列类型强转(失败即报错,绝不静默写入错值)
  128 + String fkTable = queryOne(
  129 + "SELECT MAX(sFkTable) FROM viw_kg_field_dict WHERE sTable=? AND sField=?", l.table, field);
  130 + Object stored;
  131 + String shown = newValue;
  132 + if (fkTable != null && !fkTable.isBlank() && !"null".equalsIgnoreCase(fkTable)) {
  133 + String id = resolver.resolveFk(fkTable, newValue);
  134 + if (id == null) {
  135 + String ent = fieldChinese.replace("名称", "");
  136 + return err("「" + newValue + "」不是系统里已有的" + ent + ",请从已有记录里选一个。");
  137 + }
  138 + stored = id;
  139 + } else {
  140 + try {
  141 + stored = resolver.coerce(resolver.columnTypes(l.table).get(field), newValue);
  142 + } catch (IllegalArgumentException ex) {
  143 + return err("「" + fieldChinese + "」" + ex.getMessage() + ",请给一个有效值。");
  144 + }
  145 + if (stored == null) {
  146 + return err("「" + newValue + "」不是「" + fieldChinese + "」可接受的值(日期请用 2026-07-28 这种格式)。");
  147 + }
  148 + shown = String.valueOf(stored);
  149 + }
  150 +
  151 + // 4) 暂存 draft(不执行)。摘要展示**将要写入的值**,与 payload 同源,避免"看到的与执行的不一致"
124 152 String description = "将【" + l.recordName + "】的【" + fieldChinese + "】"
125   - + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」";
  153 + + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + shown + "」"
  154 + + (shown.equals(newValue) ? "" : "(原话:" + newValue + ")");
126 155 String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId,
127   - field, fieldChinese, oldValue, newValue, description);
  156 + field, fieldChinese, oldValue, String.valueOf(stored), description);
128 157  
129 158 Map<String, Object> out = new LinkedHashMap<>();
130 159 out.put("opId", opId);
... ... @@ -216,13 +245,21 @@ public class ProposeWriteTool {
216 245 + ent + ";**不要**因此新建" + ent + "。");
217 246 }
218 247 col.put(colName, id);
  248 + descParts.add(zh + "=" + v);
219 249 } else {
220   - Object cv = resolver.coerce(types.get(colName), v); // 按列类型强转
221   - if (cv != null) {
222   - col.put(colName, cv);
  250 + Object cv;
  251 + try {
  252 + cv = resolver.coerce(types.get(colName), v); // 按列类型强转
  253 + } catch (IllegalArgumentException ex) {
  254 + return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。");
223 255 }
  256 + if (cv == null) {
  257 + return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。");
  258 + }
  259 + col.put(colName, cv);
  260 + // 摘要只写**真正入库的值**,避免确认卡片与 payload 不一致
  261 + descParts.add(zh + "=" + cv + (String.valueOf(cv).equals(v) ? "" : "(原话:" + v + ")"));
224 262 }
225   - descParts.add(zh + "=" + v);
226 263 }
227 264 }
228 265 } catch (Exception ex) {
... ... @@ -330,12 +367,17 @@ public class ProposeWriteTool {
330 367 }
331 368 if ("manyqtys".equals(tgt)) {
332 369 for (String q : v.split("[,,、\\s]+")) {
333   - String n = q.replaceAll("[^0-9.]", "");
334   - if (!n.isEmpty()) {
335   - manyQtys.add(n);
  370 + String n = q.trim();
  371 + if (n.isEmpty()) {
  372 + continue;
  373 + }
  374 + // 逐个必须是干净数字:以前 replaceAll 会把「三千」吞成空、「1k」变成 1
  375 + if (!n.matches("\\d+(\\.\\d+)?")) {
  376 + return err("多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。");
336 377 }
  378 + manyQtys.add(n);
337 379 }
338   - descParts.add(zh + "=" + v);
  380 + descParts.add(zh + "=" + String.join(",", manyQtys));
339 381 continue;
340 382 }
341 383 Object value;
... ... @@ -352,16 +394,23 @@ public class ProposeWriteTool {
352 394 }
353 395 } else {
354 396 String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName);
355   - value = resolver.coerce(dt, v);
356   - }
357   - if (value != null) {
358   - if ("slave".equals(tgt)) {
359   - slaveCol.put(colName, value);
360   - } else {
361   - masterCol.put(colName, value);
  397 + try {
  398 + value = resolver.coerce(dt, v);
  399 + } catch (IllegalArgumentException ex) {
  400 + return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。");
362 401 }
  402 + if (value == null) {
  403 + return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。");
  404 + }
  405 + }
  406 + if ("slave".equals(tgt)) {
  407 + slaveCol.put(colName, value);
  408 + } else {
  409 + masterCol.put(colName, value);
363 410 }
364   - descParts.add(zh + "=" + v);
  411 + // 摘要写真正入库的值(FK 显示用户给的名称,数值显示规范化后的数)
  412 + String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(value);
  413 + descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")"));
365 414 }
366 415 }
367 416 } catch (Exception ex) {
... ...
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory;
23 23 import org.springframework.http.MediaType;
24 24 import org.springframework.web.bind.annotation.PostMapping;
25 25 import org.springframework.web.bind.annotation.RequestBody;
  26 +import org.springframework.web.bind.annotation.RequestHeader;
26 27 import org.springframework.web.bind.annotation.RequestMapping;
27 28 import org.springframework.web.bind.annotation.RestController;
28 29 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
... ... @@ -96,26 +97,27 @@ public class AgentChatController {
96 97  
97 98 public static class ChatReq {
98 99 public String text;
99   - public String userid;
100 100 public String conversationId;
101   - // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt)
  101 + /** 透传的 ERP 会话 token(token 绝不进 prompt)。身份一律由它内省得出,不接受客户端自报。 */
102 102 public String authorization;
103   - public String brandsid;
104   - public String subsidiaryid;
105   - public String usertype;
106 103 }
107 104  
108 105 @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
109   - public SseEmitter chat(@RequestBody ChatReq req) {
  106 + public SseEmitter chat(@RequestBody ChatReq req,
  107 + @RequestHeader(value = "Authorization", required = false) String authHeader) {
110 108 SseEmitter emitter = new SseEmitter(180_000L);
111 109 final String userInput = req.text == null ? "" : req.text;
112   - final String convId = (req.conversationId != null && !req.conversationId.isBlank())
113   - ? req.conversationId
114   - : ((req.userid == null ? "anon" : req.userid) + ":default");
  110 + final AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
  111 + if (identity == null) {
  112 + send(emitter, "error", "登录已过期或未登录,请重新登录后再试。");
  113 + emitter.complete();
  114 + return emitter;
  115 + }
  116 + // 会话键绑定服务端身份 → 换个 conversationId 只能命中自己的会话,碰不到别人的记忆
  117 + final String convId = conversations.scopedId(identity, req.conversationId);
115 118  
116   - conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput);
  119 + conversations.touch(identity.userId(), convId, userInput);
117 120 ledger.append(convId, "user", Map.of("text", userInput));
118   - final AgentIdentity identity = resolveIdentity(req);
119 121  
120 122 exec.submit(() -> {
121 123 try {
... ... @@ -132,12 +134,8 @@ public class AgentChatController {
132 134 public static class FormSubmitReq {
133 135 public String entity;
134 136 public Map<String, String> fields; // 字段中文名 -> 值
135   - public String userid;
136 137 public String conversationId;
137 138 public String authorization;
138   - public String brandsid;
139   - public String subsidiaryid;
140   - public String usertype;
141 139 }
142 140  
143 141 /**
... ... @@ -145,24 +143,21 @@ public class AgentChatController {
145 143 * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
146 144 */
147 145 @PostMapping("/form/submit")
148   - public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req) {
149   - final String convId = (req.conversationId != null && !req.conversationId.isBlank())
150   - ? req.conversationId
151   - : ((req.userid == null ? "anon" : req.userid) + ":default");
  146 + public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req,
  147 + @RequestHeader(value = "Authorization", required = false) String authHeader) {
152 148 Map<String, Object> out = new LinkedHashMap<>();
  149 + AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
  150 + if (identity == null) {
  151 + out.put("error", "登录已过期或未登录,请重新登录后再试。");
  152 + return out;
  153 + }
  154 + final String convId = conversations.scopedId(identity, req.conversationId);
153 155 String entity = req.entity == null ? "" : req.entity.trim();
154 156 Map<String, String> fields = req.fields == null ? Map.of() : req.fields;
155 157 if (entity.isEmpty() || fields.isEmpty()) {
156 158 out.put("error", "缺少单据类型或表单字段。");
157 159 return out;
158 160 }
159   - ChatReq idReq = new ChatReq();
160   - idReq.userid = req.userid;
161   - idReq.authorization = req.authorization;
162   - idReq.brandsid = req.brandsid;
163   - idReq.subsidiaryid = req.subsidiaryid;
164   - idReq.usertype = req.usertype;
165   - AgentIdentity identity = resolveIdentity(idReq);
166 161  
167 162 StringBuilder parts = new StringBuilder();
168 163 fields.forEach((k, v) -> {
... ... @@ -172,7 +167,7 @@ public class AgentChatController {
172 167 }
173 168 });
174 169 String userText = "提交「" + entity + "」新增表单:" + parts;
175   - conversations.touch(req.userid == null ? "anon" : req.userid, convId, userText);
  170 + conversations.touch(identity.userId(), convId, userText);
176 171 ledger.append(convId, "user", Map.of("text", userText));
177 172  
178 173 String fieldsJson;
... ... @@ -233,8 +228,8 @@ public class AgentChatController {
233 228 runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true);
234 229 return;
235 230 default:
236   - // 其他/分类失败:原文交给 agent,尽量不丢能力。
237   - runAgent(emitter, convId, identity, withState(userInput, digest), false);
  231 + // 其他/分类失败:原文交给 agent,尽量不丢能力。门失败时不能顺带关掉反编造护栏。
  232 + runAgent(emitter, convId, identity, withState(userInput, digest), it.failed);
238 233 }
239 234 }
240 235  
... ... @@ -313,8 +308,8 @@ public class AgentChatController {
313 308  
314 309 /**
315 310 * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行):
316   - * 查询轮**零工具调用**却答出数字 → 重试一次强制先查数,仍复发则标注「未经核实」;
317   - * 非查询轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示(无提议卡片即未生效)。
  311 + * queryGuard 轮(查询 / 意图门失败)**零工具调用**却答出数字 → 重试一次强制先查数,
  312 + * 仍复发则标注「未经核实」;任意轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示。
318 313 */
319 314 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
320 315 String text, boolean queryGuard, boolean allowRetry) {
... ... @@ -342,7 +337,7 @@ public class AgentChatController {
342 337 }
343 338 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。");
344 339 }
345   - if (!queryGuard && !proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
  340 + if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
346 341 send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。");
347 342 }
348 343 if (!answer.isBlank()) {
... ... @@ -511,17 +506,9 @@ public class AgentChatController {
511 506 return s == null || s.isBlank();
512 507 }
513 508  
514   - /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */
515   - private AgentIdentity resolveIdentity(ChatReq req) {
516   - try {
517   - if (req.authorization != null && !req.authorization.isBlank()) {
518   - return authz.userIdentity(req.authorization.trim(), req.userid,
519   - req.brandsid, req.subsidiaryid, req.usertype);
520   - }
521   - } catch (Exception e) {
522   - log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage());
523   - }
524   - return authz.devIdentity();
  509 + private static String firstNonBlank(String a, String b) {
  510 + if (a != null && !a.isBlank()) return a;
  511 + return b;
525 512 }
526 513  
527 514 /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */
... ...
src/main/java/com/xly/web/ConversationController.java
1 1 package com.xly.web;
2 2  
  3 +import com.xly.agent.AgentIdentity;
  4 +import com.xly.service.AuthzService;
3 5 import com.xly.service.ConversationService;
  6 +import org.springframework.http.HttpStatus;
4 7 import org.springframework.web.bind.annotation.DeleteMapping;
5 8 import org.springframework.web.bind.annotation.GetMapping;
6 9 import org.springframework.web.bind.annotation.PathVariable;
7 10 import org.springframework.web.bind.annotation.PostMapping;
8   -import org.springframework.web.bind.annotation.RequestBody;
  11 +import org.springframework.web.bind.annotation.RequestHeader;
9 12 import org.springframework.web.bind.annotation.RequestMapping;
10   -import org.springframework.web.bind.annotation.RequestParam;
11 13 import org.springframework.web.bind.annotation.RestController;
  14 +import org.springframework.web.server.ResponseStatusException;
12 15  
13 16 import java.util.List;
14 17 import java.util.Map;
15 18  
16 19 /**
17 20 * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。
  21 + *
  22 + * <p><b>身份</b>:一律由 {@code Authorization} 头经 {@link AuthzService#resolveIdentity} 服务端内省得出,
  23 + * 不接受客户端自报的 userid;会话 id 绑定用户命名空间,越权访问他人会话直接 403。
18 24 */
19 25 @RestController
20 26 @RequestMapping("/api/agent/conversations")
21 27 public class ConversationController {
22 28  
23 29 private final ConversationService conversations;
  30 + private final AuthzService authz;
24 31  
25   - public ConversationController(ConversationService conversations) {
  32 + public ConversationController(ConversationService conversations, AuthzService authz) {
26 33 this.conversations = conversations;
  34 + this.authz = authz;
27 35 }
28 36  
29   - /** 列出用户的会话(最近更新在前)。 */
  37 + /** 列出当前登录用户的会话(最近更新在前)。 */
30 38 @GetMapping
31   - public List<Map<String, Object>> list(@RequestParam("userid") String userid) {
32   - return conversations.list(userid);
  39 + public List<Map<String, Object>> list(@RequestHeader(value = "Authorization", required = false) String auth) {
  40 + return conversations.list(identity(auth).userId());
33 41 }
34 42  
35 43 /** 新建空会话,返回 conversationId。 */
36 44 @PostMapping
37   - public Map<String, String> create(@RequestBody Map<String, String> body) {
38   - String userid = body.getOrDefault("userid", "anon");
39   - return Map.of("conversationId", conversations.create(userid));
  45 + public Map<String, String> create(@RequestHeader(value = "Authorization", required = false) String auth) {
  46 + return Map.of("conversationId", conversations.create(identity(auth).userId()));
40 47 }
41 48  
42   - /** 删除会话(含其消息记忆)。 */
  49 + /** 删除自己的会话(含消息记忆、账本、状态槽)。 */
43 50 @DeleteMapping("/{convId}")
44 51 public Map<String, Object> delete(@PathVariable("convId") String convId,
45   - @RequestParam("userid") String userid) {
46   - conversations.delete(userid, convId);
  52 + @RequestHeader(value = "Authorization", required = false) String auth) {
  53 + String uid = requireOwner(auth, convId);
  54 + conversations.delete(uid, convId);
47 55 return Map.of("ok", true);
48 56 }
49 57  
50   - /** 会话历史消息(用于切换会话时回填)。 */
  58 + /** 自己会话的历史消息(用于切换会话时回填)。 */
51 59 @GetMapping("/{convId}/messages")
52   - public List<Map<String, String>> messages(@PathVariable("convId") String convId) {
  60 + public List<Map<String, String>> messages(@PathVariable("convId") String convId,
  61 + @RequestHeader(value = "Authorization", required = false) String auth) {
  62 + requireOwner(auth, convId);
53 63 return conversations.history(convId);
54 64 }
  65 +
  66 + private AgentIdentity identity(String auth) {
  67 + AgentIdentity id = authz.resolveIdentity(auth);
  68 + if (id == null) {
  69 + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
  70 + }
  71 + return id;
  72 + }
  73 +
  74 + private String requireOwner(String auth, String convId) {
  75 + AgentIdentity id = identity(auth);
  76 + if (!conversations.owns(id.userId(), convId)) {
  77 + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话");
  78 + }
  79 + return id.userId();
  80 + }
55 81 }
... ...
src/main/java/com/xly/web/FormController.java
1 1 package com.xly.web;
2 2  
  3 +import com.xly.agent.AgentIdentity;
  4 +import com.xly.service.AuthzService;
3 5 import com.xly.service.FormResolverService;
  6 +import org.springframework.http.HttpStatus;
4 7 import org.springframework.web.bind.annotation.GetMapping;
  8 +import org.springframework.web.bind.annotation.RequestHeader;
5 9 import org.springframework.web.bind.annotation.RequestMapping;
6 10 import org.springframework.web.bind.annotation.RequestParam;
7 11 import org.springframework.web.bind.annotation.RestController;
  12 +import org.springframework.web.server.ResponseStatusException;
8 13  
9 14 import java.util.Map;
10 15  
... ... @@ -12,28 +17,34 @@ import java.util.Map;
12 17 * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取,
13 18 * 多列展示 + 名称搜索 + 分页。
14 19 *
15   - * <p>前端渲染 {@code type=fkselect} 字段时调
16   - * {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20},
  20 + * <p>前端调 {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20},
17 21 * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。
18   - * 安全:{@link FormResolverService#fkOptionPage} 只允许「在字段字典里作为外键目标出现过」的表,
19   - * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。
  22 + *
  23 + * <p><b>安全</b>:需登录;**租户来自服务端内省的身份**(不接受客户端传 brandsid,否则不传即可跨租户全库翻页);
  24 + * 表名限于「在字段字典里作为外键目标出现过」的白名单(见 {@link FormResolverService#fkOptionPage})。
20 25 */
21 26 @RestController
22 27 @RequestMapping("/api/agent/form")
23 28 public class FormController {
24 29  
25 30 private final FormResolverService resolver;
  31 + private final AuthzService authz;
26 32  
27   - public FormController(FormResolverService resolver) {
  33 + public FormController(FormResolverService resolver, AuthzService authz) {
28 34 this.resolver = resolver;
  35 + this.authz = authz;
29 36 }
30 37  
31 38 @GetMapping("/options")
32 39 public Map<String, Object> options(@RequestParam("table") String table,
33 40 @RequestParam(value = "q", required = false) String q,
34   - @RequestParam(value = "brandsid", required = false) String brandsid,
35 41 @RequestParam(value = "page", defaultValue = "1") int page,
36   - @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
37   - return resolver.fkOptionPage(table, brandsid, q, page, pageSize);
  42 + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize,
  43 + @RequestHeader(value = "Authorization", required = false) String auth) {
  44 + AgentIdentity id = authz.resolveIdentity(auth);
  45 + if (id == null) {
  46 + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
  47 + }
  48 + return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize);
38 49 }
39 50 }
... ...
src/main/java/com/xly/web/OpController.java
... ... @@ -2,12 +2,18 @@ package com.xly.web;
2 2  
3 3 import com.fasterxml.jackson.databind.JsonNode;
4 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.agent.AgentIdentity;
5 6 import com.xly.config.RedisChatMemoryStore;
6 7 import com.xly.service.AuditService;
  8 +import com.xly.service.AuthzService;
  9 +import com.xly.service.ConversationService;
7 10 import com.xly.service.ErpClient;
  11 +import com.xly.service.FormResolverService;
8 12 import com.xly.service.LedgerService;
9 13 import com.xly.service.OpService;
10 14 import com.xly.service.StateService;
  15 +import org.springframework.http.HttpStatus;
  16 +import org.springframework.web.server.ResponseStatusException;
11 17 import org.slf4j.Logger;
12 18 import org.slf4j.LoggerFactory;
13 19 import org.springframework.web.bind.annotation.GetMapping;
... ... @@ -48,13 +54,18 @@ public class OpController {
48 54 private final LedgerService ledger;
49 55 private final StateService state;
50 56 private final RedisChatMemoryStore memoryStore;
  57 + private final AuthzService authz;
  58 + private final ConversationService conversations;
  59 + private final FormResolverService resolver;
51 60  
52 61 /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */
53 62 @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}")
54 63 private boolean execStagingEnabled;
55 64  
56 65 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper,
57   - LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore) {
  66 + LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore,
  67 + AuthzService authz, ConversationService conversations,
  68 + FormResolverService resolver) {
58 69 this.ops = ops;
59 70 this.erp = erp;
60 71 this.audit = audit;
... ... @@ -62,6 +73,9 @@ public class OpController {
62 73 this.ledger = ledger;
63 74 this.state = state;
64 75 this.memoryStore = memoryStore;
  76 + this.authz = authz;
  77 + this.conversations = conversations;
  78 + this.resolver = resolver;
65 79 }
66 80  
67 81 /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */
... ... @@ -90,11 +104,26 @@ public class OpController {
90 104 }
91 105 }
92 106  
93   - /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */
  107 + /**
  108 + * 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。
  109 + * 需登录且只能查自己的会话;只回渲染必需的字段——绝不外泄 sPayload/内部表名等。
  110 + */
94 111 @GetMapping("/pending")
95   - public Map<String, Object> pending(@RequestParam("conversationId") String conversationId) {
  112 + public Map<String, Object> pending(@RequestParam("conversationId") String conversationId,
  113 + @RequestHeader(value = "Authorization", required = false) String authToken) {
  114 + String uid = requireLogin(authToken).userId();
  115 + if (!conversations.owns(uid, conversationId)) {
  116 + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话");
  117 + }
96 118 Map<String, Object> op = ops.pending(conversationId);
97   - return op == null ? Map.of() : op;
  119 + if (op == null) {
  120 + return Map.of();
  121 + }
  122 + Map<String, Object> out = new LinkedHashMap<>();
  123 + out.put("opId", op.get("sId"));
  124 + out.put("summary", op.get("sDescription"));
  125 + out.put("status", op.get("sStatus"));
  126 + return out;
98 127 }
99 128  
100 129 /**
... ... @@ -105,13 +134,21 @@ public class OpController {
105 134 @PostMapping("/{id}/confirm")
106 135 public Map<String, Object> confirm(@PathVariable("id") String id,
107 136 @RequestHeader(value = "Authorization", required = false) String authToken) {
  137 + AgentIdentity me = requireLogin(authToken);
108 138 Map<String, Object> op = ops.get(id);
109 139 if (op == null) {
110 140 return result("failed", "找不到该操作", null);
111 141 }
  142 + requireOwner(me, op);
112 143 if (!"draft".equals(String.valueOf(op.get("sStatus")))) {
113 144 return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op);
114 145 }
  146 + // CAS 抢占:并发/重复确认只有一个能真正执行,杜绝同一张单被执行两次
  147 + if (!ops.claim(id)) {
  148 + Map<String, Object> cur = ops.get(id);
  149 + String st = cur == null ? "unknown" : String.valueOf(cur.get("sStatus"));
  150 + return result(st, "该操作正在处理或已处理过(" + st + ")", op);
  151 + }
115 152 String uid = str(op.get("sUserId"));
116 153 String conv = str(op.get("sConversationId"));
117 154 String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId"));
... ... @@ -129,10 +166,16 @@ public class OpController {
129 166 if (payload.has("__tables__")) { // 报价等多表主-从创建
130 167 @SuppressWarnings("unchecked")
131 168 List<Map<String, Object>> tables = mapper.convertValue(payload.get("__tables__"), List.class);
  169 + if (!tables.isEmpty()) {
  170 + @SuppressWarnings("unchecked")
  171 + Map<String, Object> masterCol = (Map<String, Object>) tables.get(0).get("column");
  172 + refreshBillNo(masterCol, str(op.get("sTargetTable")), me.brandsId());
  173 + }
132 174 r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables);
133 175 } else {
134 176 @SuppressWarnings("unchecked")
135 177 Map<String, Object> columns = mapper.convertValue(payload, Map.class);
  178 + refreshBillNo(columns, str(op.get("sTargetTable")), me.brandsId());
136 179 r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns);
137 180 }
138 181 } else if ("delete".equals(opType)) {
... ... @@ -189,10 +232,15 @@ public class OpController {
189 232 }
190 233 }
191 234  
192   - /** 取消。 */
  235 + /** 取消(需登录且必须是本人的操作)。 */
193 236 @PostMapping("/{id}/cancel")
194   - public Map<String, Object> cancel(@PathVariable("id") String id) {
  237 + public Map<String, Object> cancel(@PathVariable("id") String id,
  238 + @RequestHeader(value = "Authorization", required = false) String authToken) {
  239 + AgentIdentity me = requireLogin(authToken);
195 240 Map<String, Object> op = ops.get(id);
  241 + if (op != null) {
  242 + requireOwner(me, op);
  243 + }
196 244 if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) {
197 245 ops.setStatus(id, "cancelled", "用户取消");
198 246 audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel",
... ... @@ -203,6 +251,37 @@ public class OpController {
203 251 return result("cancelled", "已取消", op);
204 252 }
205 253  
  254 + /**
  255 + * 单号在 propose 时算的是当时的 MAX+1;期间别人建过单就会撞号,所以**执行前重新生成**。
  256 + * 拿不到新号时保留旧号(由 ERP 侧唯一约束兜底),不因此中断执行。
  257 + */
  258 + private void refreshBillNo(Map<String, Object> columns, String table, String brandsId) {
  259 + if (columns == null || !columns.containsKey("sBillNo")) {
  260 + return;
  261 + }
  262 + String fresh = resolver.nextBillNo(table, brandsId);
  263 + if (fresh != null && !fresh.isBlank()) {
  264 + columns.put("sBillNo", fresh);
  265 + }
  266 + }
  267 +
  268 + /** 需登录(token 服务端内省);未登录/过期 → 401。 */
  269 + private AgentIdentity requireLogin(String authToken) {
  270 + AgentIdentity id = authz.resolveIdentity(authToken);
  271 + if (id == null) {
  272 + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
  273 + }
  274 + return id;
  275 + }
  276 +
  277 + /** 只有提议的发起人本人能确认/取消——否则任何人拿到 opId 就能替别人写库。 */
  278 + private void requireOwner(AgentIdentity me, Map<String, Object> op) {
  279 + String owner = str(op.get("sUserId"));
  280 + if (owner == null || !owner.equals(me.userId())) {
  281 + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权处理该操作");
  282 + }
  283 + }
  284 +
206 285 private Map<String, Object> result(String status, String msg, Map<String, Object> op) {
207 286 Map<String, Object> m = new LinkedHashMap<>();
208 287 m.put("status", status);
... ...
src/main/resources/application-saaslocal.yml
... ... @@ -12,10 +12,13 @@ logging:
12 12  
13 13 spring:
14 14 datasource:
15   - # Local saas DB from docker-compose.saas.yml (mysql-saas, root/local).
16   - url: jdbc:mysql://127.0.0.1:33307/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&allowMultiQueries=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
  15 + # Local saas DB from docker-compose.saas.yml (mysql-saas, root/local) — local throwaway creds only.
  16 + url: jdbc:mysql://127.0.0.1:33307/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&allowMultiQueries=false&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
17 17 username: root
18 18 password: local
  19 + data:
  20 + redis:
  21 + password: xlyXLY2015
19 22  
20 23 # Point ERP form-read API at the LOCAL xlyEntry (:8697, ctx /xlyEntry) which uses the
21 24 # same local DB. The committed application.yml erp.baseurl targets the remote deploy.
... ... @@ -23,6 +26,7 @@ spring:
23 26 # production should instead pass through the user's own browser ERP token per request.
24 27 erp:
25 28 baseurl: http://127.0.0.1:8697/xlyEntry
  29 + # 仅本地:无 token 时以 dev 账号执行。生产 profile 必须 false(application.yml 默认即 false)。
26 30 dev-login:
27 31 enabled: true
28 32 brand: "1111111111"
... ...
src/main/resources/application.yml
... ... @@ -26,9 +26,11 @@ spring:
26 26 cache: false # 开发时关闭缓存
27 27 datasource:
28 28 driver-class-name: com.mysql.cj.jdbc.Driver
29   - url: jdbc:mysql://118.178.19.35:3318/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&&allowMultiQueries=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
30   - username: xlyprint
31   - password: xlyXLYprint2016
  29 + # 连接串与口令一律来自环境变量(DB_URL/DB_USERNAME/DB_PASSWORD)——仓库里不放任何生产凭据。
  30 + # 单语句执行:allowMultiQueries 关闭,堆叠语句在驱动层就被拒。
  31 + url: ${DB_URL:jdbc:mysql://127.0.0.1:33307/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&allowMultiQueries=false&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL}
  32 + username: ${DB_USERNAME:}
  33 + password: ${DB_PASSWORD:}
32 34 # 连接池配置(使用HikariCP)
33 35 hikari:
34 36 maximum-pool-size: 20
... ... @@ -39,9 +41,9 @@ spring:
39 41 # REDIS (RedisProperties)
40 42 data:
41 43 redis:
42   - host: 127.0.0.1
43   - password: xlyXLY2015
44   - port: 16379
  44 + host: ${REDIS_HOST:127.0.0.1}
  45 + password: ${REDIS_PASSWORD:}
  46 + port: ${REDIS_PORT:16379}
45 47 database: 0 # index
46 48 timeout: 30000ms # 连接超时时长(毫秒)
47 49 block-when-exhausted: true
... ... @@ -63,9 +65,13 @@ management:
63 65  
64 66 # LLM:OpenAI 兼容协议(Ollama /v1、vLLM、云端网关皆可,换供应商只改这里)
65 67 llm:
66   - base-url: http://112.82.245.194:41434/v1
67   - api-key: ollama # Ollama 不校验(任意非空);云端填真实 key
68   - chat-model: qwen3.6-27b-iq3:latest
  68 + base-url: ${LLM_BASE_URL:http://112.82.245.194:41434/v1}
  69 + api-key: ${LLM_API_KEY:ollama} # Ollama 不校验(任意非空);云端填真实 key
  70 + chat-model: ${LLM_CHAT_MODEL:qwen3.6-27b-iq3:latest}
69 71  
70 72 erp:
71   - baseurl: http://118.178.19.35:8080/xlyEntry_saas
  73 + baseurl: ${ERP_BASEURL:http://118.178.19.35:8080/xlyEntry_saas}
  74 + # dev-login = 无 token 时以 dev 账号(管理员)执行。生产必须保持 false,
  75 + # 否则任何未登录请求都会拿到管理员身份。本地开发在 application-saaslocal.yml 里开。
  76 + dev-login:
  77 + enabled: false
... ...
src/main/resources/templates/chat.html
... ... @@ -338,14 +338,23 @@ body {
338 338 <div id="toast"></div>
339 339  
340 340 <script>
341   -/* ================= 身份(生产环境由 ERP 外壳注入真实值 + 用户 token;token 绝不进 prompt) ================= */
342   -let userid = "17522967560005776104370282597000";
343   -let brandsid = "1111111111";
344   -let subsidiaryid = "1111111111";
345   -let usertype = "sysadmin";
346   -let authorization = ""; // 本地留空 → 后端回退 dev-login;生产注入用户实时 ERP token
  341 +/* ================= 身份 =================
  342 + 前端只持有 ERP 会话 token,随每个请求以 Authorization 头发出;
  343 + 用户是谁、属于哪个租户、有什么权限,全部由后端拿 token 向 ERP 内省决定——
  344 + 前端不再自报 userid/brandsid/usertype(自报即可伪造)。
  345 + 生产由 ERP 外壳注入 token(window.__ERP_TOKEN__ 或 ?token=);本地留空 → 后端 dev-login(仅本地开启)。 */
  346 +const authorization = (window.__ERP_TOKEN__
  347 + || new URLSearchParams(window.location.search).get("token") || "").trim();
347 348 const BASE = window.location.origin + "/xlyAi";
348 349  
  350 +/** 统一请求头:带 token(有则带),JSON 内容类型按需。 */
  351 +function authHeaders(json) {
  352 + const h = {};
  353 + if (authorization) h["Authorization"] = authorization;
  354 + if (json) h["Content-Type"] = "application/json";
  355 + return h;
  356 +}
  357 +
349 358 const md = window.markdownit({ html: false, linkify: true, breaks: true });
350 359  
351 360 let conversationId = null;
... ... @@ -405,7 +414,7 @@ function addAiMd(text) {
405 414 /* ================= 会话管理 ================= */
406 415 async function loadConvs() {
407 416 try {
408   - const r = await fetch(BASE + "/api/agent/conversations?userid=" + encodeURIComponent(userid));
  417 + const r = await fetch(BASE + "/api/agent/conversations", { headers: authHeaders(false) });
409 418 const list = await r.json();
410 419 const box = $("#convList");
411 420 box.innerHTML = "";
... ... @@ -416,7 +425,8 @@ async function loadConvs() {
416 425 del.title = "删除会话";
417 426 del.onclick = async (e) => {
418 427 e.stopPropagation();
419   - await fetch(BASE + "/api/agent/conversations/" + encodeURIComponent(c.id) + "?userid=" + encodeURIComponent(userid), { method: "DELETE" });
  428 + await fetch(BASE + "/api/agent/conversations/" + encodeURIComponent(c.id),
  429 + { method: "DELETE", headers: authHeaders(false) });
420 430 if (c.id === conversationId) newConv();
421 431 loadConvs();
422 432 };
... ... @@ -432,8 +442,7 @@ async function newConv() {
432 442 try {
433 443 const r = await fetch(BASE + "/api/agent/conversations", {
434 444 method: "POST",
435   - headers: { "Content-Type": "application/json" },
436   - body: JSON.stringify({ userid: userid })
  445 + headers: authHeaders(true)
437 446 });
438 447 conversationId = (await r.json()).conversationId;
439 448 } catch (e) {
... ... @@ -450,7 +459,8 @@ async function switchConv(id, title) {
450 459 $("#convTitle").textContent = title || "";
451 460 messagesEl.innerHTML = "";
452 461 try {
453   - const r = await fetch(BASE + "/api/agent/conversations/" + encodeURIComponent(id) + "/messages");
  462 + const r = await fetch(BASE + "/api/agent/conversations/" + encodeURIComponent(id) + "/messages",
  463 + { headers: authHeaders(false) });
454 464 const hist = await r.json();
455 465 hist.forEach(m => {
456 466 if (m.role === "user") addUserMsg(m.content);
... ... @@ -473,16 +483,8 @@ async function sendMessage(text) {
473 483 try {
474 484 const resp = await fetch(BASE + "/api/agent/chat", {
475 485 method: "POST",
476   - headers: { "Content-Type": "application/json" },
477   - body: JSON.stringify({
478   - text: text,
479   - userid: userid,
480   - conversationId: conversationId,
481   - authorization: authorization,
482   - brandsid: brandsid,
483   - subsidiaryid: subsidiaryid,
484   - usertype: usertype
485   - })
  486 + headers: authHeaders(true),
  487 + body: JSON.stringify({ text: text, conversationId: conversationId })
486 488 });
487 489 const reader = resp.body.getReader();
488 490 const dec = new TextDecoder();
... ... @@ -569,7 +571,7 @@ function addProposalCard(opId, summary) {
569 571 ok.disabled = no.disabled = true;
570 572 try {
571 573 const r = await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/confirm",
572   - { method: "POST", headers: { "Authorization": authorization } });
  574 + { method: "POST", headers: authHeaders(false) });
573 575 const d = await r.json();
574 576 finish(d.status === "executed" ? "ok" : "bad", d.msg || d.status);
575 577 } catch (e) {
... ... @@ -579,7 +581,8 @@ function addProposalCard(opId, summary) {
579 581 no.onclick = async () => {
580 582 ok.disabled = no.disabled = true;
581 583 try {
582   - await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/cancel", { method: "POST" });
  584 + await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/cancel",
  585 + { method: "POST", headers: authHeaders(false) });
583 586 } catch (e) { }
584 587 finish("bad", "已取消");
585 588 };
... ... @@ -702,16 +705,11 @@ function addFormCard(ev) {
702 705 // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码
703 706 const r = await fetch(BASE + "/api/agent/form/submit", {
704 707 method: "POST",
705   - headers: { "Content-Type": "application/json" },
  708 + headers: authHeaders(true),
706 709 body: JSON.stringify({
707 710 entity: ev.entity,
708 711 fields: fields,
709   - userid: userid,
710   - conversationId: conversationId,
711   - authorization: authorization,
712   - brandsid: brandsid,
713   - subsidiaryid: subsidiaryid,
714   - usertype: usertype
  712 + conversationId: conversationId
715 713 })
716 714 });
717 715 const d = await r.json();
... ... @@ -753,10 +751,9 @@ async function loadFkPage() {
753 751 body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>';
754 752 try {
755 753 const url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table)
756   - + "&brandsid=" + encodeURIComponent(brandsid)
757 754 + "&q=" + encodeURIComponent(fk.q)
758 755 + "&page=" + fk.page + "&pageSize=" + fk.pageSize;
759   - const d = await (await fetch(url)).json();
  756 + const d = await (await fetch(url, { headers: authHeaders(false) })).json();
760 757 fk.total = d.total || 0;
761 758 const cols = d.columns || [];
762 759 const rows = d.rows || [];
... ...