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,6 +35,7 @@ public class Intent {
35 } 35 }
36 36
37 public String intent = OTHER; 37 public String intent = OTHER;
  38 + public boolean failed = false; // true = 意图门本身失败(模型不可达/解析异常),非真实「其他」
38 public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 39 public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单
39 public List<Entity> entities = new ArrayList<>(); 40 public List<Entity> entities = new ArrayList<>();
40 public List<String> missing = new ArrayList<>(); // 完成该意图还缺的关键信息 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,8 +7,10 @@ import lombok.extern.slf4j.Slf4j;
7 import org.springframework.dao.DataAccessException; 7 import org.springframework.dao.DataAccessException;
8 import org.springframework.dao.DataIntegrityViolationException; 8 import org.springframework.dao.DataIntegrityViolationException;
9 import org.springframework.dao.DuplicateKeyException; 9 import org.springframework.dao.DuplicateKeyException;
  10 +import org.springframework.http.ResponseEntity;
10 import org.springframework.http.converter.HttpMessageConversionException; 11 import org.springframework.http.converter.HttpMessageConversionException;
11 import org.springframework.jdbc.BadSqlGrammarException; 12 import org.springframework.jdbc.BadSqlGrammarException;
  13 +import org.springframework.web.server.ResponseStatusException;
12 import org.springframework.web.HttpMediaTypeNotSupportedException; 14 import org.springframework.web.HttpMediaTypeNotSupportedException;
13 import org.springframework.web.HttpRequestMethodNotSupportedException; 15 import org.springframework.web.HttpRequestMethodNotSupportedException;
14 import org.springframework.web.bind.MethodArgumentNotValidException; 16 import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -33,6 +35,17 @@ public class GlobalExceptionHandler { @@ -33,6 +35,17 @@ public class GlobalExceptionHandler {
33 return body; 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 @ExceptionHandler(Exception.class) 49 @ExceptionHandler(Exception.class)
37 public Map<String, Object> handleException(HttpServletRequest request, Exception e) { 50 public Map<String, Object> handleException(HttpServletRequest request, Exception e) {
38 log.error("请求地址: {}, 全局异常: ", request.getRequestURI(), e); 51 log.error("请求地址: {}, 全局异常: ", request.getRequestURI(), e);
src/main/java/com/xly/service/AuthzService.java
@@ -13,25 +13,28 @@ import java.util.Set; @@ -13,25 +13,28 @@ import java.util.Set;
13 import java.util.stream.Collectors; 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 @Service 27 @Service
26 public class AuthzService { 28 public class AuthzService {
27 29
28 private final JdbcTemplate jdbc; 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 private String devUserNo; 34 private String devUserNo;
32 - @Value("${erp.dev-login.brand:1111111111}") 35 + @Value("${erp.dev-login.brand:}")
33 private String devBrand; 36 private String devBrand;
34 - @Value("${erp.dev-login.subsidiary:1111111111}") 37 + @Value("${erp.dev-login.subsidiary:}")
35 private String devSub; 38 private String devSub;
36 @Value("${erp.dev-login.usertype:sysadmin}") 39 @Value("${erp.dev-login.usertype:sysadmin}")
37 private String devUserType; 40 private String devUserType;
@@ -39,28 +42,41 @@ public class AuthzService { @@ -39,28 +42,41 @@ public class AuthzService {
39 @Value("${erp.dev-login.userid:}") 42 @Value("${erp.dev-login.userid:}")
40 private String devUserIdOverride; 43 private String devUserIdOverride;
41 44
42 - public AuthzService(JdbcTemplate jdbc) { 45 + public AuthzService(JdbcTemplate jdbc, ErpClient erp) {
43 this.jdbc = jdbc; 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 /** null = 全部(管理员);否则 = 有权的 id 集合。 */ 82 /** null = 全部(管理员);否则 = 有权的 id 集合。 */
src/main/java/com/xly/service/ConversationService.java
@@ -39,13 +39,45 @@ public class ConversationService { @@ -39,13 +39,45 @@ public class ConversationService {
39 this.state = state; 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 public String create(String userId) { 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 return convId; 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 /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */ 81 /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */
50 public void touch(String userId, String convId, String firstMsg) { 82 public void touch(String userId, String convId, String firstMsg) {
51 Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId); 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,13 +43,16 @@ public class ErpClient {
43 43
44 @Value("${erp.baseurl}") 44 @Value("${erp.baseurl}")
45 private String baseUrl; 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 private String brand; 50 private String brand;
48 - @Value("${erp.dev-login.subsidiary:1111111111}") 51 + @Value("${erp.dev-login.subsidiary:}")
49 private String subsidiary; 52 private String subsidiary;
50 - @Value("${erp.dev-login.username:admin}") 53 + @Value("${erp.dev-login.username:}")
51 private String username; 54 private String username;
52 - @Value("${erp.dev-login.password:666666}") 55 + @Value("${erp.dev-login.password:}")
53 private String password; 56 private String password;
54 57
55 private volatile String cachedToken; 58 private volatile String cachedToken;
@@ -58,8 +61,16 @@ public class ErpClient { @@ -58,8 +61,16 @@ public class ErpClient {
58 this.mapper = mapper; 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 private synchronized String login() { 70 private synchronized String login() {
  71 + if (!devLoginEnabled) {
  72 + throw new IllegalStateException("缺少用户登录 token(dev-login 已禁用)");
  73 + }
63 try { 74 try {
64 String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary; 75 String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary;
65 String body = mapper.writeValueAsString(Map.of("username", username, "password", password)); 76 String body = mapper.writeValueAsString(Map.of("username", username, "password", password));
@@ -90,6 +101,18 @@ public class ErpClient { @@ -90,6 +101,18 @@ public class ErpClient {
90 return (t != null && !t.isBlank()) ? t : login(); 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 /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */ 116 /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */
94 private String resolveToken(String override) { 117 private String resolveToken(String override) {
95 return (override != null && !override.isBlank()) ? override : token(); 118 return (override != null && !override.isBlank()) ? override : token();
@@ -105,10 +128,30 @@ public class ErpClient { @@ -105,10 +128,30 @@ public class ErpClient {
105 return override == null || override.isBlank(); 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,8 +172,8 @@ public class ErpClient {
129 private JsonNode doRead(String formId, String moduleId, int page, int pageSize, 172 private JsonNode doRead(String formId, String moduleId, int page, int pageSize,
130 String filterField, String filterValue, String tok) { 173 String filterField, String filterValue, String tok) {
131 try { 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 List<Map<String, Object>> bFilter = new ArrayList<>(); 177 List<Map<String, Object>> bFilter = new ArrayList<>();
135 if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) { 178 if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) {
136 Map<String, Object> f = new LinkedHashMap<>(); 179 Map<String, Object> f = new LinkedHashMap<>();
@@ -214,7 +257,7 @@ public class ErpClient { @@ -214,7 +257,7 @@ public class ErpClient {
214 257
215 private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) { 258 private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) {
216 try { 259 try {
217 - String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + moduleId; 260 + String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + safeId(moduleId);
218 Map<String, Object> body = new LinkedHashMap<>(); 261 Map<String, Object> body = new LinkedHashMap<>();
219 body.put("sId", List.of(billId)); // 后端 sId 期望 List<String> 262 body.put("sId", List.of(billId)); // 后端 sId 期望 List<String>
220 body.put("sTableName", table); 263 body.put("sTableName", table);
@@ -243,7 +286,7 @@ public class ErpClient { @@ -243,7 +286,7 @@ public class ErpClient {
243 */ 286 */
244 public JsonNode execStaging(String authToken, String opId) { 287 public JsonNode execStaging(String authToken, String opId) {
245 try { 288 try {
246 - String url = baseUrl + "/ai/execStaging/" + opId; 289 + String url = baseUrl + "/ai/execStaging/" + safeId(opId);
247 HttpRequest req = HttpRequest.newBuilder(URI.create(url)) 290 HttpRequest req = HttpRequest.newBuilder(URI.create(url))
248 .header("Content-Type", "application/json;charset=UTF-8") 291 .header("Content-Type", "application/json;charset=UTF-8")
249 .header("Authorization", resolveToken(authToken)) 292 .header("Authorization", resolveToken(authToken))
@@ -265,7 +308,7 @@ public class ErpClient { @@ -265,7 +308,7 @@ public class ErpClient {
265 308
266 private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) { 309 private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) {
267 try { 310 try {
268 - String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId; 311 + String url = baseUrl + "/business/doExamine?sModelsId=" + safeId(moduleId);
269 Map<String, Object> paramsMap = new LinkedHashMap<>(); 312 Map<String, Object> paramsMap = new LinkedHashMap<>();
270 paramsMap.put("sFormGuid", moduleId); 313 paramsMap.put("sFormGuid", moduleId);
271 paramsMap.put("sGuid", billId); 314 paramsMap.put("sGuid", billId);
@@ -287,7 +330,7 @@ public class ErpClient { @@ -287,7 +330,7 @@ public class ErpClient {
287 330
288 private JsonNode doDelete(String moduleId, String table, String billId, String tok) { 331 private JsonNode doDelete(String moduleId, String table, String billId, String tok) {
289 try { 332 try {
290 - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; 333 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
291 Map<String, Object> col = new LinkedHashMap<>(); 334 Map<String, Object> col = new LinkedHashMap<>();
292 col.put("handleType", "del"); 335 col.put("handleType", "del");
293 col.put("sId", billId); 336 col.put("sId", billId);
@@ -359,7 +402,7 @@ public class ErpClient { @@ -359,7 +402,7 @@ public class ErpClient {
359 @SuppressWarnings("unchecked") 402 @SuppressWarnings("unchecked")
360 private JsonNode doCreateMulti(String moduleId, List<Map<String, Object>> tables, String tok) { 403 private JsonNode doCreateMulti(String moduleId, List<Map<String, Object>> tables, String tok) {
361 try { 404 try {
362 - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; 405 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
363 List<Map<String, Object>> data = new ArrayList<>(); 406 List<Map<String, Object>> data = new ArrayList<>();
364 for (Map<String, Object> t : tables) { 407 for (Map<String, Object> t : tables) {
365 Map<String, Object> col = new LinkedHashMap<>((Map<String, Object>) t.get("column")); 408 Map<String, Object> col = new LinkedHashMap<>((Map<String, Object>) t.get("column"));
@@ -388,7 +431,7 @@ public class ErpClient { @@ -388,7 +431,7 @@ public class ErpClient {
388 431
389 private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) { 432 private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) {
390 try { 433 try {
391 - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; 434 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
392 Map<String, Object> col = new LinkedHashMap<>(columns); 435 Map<String, Object> col = new LinkedHashMap<>(columns);
393 col.put("handleType", "add"); 436 col.put("handleType", "add");
394 Map<String, Object> dataItem = new LinkedHashMap<>(); 437 Map<String, Object> dataItem = new LinkedHashMap<>();
@@ -413,7 +456,7 @@ public class ErpClient { @@ -413,7 +456,7 @@ public class ErpClient {
413 456
414 private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { 457 private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) {
415 try { 458 try {
416 - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; 459 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId);
417 Map<String, Object> col = new LinkedHashMap<>(); 460 Map<String, Object> col = new LinkedHashMap<>();
418 col.put("handleType", "update"); 461 col.put("handleType", "update");
419 col.put("sId", billId); 462 col.put("sId", billId);
src/main/java/com/xly/service/FormResolverService.java
@@ -201,6 +201,10 @@ public class FormResolverService { @@ -201,6 +201,10 @@ public class FormResolverService {
201 out.put("total", 0); 201 out.put("total", 0);
202 out.put("page", Math.max(1, page)); 202 out.put("page", Math.max(1, page));
203 out.put("pageSize", pageSize); 203 out.put("pageSize", pageSize);
  204 + // 租户谓词是强制的:拿不到 brand 宁可返回空,也绝不退化成全库查询
  205 + if (brand == null || brand.isBlank()) {
  206 + return out;
  207 + }
204 try { 208 try {
205 Integer ok = jdbc.queryForObject( 209 Integer ok = jdbc.queryForObject(
206 "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); 210 "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable);
@@ -280,15 +284,24 @@ public class FormResolverService { @@ -280,15 +284,24 @@ public class FormResolverService {
280 return m; 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 public Object coerce(String dataType, String v) { 294 public Object coerce(String dataType, String v) {
285 if (v == null) { 295 if (v == null) {
286 return null; 296 return null;
287 } 297 }
288 String t = dataType == null ? "varchar" : dataType.toLowerCase(); 298 String t = dataType == null ? "varchar" : dataType.toLowerCase();
289 if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { 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 if (t.contains("date") || t.contains("time")) { 306 if (t.contains("date") || t.contains("time")) {
294 return v.matches("\\d{4}-\\d{2}-\\d{2}.*") ? v : null; // 非日期 → null,让 ERP 用默认值 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,6 +59,7 @@ public class IntentService {
59 JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema()); 59 JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema());
60 if (n == null) { 60 if (n == null) {
61 log.warn("intent classify fell back to 其他 (model unavailable)"); 61 log.warn("intent classify fell back to 其他 (model unavailable)");
  62 + out.failed = true; // 门失败 ≠ 真实闲聊:下游护栏不能因此关闭
62 return out; 63 return out;
63 } 64 }
64 String intent = n.path("intent").asText(Intent.OTHER); 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,4 +69,18 @@ public class OpService {
69 jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?", 69 jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?",
70 status, resultMsg, sId); 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,13 +118,42 @@ public class ProposeWriteTool {
118 if (field == null) { 118 if (field == null) {
119 return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); 119 return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
120 } 120 }
  121 + // 系统/审计列(单号、制单人、租户、主键、日期标志…)不允许由对话改写
  122 + if (resolver.isSystemColumn(field)) {
  123 + return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。");
  124 + }
121 String oldValue = l.rec.path(field).asText(""); 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 String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" 152 String description = "将【" + l.recordName + "】的【" + fieldChinese + "】"
125 - + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; 153 + + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + shown + "」"
  154 + + (shown.equals(newValue) ? "" : "(原话:" + newValue + ")");
126 String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId, 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 Map<String, Object> out = new LinkedHashMap<>(); 158 Map<String, Object> out = new LinkedHashMap<>();
130 out.put("opId", opId); 159 out.put("opId", opId);
@@ -216,13 +245,21 @@ public class ProposeWriteTool { @@ -216,13 +245,21 @@ public class ProposeWriteTool {
216 + ent + ";**不要**因此新建" + ent + "。"); 245 + ent + ";**不要**因此新建" + ent + "。");
217 } 246 }
218 col.put(colName, id); 247 col.put(colName, id);
  248 + descParts.add(zh + "=" + v);
219 } else { 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 } catch (Exception ex) { 265 } catch (Exception ex) {
@@ -330,12 +367,17 @@ public class ProposeWriteTool { @@ -330,12 +367,17 @@ public class ProposeWriteTool {
330 } 367 }
331 if ("manyqtys".equals(tgt)) { 368 if ("manyqtys".equals(tgt)) {
332 for (String q : v.split("[,,、\\s]+")) { 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 continue; 381 continue;
340 } 382 }
341 Object value; 383 Object value;
@@ -352,16 +394,23 @@ public class ProposeWriteTool { @@ -352,16 +394,23 @@ public class ProposeWriteTool {
352 } 394 }
353 } else { 395 } else {
354 String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName); 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 } catch (Exception ex) { 416 } catch (Exception ex) {
src/main/java/com/xly/web/AgentChatController.java
@@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory; @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory;
23 import org.springframework.http.MediaType; 23 import org.springframework.http.MediaType;
24 import org.springframework.web.bind.annotation.PostMapping; 24 import org.springframework.web.bind.annotation.PostMapping;
25 import org.springframework.web.bind.annotation.RequestBody; 25 import org.springframework.web.bind.annotation.RequestBody;
  26 +import org.springframework.web.bind.annotation.RequestHeader;
26 import org.springframework.web.bind.annotation.RequestMapping; 27 import org.springframework.web.bind.annotation.RequestMapping;
27 import org.springframework.web.bind.annotation.RestController; 28 import org.springframework.web.bind.annotation.RestController;
28 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 29 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -96,26 +97,27 @@ public class AgentChatController { @@ -96,26 +97,27 @@ public class AgentChatController {
96 97
97 public static class ChatReq { 98 public static class ChatReq {
98 public String text; 99 public String text;
99 - public String userid;  
100 public String conversationId; 100 public String conversationId;
101 - // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt) 101 + /** 透传的 ERP 会话 token(token 绝不进 prompt)。身份一律由它内省得出,不接受客户端自报。 */
102 public String authorization; 102 public String authorization;
103 - public String brandsid;  
104 - public String subsidiaryid;  
105 - public String usertype;  
106 } 103 }
107 104
108 @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") 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 SseEmitter emitter = new SseEmitter(180_000L); 108 SseEmitter emitter = new SseEmitter(180_000L);
111 final String userInput = req.text == null ? "" : req.text; 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 ledger.append(convId, "user", Map.of("text", userInput)); 120 ledger.append(convId, "user", Map.of("text", userInput));
118 - final AgentIdentity identity = resolveIdentity(req);  
119 121
120 exec.submit(() -> { 122 exec.submit(() -> {
121 try { 123 try {
@@ -132,12 +134,8 @@ public class AgentChatController { @@ -132,12 +134,8 @@ public class AgentChatController {
132 public static class FormSubmitReq { 134 public static class FormSubmitReq {
133 public String entity; 135 public String entity;
134 public Map<String, String> fields; // 字段中文名 -> 值 136 public Map<String, String> fields; // 字段中文名 -> 值
135 - public String userid;  
136 public String conversationId; 137 public String conversationId;
137 public String authorization; 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,24 +143,21 @@ public class AgentChatController {
145 * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。 143 * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
146 */ 144 */
147 @PostMapping("/form/submit") 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 Map<String, Object> out = new LinkedHashMap<>(); 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 String entity = req.entity == null ? "" : req.entity.trim(); 155 String entity = req.entity == null ? "" : req.entity.trim();
154 Map<String, String> fields = req.fields == null ? Map.of() : req.fields; 156 Map<String, String> fields = req.fields == null ? Map.of() : req.fields;
155 if (entity.isEmpty() || fields.isEmpty()) { 157 if (entity.isEmpty() || fields.isEmpty()) {
156 out.put("error", "缺少单据类型或表单字段。"); 158 out.put("error", "缺少单据类型或表单字段。");
157 return out; 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 StringBuilder parts = new StringBuilder(); 162 StringBuilder parts = new StringBuilder();
168 fields.forEach((k, v) -> { 163 fields.forEach((k, v) -> {
@@ -172,7 +167,7 @@ public class AgentChatController { @@ -172,7 +167,7 @@ public class AgentChatController {
172 } 167 }
173 }); 168 });
174 String userText = "提交「" + entity + "」新增表单:" + parts; 169 String userText = "提交「" + entity + "」新增表单:" + parts;
175 - conversations.touch(req.userid == null ? "anon" : req.userid, convId, userText); 170 + conversations.touch(identity.userId(), convId, userText);
176 ledger.append(convId, "user", Map.of("text", userText)); 171 ledger.append(convId, "user", Map.of("text", userText));
177 172
178 String fieldsJson; 173 String fieldsJson;
@@ -233,8 +228,8 @@ public class AgentChatController { @@ -233,8 +228,8 @@ public class AgentChatController {
233 runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); 228 runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true);
234 return; 229 return;
235 default: 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,8 +308,8 @@ public class AgentChatController {
313 308
314 /** 309 /**
315 * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行): 310 * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行):
316 - * 查询轮**零工具调用**却答出数字 → 重试一次强制先查数,仍复发则标注「未经核实」;  
317 - * 非查询轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示(无提议卡片即未生效)。 311 + * queryGuard 轮(查询 / 意图门失败)**零工具调用**却答出数字 → 重试一次强制先查数,
  312 + * 仍复发则标注「未经核实」;任意轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示。
318 */ 313 */
319 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, 314 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
320 String text, boolean queryGuard, boolean allowRetry) { 315 String text, boolean queryGuard, boolean allowRetry) {
@@ -342,7 +337,7 @@ public class AgentChatController { @@ -342,7 +337,7 @@ public class AgentChatController {
342 } 337 }
343 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); 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 send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); 341 send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。");
347 } 342 }
348 if (!answer.isBlank()) { 343 if (!answer.isBlank()) {
@@ -511,17 +506,9 @@ public class AgentChatController { @@ -511,17 +506,9 @@ public class AgentChatController {
511 return s == null || s.isBlank(); 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 /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */ 514 /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */
src/main/java/com/xly/web/ConversationController.java
1 package com.xly.web; 1 package com.xly.web;
2 2
  3 +import com.xly.agent.AgentIdentity;
  4 +import com.xly.service.AuthzService;
3 import com.xly.service.ConversationService; 5 import com.xly.service.ConversationService;
  6 +import org.springframework.http.HttpStatus;
4 import org.springframework.web.bind.annotation.DeleteMapping; 7 import org.springframework.web.bind.annotation.DeleteMapping;
5 import org.springframework.web.bind.annotation.GetMapping; 8 import org.springframework.web.bind.annotation.GetMapping;
6 import org.springframework.web.bind.annotation.PathVariable; 9 import org.springframework.web.bind.annotation.PathVariable;
7 import org.springframework.web.bind.annotation.PostMapping; 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 import org.springframework.web.bind.annotation.RequestMapping; 12 import org.springframework.web.bind.annotation.RequestMapping;
10 -import org.springframework.web.bind.annotation.RequestParam;  
11 import org.springframework.web.bind.annotation.RestController; 13 import org.springframework.web.bind.annotation.RestController;
  14 +import org.springframework.web.server.ResponseStatusException;
12 15
13 import java.util.List; 16 import java.util.List;
14 import java.util.Map; 17 import java.util.Map;
15 18
16 /** 19 /**
17 * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。 20 * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。
  21 + *
  22 + * <p><b>身份</b>:一律由 {@code Authorization} 头经 {@link AuthzService#resolveIdentity} 服务端内省得出,
  23 + * 不接受客户端自报的 userid;会话 id 绑定用户命名空间,越权访问他人会话直接 403。
18 */ 24 */
19 @RestController 25 @RestController
20 @RequestMapping("/api/agent/conversations") 26 @RequestMapping("/api/agent/conversations")
21 public class ConversationController { 27 public class ConversationController {
22 28
23 private final ConversationService conversations; 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 this.conversations = conversations; 33 this.conversations = conversations;
  34 + this.authz = authz;
27 } 35 }
28 36
29 - /** 列出用户的会话(最近更新在前)。 */ 37 + /** 列出当前登录用户的会话(最近更新在前)。 */
30 @GetMapping 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 /** 新建空会话,返回 conversationId。 */ 43 /** 新建空会话,返回 conversationId。 */
36 @PostMapping 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 @DeleteMapping("/{convId}") 50 @DeleteMapping("/{convId}")
44 public Map<String, Object> delete(@PathVariable("convId") String convId, 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 return Map.of("ok", true); 55 return Map.of("ok", true);
48 } 56 }
49 57
50 - /** 会话历史消息(用于切换会话时回填)。 */ 58 + /** 自己会话的历史消息(用于切换会话时回填)。 */
51 @GetMapping("/{convId}/messages") 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 return conversations.history(convId); 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 package com.xly.web; 1 package com.xly.web;
2 2
  3 +import com.xly.agent.AgentIdentity;
  4 +import com.xly.service.AuthzService;
3 import com.xly.service.FormResolverService; 5 import com.xly.service.FormResolverService;
  6 +import org.springframework.http.HttpStatus;
4 import org.springframework.web.bind.annotation.GetMapping; 7 import org.springframework.web.bind.annotation.GetMapping;
  8 +import org.springframework.web.bind.annotation.RequestHeader;
5 import org.springframework.web.bind.annotation.RequestMapping; 9 import org.springframework.web.bind.annotation.RequestMapping;
6 import org.springframework.web.bind.annotation.RequestParam; 10 import org.springframework.web.bind.annotation.RequestParam;
7 import org.springframework.web.bind.annotation.RestController; 11 import org.springframework.web.bind.annotation.RestController;
  12 +import org.springframework.web.server.ResponseStatusException;
8 13
9 import java.util.Map; 14 import java.util.Map;
10 15
@@ -12,28 +17,34 @@ import java.util.Map; @@ -12,28 +17,34 @@ import java.util.Map;
12 * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取, 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 * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。 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 @RestController 26 @RestController
22 @RequestMapping("/api/agent/form") 27 @RequestMapping("/api/agent/form")
23 public class FormController { 28 public class FormController {
24 29
25 private final FormResolverService resolver; 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 this.resolver = resolver; 34 this.resolver = resolver;
  35 + this.authz = authz;
29 } 36 }
30 37
31 @GetMapping("/options") 38 @GetMapping("/options")
32 public Map<String, Object> options(@RequestParam("table") String table, 39 public Map<String, Object> options(@RequestParam("table") String table,
33 @RequestParam(value = "q", required = false) String q, 40 @RequestParam(value = "q", required = false) String q,
34 - @RequestParam(value = "brandsid", required = false) String brandsid,  
35 @RequestParam(value = "page", defaultValue = "1") int page, 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,12 +2,18 @@ package com.xly.web;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.agent.AgentIdentity;
5 import com.xly.config.RedisChatMemoryStore; 6 import com.xly.config.RedisChatMemoryStore;
6 import com.xly.service.AuditService; 7 import com.xly.service.AuditService;
  8 +import com.xly.service.AuthzService;
  9 +import com.xly.service.ConversationService;
7 import com.xly.service.ErpClient; 10 import com.xly.service.ErpClient;
  11 +import com.xly.service.FormResolverService;
8 import com.xly.service.LedgerService; 12 import com.xly.service.LedgerService;
9 import com.xly.service.OpService; 13 import com.xly.service.OpService;
10 import com.xly.service.StateService; 14 import com.xly.service.StateService;
  15 +import org.springframework.http.HttpStatus;
  16 +import org.springframework.web.server.ResponseStatusException;
11 import org.slf4j.Logger; 17 import org.slf4j.Logger;
12 import org.slf4j.LoggerFactory; 18 import org.slf4j.LoggerFactory;
13 import org.springframework.web.bind.annotation.GetMapping; 19 import org.springframework.web.bind.annotation.GetMapping;
@@ -48,13 +54,18 @@ public class OpController { @@ -48,13 +54,18 @@ public class OpController {
48 private final LedgerService ledger; 54 private final LedgerService ledger;
49 private final StateService state; 55 private final StateService state;
50 private final RedisChatMemoryStore memoryStore; 56 private final RedisChatMemoryStore memoryStore;
  57 + private final AuthzService authz;
  58 + private final ConversationService conversations;
  59 + private final FormResolverService resolver;
51 60
52 /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ 61 /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */
53 @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") 62 @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}")
54 private boolean execStagingEnabled; 63 private boolean execStagingEnabled;
55 64
56 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, 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 this.ops = ops; 69 this.ops = ops;
59 this.erp = erp; 70 this.erp = erp;
60 this.audit = audit; 71 this.audit = audit;
@@ -62,6 +73,9 @@ public class OpController { @@ -62,6 +73,9 @@ public class OpController {
62 this.ledger = ledger; 73 this.ledger = ledger;
63 this.state = state; 74 this.state = state;
64 this.memoryStore = memoryStore; 75 this.memoryStore = memoryStore;
  76 + this.authz = authz;
  77 + this.conversations = conversations;
  78 + this.resolver = resolver;
65 } 79 }
66 80
67 /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */ 81 /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */
@@ -90,11 +104,26 @@ public class OpController { @@ -90,11 +104,26 @@ public class OpController {
90 } 104 }
91 } 105 }
92 106
93 - /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ 107 + /**
  108 + * 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。
  109 + * 需登录且只能查自己的会话;只回渲染必需的字段——绝不外泄 sPayload/内部表名等。
  110 + */
94 @GetMapping("/pending") 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 Map<String, Object> op = ops.pending(conversationId); 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,13 +134,21 @@ public class OpController {
105 @PostMapping("/{id}/confirm") 134 @PostMapping("/{id}/confirm")
106 public Map<String, Object> confirm(@PathVariable("id") String id, 135 public Map<String, Object> confirm(@PathVariable("id") String id,
107 @RequestHeader(value = "Authorization", required = false) String authToken) { 136 @RequestHeader(value = "Authorization", required = false) String authToken) {
  137 + AgentIdentity me = requireLogin(authToken);
108 Map<String, Object> op = ops.get(id); 138 Map<String, Object> op = ops.get(id);
109 if (op == null) { 139 if (op == null) {
110 return result("failed", "找不到该操作", null); 140 return result("failed", "找不到该操作", null);
111 } 141 }
  142 + requireOwner(me, op);
112 if (!"draft".equals(String.valueOf(op.get("sStatus")))) { 143 if (!"draft".equals(String.valueOf(op.get("sStatus")))) {
113 return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); 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 String uid = str(op.get("sUserId")); 152 String uid = str(op.get("sUserId"));
116 String conv = str(op.get("sConversationId")); 153 String conv = str(op.get("sConversationId"));
117 String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")); 154 String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId"));
@@ -129,10 +166,16 @@ public class OpController { @@ -129,10 +166,16 @@ public class OpController {
129 if (payload.has("__tables__")) { // 报价等多表主-从创建 166 if (payload.has("__tables__")) { // 报价等多表主-从创建
130 @SuppressWarnings("unchecked") 167 @SuppressWarnings("unchecked")
131 List<Map<String, Object>> tables = mapper.convertValue(payload.get("__tables__"), List.class); 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 r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables); 174 r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables);
133 } else { 175 } else {
134 @SuppressWarnings("unchecked") 176 @SuppressWarnings("unchecked")
135 Map<String, Object> columns = mapper.convertValue(payload, Map.class); 177 Map<String, Object> columns = mapper.convertValue(payload, Map.class);
  178 + refreshBillNo(columns, str(op.get("sTargetTable")), me.brandsId());
136 r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns); 179 r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns);
137 } 180 }
138 } else if ("delete".equals(opType)) { 181 } else if ("delete".equals(opType)) {
@@ -189,10 +232,15 @@ public class OpController { @@ -189,10 +232,15 @@ public class OpController {
189 } 232 }
190 } 233 }
191 234
192 - /** 取消。 */ 235 + /** 取消(需登录且必须是本人的操作)。 */
193 @PostMapping("/{id}/cancel") 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 Map<String, Object> op = ops.get(id); 240 Map<String, Object> op = ops.get(id);
  241 + if (op != null) {
  242 + requireOwner(me, op);
  243 + }
196 if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { 244 if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) {
197 ops.setStatus(id, "cancelled", "用户取消"); 245 ops.setStatus(id, "cancelled", "用户取消");
198 audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", 246 audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel",
@@ -203,6 +251,37 @@ public class OpController { @@ -203,6 +251,37 @@ public class OpController {
203 return result("cancelled", "已取消", op); 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 private Map<String, Object> result(String status, String msg, Map<String, Object> op) { 285 private Map<String, Object> result(String status, String msg, Map<String, Object> op) {
207 Map<String, Object> m = new LinkedHashMap<>(); 286 Map<String, Object> m = new LinkedHashMap<>();
208 m.put("status", status); 287 m.put("status", status);
src/main/resources/application-saaslocal.yml
@@ -12,10 +12,13 @@ logging: @@ -12,10 +12,13 @@ logging:
12 12
13 spring: 13 spring:
14 datasource: 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 username: root 17 username: root
18 password: local 18 password: local
  19 + data:
  20 + redis:
  21 + password: xlyXLY2015
19 22
20 # Point ERP form-read API at the LOCAL xlyEntry (:8697, ctx /xlyEntry) which uses the 23 # Point ERP form-read API at the LOCAL xlyEntry (:8697, ctx /xlyEntry) which uses the
21 # same local DB. The committed application.yml erp.baseurl targets the remote deploy. 24 # same local DB. The committed application.yml erp.baseurl targets the remote deploy.
@@ -23,6 +26,7 @@ spring: @@ -23,6 +26,7 @@ spring:
23 # production should instead pass through the user's own browser ERP token per request. 26 # production should instead pass through the user's own browser ERP token per request.
24 erp: 27 erp:
25 baseurl: http://127.0.0.1:8697/xlyEntry 28 baseurl: http://127.0.0.1:8697/xlyEntry
  29 + # 仅本地:无 token 时以 dev 账号执行。生产 profile 必须 false(application.yml 默认即 false)。
26 dev-login: 30 dev-login:
27 enabled: true 31 enabled: true
28 brand: "1111111111" 32 brand: "1111111111"
src/main/resources/application.yml
@@ -26,9 +26,11 @@ spring: @@ -26,9 +26,11 @@ spring:
26 cache: false # 开发时关闭缓存 26 cache: false # 开发时关闭缓存
27 datasource: 27 datasource:
28 driver-class-name: com.mysql.cj.jdbc.Driver 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 # 连接池配置(使用HikariCP) 34 # 连接池配置(使用HikariCP)
33 hikari: 35 hikari:
34 maximum-pool-size: 20 36 maximum-pool-size: 20
@@ -39,9 +41,9 @@ spring: @@ -39,9 +41,9 @@ spring:
39 # REDIS (RedisProperties) 41 # REDIS (RedisProperties)
40 data: 42 data:
41 redis: 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 database: 0 # index 47 database: 0 # index
46 timeout: 30000ms # 连接超时时长(毫秒) 48 timeout: 30000ms # 连接超时时长(毫秒)
47 block-when-exhausted: true 49 block-when-exhausted: true
@@ -63,9 +65,13 @@ management: @@ -63,9 +65,13 @@ management:
63 65
64 # LLM:OpenAI 兼容协议(Ollama /v1、vLLM、云端网关皆可,换供应商只改这里) 66 # LLM:OpenAI 兼容协议(Ollama /v1、vLLM、云端网关皆可,换供应商只改这里)
65 llm: 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 erp: 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,14 +338,23 @@ body {
338 <div id="toast"></div> 338 <div id="toast"></div>
339 339
340 <script> 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 const BASE = window.location.origin + "/xlyAi"; 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 const md = window.markdownit({ html: false, linkify: true, breaks: true }); 358 const md = window.markdownit({ html: false, linkify: true, breaks: true });
350 359
351 let conversationId = null; 360 let conversationId = null;
@@ -405,7 +414,7 @@ function addAiMd(text) { @@ -405,7 +414,7 @@ function addAiMd(text) {
405 /* ================= 会话管理 ================= */ 414 /* ================= 会话管理 ================= */
406 async function loadConvs() { 415 async function loadConvs() {
407 try { 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 const list = await r.json(); 418 const list = await r.json();
410 const box = $("#convList"); 419 const box = $("#convList");
411 box.innerHTML = ""; 420 box.innerHTML = "";
@@ -416,7 +425,8 @@ async function loadConvs() { @@ -416,7 +425,8 @@ async function loadConvs() {
416 del.title = "删除会话"; 425 del.title = "删除会话";
417 del.onclick = async (e) => { 426 del.onclick = async (e) => {
418 e.stopPropagation(); 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 if (c.id === conversationId) newConv(); 430 if (c.id === conversationId) newConv();
421 loadConvs(); 431 loadConvs();
422 }; 432 };
@@ -432,8 +442,7 @@ async function newConv() { @@ -432,8 +442,7 @@ async function newConv() {
432 try { 442 try {
433 const r = await fetch(BASE + "/api/agent/conversations", { 443 const r = await fetch(BASE + "/api/agent/conversations", {
434 method: "POST", 444 method: "POST",
435 - headers: { "Content-Type": "application/json" },  
436 - body: JSON.stringify({ userid: userid }) 445 + headers: authHeaders(true)
437 }); 446 });
438 conversationId = (await r.json()).conversationId; 447 conversationId = (await r.json()).conversationId;
439 } catch (e) { 448 } catch (e) {
@@ -450,7 +459,8 @@ async function switchConv(id, title) { @@ -450,7 +459,8 @@ async function switchConv(id, title) {
450 $("#convTitle").textContent = title || ""; 459 $("#convTitle").textContent = title || "";
451 messagesEl.innerHTML = ""; 460 messagesEl.innerHTML = "";
452 try { 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 const hist = await r.json(); 464 const hist = await r.json();
455 hist.forEach(m => { 465 hist.forEach(m => {
456 if (m.role === "user") addUserMsg(m.content); 466 if (m.role === "user") addUserMsg(m.content);
@@ -473,16 +483,8 @@ async function sendMessage(text) { @@ -473,16 +483,8 @@ async function sendMessage(text) {
473 try { 483 try {
474 const resp = await fetch(BASE + "/api/agent/chat", { 484 const resp = await fetch(BASE + "/api/agent/chat", {
475 method: "POST", 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 const reader = resp.body.getReader(); 489 const reader = resp.body.getReader();
488 const dec = new TextDecoder(); 490 const dec = new TextDecoder();
@@ -569,7 +571,7 @@ function addProposalCard(opId, summary) { @@ -569,7 +571,7 @@ function addProposalCard(opId, summary) {
569 ok.disabled = no.disabled = true; 571 ok.disabled = no.disabled = true;
570 try { 572 try {
571 const r = await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/confirm", 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 const d = await r.json(); 575 const d = await r.json();
574 finish(d.status === "executed" ? "ok" : "bad", d.msg || d.status); 576 finish(d.status === "executed" ? "ok" : "bad", d.msg || d.status);
575 } catch (e) { 577 } catch (e) {
@@ -579,7 +581,8 @@ function addProposalCard(opId, summary) { @@ -579,7 +581,8 @@ function addProposalCard(opId, summary) {
579 no.onclick = async () => { 581 no.onclick = async () => {
580 ok.disabled = no.disabled = true; 582 ok.disabled = no.disabled = true;
581 try { 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 } catch (e) { } 586 } catch (e) { }
584 finish("bad", "已取消"); 587 finish("bad", "已取消");
585 }; 588 };
@@ -702,16 +705,11 @@ function addFormCard(ev) { @@ -702,16 +705,11 @@ function addFormCard(ev) {
702 // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码 705 // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码
703 const r = await fetch(BASE + "/api/agent/form/submit", { 706 const r = await fetch(BASE + "/api/agent/form/submit", {
704 method: "POST", 707 method: "POST",
705 - headers: { "Content-Type": "application/json" }, 708 + headers: authHeaders(true),
706 body: JSON.stringify({ 709 body: JSON.stringify({
707 entity: ev.entity, 710 entity: ev.entity,
708 fields: fields, 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 const d = await r.json(); 715 const d = await r.json();
@@ -753,10 +751,9 @@ async function loadFkPage() { @@ -753,10 +751,9 @@ async function loadFkPage() {
753 body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>'; 751 body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>';
754 try { 752 try {
755 const url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table) 753 const url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table)
756 - + "&brandsid=" + encodeURIComponent(brandsid)  
757 + "&q=" + encodeURIComponent(fk.q) 754 + "&q=" + encodeURIComponent(fk.q)
758 + "&page=" + fk.page + "&pageSize=" + fk.pageSize; 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 fk.total = d.total || 0; 757 fk.total = d.total || 0;
761 const cols = d.columns || []; 758 const cols = d.columns || [];
762 const rows = d.rows || []; 759 const rows = d.rows || [];