Commit c05925803400b42a3716a9c670353da6de1b3d5e
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.
Showing
16 changed files
with
472 additions
and
180 deletions
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 || []; | ... | ... |