diff --git a/src/main/java/com/xly/agent/Intent.java b/src/main/java/com/xly/agent/Intent.java index a412a85..3c507f9 100644 --- a/src/main/java/com/xly/agent/Intent.java +++ b/src/main/java/com/xly/agent/Intent.java @@ -35,6 +35,7 @@ public class Intent { } public String intent = OTHER; + public boolean failed = false; // true = 意图门本身失败(模型不可达/解析异常),非真实「其他」 public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 public List entities = new ArrayList<>(); public List missing = new ArrayList<>(); // 完成该意图还缺的关键信息 diff --git a/src/main/java/com/xly/exception/GlobalExceptionHandler.java b/src/main/java/com/xly/exception/GlobalExceptionHandler.java index 566cc60..5e40477 100644 --- a/src/main/java/com/xly/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/xly/exception/GlobalExceptionHandler.java @@ -7,8 +7,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConversionException; import org.springframework.jdbc.BadSqlGrammarException; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -33,6 +35,17 @@ public class GlobalExceptionHandler { return body; } + /** + * 鉴权类异常按**真实 HTTP 状态**返回(401/403),不被下面的兜底包装成 200。 + * 否则客户端/网关/日志都看不出这是一次被拒绝的越权访问。 + */ + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException e) { + log.warn("拒绝访问 {}: {}", e.getStatusCode(), e.getReason()); + return ResponseEntity.status(e.getStatusCode()) + .body(error(e.getStatusCode().value(), e.getReason() == null ? "拒绝访问" : e.getReason())); + } + @ExceptionHandler(Exception.class) public Map handleException(HttpServletRequest request, Exception e) { log.error("请求地址: {}, 全局异常: ", request.getRequestURI(), e); diff --git a/src/main/java/com/xly/service/AuthzService.java b/src/main/java/com/xly/service/AuthzService.java index b8d0736..54d54f7 100644 --- a/src/main/java/com/xly/service/AuthzService.java +++ b/src/main/java/com/xly/service/AuthzService.java @@ -13,25 +13,28 @@ import java.util.Set; import java.util.stream.Collectors; /** - * xlyAi 侧授权层 —— 表单级白名单(架构 §7)。 + * xlyAi 侧身份与授权层(架构 §7)。 * - *

后端逐用户表单权限被关(只验登录 + 租户 + 行级),所以在 agent 侧按用户实际授权把可碰的 - * 表单/菜单限制住。数据源与后端一致(`sysjurisdiction`,即 `getsAuthsIdNew` 的逻辑)——不新造。 + *

身份唯一来源 = {@link #resolveIdentity}:透传的用户 token 经 ERP {@code /ai/whoami} + * **服务端内省**换取真实的 userId/租户/用户类型——客户端自报的 userid/brandsid/usertype 一概不信。 + * token 缺失或无效时:仅本地开发({@code erp.dev-login.enabled=true})回落 dev 身份; + * 生产一律返回 null,调用方必须拒绝(fail-closed,绝不降级成 dev/管理员)。 * - *

管理员(sysadmin/admin) = 全部权限(返回 null);否则 = 该用户有权的 id 集合(菜单/表单/按钮, - * 由 `sysjurisdiction.sKey` 按 '-' 拆出)。当前本地 dev-login 以 admin 身份运行,因此实际全通; - * 生产透传用户 token 时即按各用户真实授权收紧。Read / Query / Write 共用此边界。 + *

授权 = 表单级白名单:后端逐用户表单权限被关(只验登录 + 租户 + 行级),所以在 agent 侧按用户 + * 实际授权(`sysjurisdiction`,即 `getsAuthsIdNew` 的逻辑)限制可碰的表单/菜单。 + * 管理员(sysadmin/admin) = 全部权限(返回 null);Read / Write 共用此边界。 */ @Service public class AuthzService { private final JdbcTemplate jdbc; + private final ErpClient erp; - @Value("${erp.dev-login.username:admin}") + @Value("${erp.dev-login.username:}") private String devUserNo; - @Value("${erp.dev-login.brand:1111111111}") + @Value("${erp.dev-login.brand:}") private String devBrand; - @Value("${erp.dev-login.subsidiary:1111111111}") + @Value("${erp.dev-login.subsidiary:}") private String devSub; @Value("${erp.dev-login.usertype:sysadmin}") private String devUserType; @@ -39,28 +42,41 @@ public class AuthzService { @Value("${erp.dev-login.userid:}") private String devUserIdOverride; - public AuthzService(JdbcTemplate jdbc) { + public AuthzService(JdbcTemplate jdbc, ErpClient erp) { this.jdbc = jdbc; + this.erp = erp; } /** - * 构造 dev-login(本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析 - * (admin → null = 全部)。 + * 服务端身份解析(所有端点的唯一入口)。token 有效 → 内省出的真实身份; + * token 无效/过期 → null(**不**回落 dev,防止过期用户被静默提权); + * token 缺失 → dev-login 开启时 dev 身份,否则 null。 */ - public com.xly.agent.AgentIdentity devIdentity() { - String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId(); - Set granted = grantedIds(uid, devUserType, devBrand, devSub); - return new com.xly.agent.AgentIdentity(null, uid, devBrand, granted); + public com.xly.agent.AgentIdentity resolveIdentity(String authorization) { + String token = authorization == null ? "" : authorization.trim(); + if (!token.isBlank()) { + com.fasterxml.jackson.databind.JsonNode w = erp.whoami(token); + if (w == null) { + return null; + } + String userId = w.path("sId").asText(""); + String brandsId = w.path("sBrandsId").asText(""); + String subsidiaryId = w.path("sSubsidiaryId").asText(""); + String userType = w.path("sType").asText(""); + Set granted = grantedIds(userId, userType, brandsId, subsidiaryId); + return new com.xly.agent.AgentIdentity(token, userId, brandsId, granted); + } + return erp.devLoginEnabled() ? devIdentity() : null; } /** - * 构造透传的真实用户身份:token = 用户浏览器里的 ERP 登录 token(转发给 ERP),权限集按该用户 - * 真实授权({@code sAuthsId})解析。用于生产环境按各用户真实权限收紧。 + * dev-login(仅本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析 + * (admin → null = 全部)。生产不可达(resolveIdentity 已按开关拦截)。 */ - public com.xly.agent.AgentIdentity userIdentity(String token, String userId, - String brandsId, String subsidiaryId, String userType) { - Set granted = grantedIds(userId, userType, brandsId, subsidiaryId); - return new com.xly.agent.AgentIdentity(token, userId, brandsId, granted); + public com.xly.agent.AgentIdentity devIdentity() { + String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId(); + Set granted = grantedIds(uid, devUserType, devBrand, devSub); + return new com.xly.agent.AgentIdentity(null, uid, devBrand, granted); } /** null = 全部(管理员);否则 = 有权的 id 集合。 */ diff --git a/src/main/java/com/xly/service/ConversationService.java b/src/main/java/com/xly/service/ConversationService.java index 9782007..1472346 100644 --- a/src/main/java/com/xly/service/ConversationService.java +++ b/src/main/java/com/xly/service/ConversationService.java @@ -39,13 +39,45 @@ public class ConversationService { this.state = state; } - /** 新建一个空会话,返回 convId。 */ + /** + * 把客户端给的 conversationId 绑定到**服务端身份**:真实 id = {userId}:{客户端 id 的清洗值}。 + * 于是伪造别人的 conversationId 只会落到自己命名空间下的新会话,碰不到他人记忆/账本。 + */ + public String scopedId(com.xly.agent.AgentIdentity identity, String rawConvId) { + String uid = identity == null || identity.userId() == null || identity.userId().isBlank() + ? "anon" : identity.userId(); + String raw = rawConvId == null ? "" : rawConvId.trim(); + // 已经是本人命名空间下的 id → 原样用;否则取其局部名(去掉别人的前缀)再挂到自己名下 + if (raw.startsWith(uid + ":")) { + return raw; + } + String local = raw.isEmpty() ? "default" : raw.replace(':', '_'); + if (local.length() > 64) { + local = local.substring(0, 64); + } + return uid + ":" + local; + } + + /** 新建一个空会话,返回 convId(已绑定该用户命名空间)。 */ public String create(String userId) { - String convId = "c-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); - writeMeta(userId, convId, "新会话"); + String uid = userId == null || userId.isBlank() ? "anon" : userId; + String convId = uid + ":c-" + System.currentTimeMillis() + + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); + writeMeta(uid, convId, "新会话"); return convId; } + /** 会话是否属于该用户(元数据存在 + id 前缀绑定,双重判定)。 */ + public boolean owns(String userId, String convId) { + if (userId == null || userId.isBlank() || convId == null || convId.isBlank()) { + return false; + } + if (!convId.startsWith(userId + ":")) { + return false; + } + return redis.opsForHash().hasKey(CONVS_KEY + userId, convId); + } + /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */ public void touch(String userId, String convId, String firstMsg) { Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId); diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index c7d3851..9db0981 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -43,13 +43,16 @@ public class ErpClient { @Value("${erp.baseurl}") private String baseUrl; - @Value("${erp.dev-login.brand:1111111111}") + /** 仅本地开发可为 true;生产必须 false(默认)——否则空 token 会静默以 dev 账号(管理员)执行。 */ + @Value("${erp.dev-login.enabled:false}") + private boolean devLoginEnabled; + @Value("${erp.dev-login.brand:}") private String brand; - @Value("${erp.dev-login.subsidiary:1111111111}") + @Value("${erp.dev-login.subsidiary:}") private String subsidiary; - @Value("${erp.dev-login.username:admin}") + @Value("${erp.dev-login.username:}") private String username; - @Value("${erp.dev-login.password:666666}") + @Value("${erp.dev-login.password:}") private String password; private volatile String cachedToken; @@ -58,8 +61,16 @@ public class ErpClient { this.mapper = mapper; } - /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。 */ + /** 是否允许 dev-login 兜底(生产 false → 所有无 token 调用 fail-closed)。 */ + public boolean devLoginEnabled() { + return devLoginEnabled; + } + + /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。仅 dev-login 开启时可用。 */ private synchronized String login() { + if (!devLoginEnabled) { + throw new IllegalStateException("缺少用户登录 token(dev-login 已禁用)"); + } try { String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary; String body = mapper.writeValueAsString(Map.of("username", username, "password", password)); @@ -90,6 +101,18 @@ public class ErpClient { return (t != null && !t.isBlank()) ? t : login(); } + /** + * 拼进 URL 的 id(formId/moduleId/opId)必须是纯 id 形态。这些值可能来自模型输出, + * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。 + */ + private static String safeId(String id) { + String s = id == null ? "" : id.trim(); + if (s.isEmpty() || s.length() > 64 || !s.matches("[A-Za-z0-9_.-]+")) { + throw new IllegalArgumentException("非法的 id 参数: " + id); + } + return s; + } + /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */ private String resolveToken(String override) { return (override != null && !override.isBlank()) ? override : token(); @@ -105,10 +128,30 @@ public class ErpClient { return override == null || override.isBlank(); } - /** 读取某表单一页数据(dev-login token,兼容旧调用)。 */ - public JsonNode readForm(String formId, String moduleId, int page, int pageSize, - String filterField, String filterValue) { - return readForm(null, formId, moduleId, page, pageSize, filterField, filterValue); + /** + * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。 + * 返回 {sId,sUserNo,sUserName,sType,sBrandsId,sSubsidiaryId};token 无效/过期返回 null。 + */ + public JsonNode whoami(String authToken) { + if (authToken == null || authToken.isBlank()) { + return null; + } + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/ai/whoami")) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", authToken) + .timeout(Duration.ofSeconds(20)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + JsonNode root = mapper.readTree(resp.body()); + JsonNode row = root.has("sId") ? root : root.path("dataset").path("rows").path(0); + String uid = row.path("sId").asText(""); + return uid.isBlank() ? null : row; + } catch (Exception e) { + log.warn("whoami failed: {}", e.getMessage()); + return null; + } } /** @@ -129,8 +172,8 @@ public class ErpClient { private JsonNode doRead(String formId, String moduleId, int page, int pageSize, String filterField, String filterValue, String tok) { try { - String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + formId - + "?sModelsId=" + moduleId + "&sName="; + String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + safeId(formId) + + "?sModelsId=" + safeId(moduleId) + "&sName="; List> bFilter = new ArrayList<>(); if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) { Map f = new LinkedHashMap<>(); @@ -214,7 +257,7 @@ public class ErpClient { private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) { try { - String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + moduleId; + String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + safeId(moduleId); Map body = new LinkedHashMap<>(); body.put("sId", List.of(billId)); // 后端 sId 期望 List body.put("sTableName", table); @@ -243,7 +286,7 @@ public class ErpClient { */ public JsonNode execStaging(String authToken, String opId) { try { - String url = baseUrl + "/ai/execStaging/" + opId; + String url = baseUrl + "/ai/execStaging/" + safeId(opId); HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .header("Content-Type", "application/json;charset=UTF-8") .header("Authorization", resolveToken(authToken)) @@ -265,7 +308,7 @@ public class ErpClient { private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) { try { - String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId; + String url = baseUrl + "/business/doExamine?sModelsId=" + safeId(moduleId); Map paramsMap = new LinkedHashMap<>(); paramsMap.put("sFormGuid", moduleId); paramsMap.put("sGuid", billId); @@ -287,7 +330,7 @@ public class ErpClient { private JsonNode doDelete(String moduleId, String table, String billId, String tok) { try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); Map col = new LinkedHashMap<>(); col.put("handleType", "del"); col.put("sId", billId); @@ -359,7 +402,7 @@ public class ErpClient { @SuppressWarnings("unchecked") private JsonNode doCreateMulti(String moduleId, List> tables, String tok) { try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); List> data = new ArrayList<>(); for (Map t : tables) { Map col = new LinkedHashMap<>((Map) t.get("column")); @@ -388,7 +431,7 @@ public class ErpClient { private JsonNode doCreate(String moduleId, String table, Map columns, String tok) { try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); Map col = new LinkedHashMap<>(columns); col.put("handleType", "add"); Map dataItem = new LinkedHashMap<>(); @@ -413,7 +456,7 @@ public class ErpClient { private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); Map col = new LinkedHashMap<>(); col.put("handleType", "update"); col.put("sId", billId); diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index 88f6179..2714677 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -201,6 +201,10 @@ public class FormResolverService { out.put("total", 0); out.put("page", Math.max(1, page)); out.put("pageSize", pageSize); + // 租户谓词是强制的:拿不到 brand 宁可返回空,也绝不退化成全库查询 + if (brand == null || brand.isBlank()) { + return out; + } try { Integer ok = jdbc.queryForObject( "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); @@ -280,15 +284,24 @@ public class FormResolverService { return m; } - /** 按列类型把用户给的字符串强转成合法值:数值取数字、日期非法则丢弃(交 ERP 默认)、bit→0/1、其余原样。 */ + /** + * 按列类型把用户给的字符串强转成合法值:日期非法则丢弃(交 ERP 默认)、bit→0/1、其余原样。 + * + *

数值列不做「尽力而为」的截取:「1,000」曾被截成 1、「一千」曾变成 0,而确认卡片显示的仍是原文 + * ——人看到的和写进库的是两份东西。现在只接受可整体解析的数字(允许千分位与空白),否则抛 + * {@link IllegalArgumentException},由调用方转成给用户的报错。 + */ public Object coerce(String dataType, String v) { if (v == null) { return null; } String t = dataType == null ? "varchar" : dataType.toLowerCase(); if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { - Matcher mm = NUM.matcher(v); - return mm.find() ? mm.group() : "0"; + String cleaned = v.replace(",", "").replace(",", "").trim(); + if (!NUM.matcher(cleaned).matches()) { + throw new IllegalArgumentException("「" + v + "」不是有效数字"); + } + return cleaned; } if (t.contains("date") || t.contains("time")) { return v.matches("\\d{4}-\\d{2}-\\d{2}.*") ? v : null; // 非日期 → null,让 ERP 用默认值 diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java index 682c18a..0ab64d0 100644 --- a/src/main/java/com/xly/service/IntentService.java +++ b/src/main/java/com/xly/service/IntentService.java @@ -59,6 +59,7 @@ public class IntentService { JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema()); if (n == null) { log.warn("intent classify fell back to 其他 (model unavailable)"); + out.failed = true; // 门失败 ≠ 真实闲聊:下游护栏不能因此关闭 return out; } String intent = n.path("intent").asText(Intent.OTHER); diff --git a/src/main/java/com/xly/service/OpService.java b/src/main/java/com/xly/service/OpService.java index c20e723..786275b 100644 --- a/src/main/java/com/xly/service/OpService.java +++ b/src/main/java/com/xly/service/OpService.java @@ -69,4 +69,18 @@ public class OpService { jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?", status, resultMsg, sId); } + + /** + * 抢占一条 draft(CAS):只有把 draft 改成 executing 的那个请求返回 true,并发/重复确认直接落空。 + * 没有它,两个并发 confirm 都能通过「读到 draft」的判断,把同一张单执行两次。 + */ + public boolean claim(String sId) { + return jdbc.update("UPDATE ai_op_queue SET sStatus='executing', tConfirmDate=NOW() " + + "WHERE sId=? AND sStatus='draft'", sId) == 1; + } + + /** 执行失败/异常时把 executing 退回 draft 之外的终态由 setStatus 负责;这里仅用于放弃抢占。 */ + public void release(String sId) { + jdbc.update("UPDATE ai_op_queue SET sStatus='draft' WHERE sId=? AND sStatus='executing'", sId); + } } diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index 8e4bfbb..4f2b2cd 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -118,13 +118,42 @@ public class ProposeWriteTool { if (field == null) { return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); } + // 系统/审计列(单号、制单人、租户、主键、日期标志…)不允许由对话改写 + if (resolver.isSystemColumn(field)) { + return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。"); + } String oldValue = l.rec.path(field).asText(""); - // 3) 暂存 draft(不执行) + // 3) 值规范化:外键列把名称解析成 id,其余按列类型强转(失败即报错,绝不静默写入错值) + String fkTable = queryOne( + "SELECT MAX(sFkTable) FROM viw_kg_field_dict WHERE sTable=? AND sField=?", l.table, field); + Object stored; + String shown = newValue; + if (fkTable != null && !fkTable.isBlank() && !"null".equalsIgnoreCase(fkTable)) { + String id = resolver.resolveFk(fkTable, newValue); + if (id == null) { + String ent = fieldChinese.replace("名称", ""); + return err("「" + newValue + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"); + } + stored = id; + } else { + try { + stored = resolver.coerce(resolver.columnTypes(l.table).get(field), newValue); + } catch (IllegalArgumentException ex) { + return err("「" + fieldChinese + "」" + ex.getMessage() + ",请给一个有效值。"); + } + if (stored == null) { + return err("「" + newValue + "」不是「" + fieldChinese + "」可接受的值(日期请用 2026-07-28 这种格式)。"); + } + shown = String.valueOf(stored); + } + + // 4) 暂存 draft(不执行)。摘要展示**将要写入的值**,与 payload 同源,避免"看到的与执行的不一致" String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" - + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; + + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + shown + "」" + + (shown.equals(newValue) ? "" : "(原话:" + newValue + ")"); String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId, - field, fieldChinese, oldValue, newValue, description); + field, fieldChinese, oldValue, String.valueOf(stored), description); Map out = new LinkedHashMap<>(); out.put("opId", opId); @@ -216,13 +245,21 @@ public class ProposeWriteTool { + ent + ";**不要**因此新建" + ent + "。"); } col.put(colName, id); + descParts.add(zh + "=" + v); } else { - Object cv = resolver.coerce(types.get(colName), v); // 按列类型强转 - if (cv != null) { - col.put(colName, cv); + Object cv; + try { + cv = resolver.coerce(types.get(colName), v); // 按列类型强转 + } catch (IllegalArgumentException ex) { + return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。"); } + if (cv == null) { + return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。"); + } + col.put(colName, cv); + // 摘要只写**真正入库的值**,避免确认卡片与 payload 不一致 + descParts.add(zh + "=" + cv + (String.valueOf(cv).equals(v) ? "" : "(原话:" + v + ")")); } - descParts.add(zh + "=" + v); } } } catch (Exception ex) { @@ -330,12 +367,17 @@ public class ProposeWriteTool { } if ("manyqtys".equals(tgt)) { for (String q : v.split("[,,、\\s]+")) { - String n = q.replaceAll("[^0-9.]", ""); - if (!n.isEmpty()) { - manyQtys.add(n); + String n = q.trim(); + if (n.isEmpty()) { + continue; + } + // 逐个必须是干净数字:以前 replaceAll 会把「三千」吞成空、「1k」变成 1 + if (!n.matches("\\d+(\\.\\d+)?")) { + return err("多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。"); } + manyQtys.add(n); } - descParts.add(zh + "=" + v); + descParts.add(zh + "=" + String.join(",", manyQtys)); continue; } Object value; @@ -352,16 +394,23 @@ public class ProposeWriteTool { } } else { String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName); - value = resolver.coerce(dt, v); - } - if (value != null) { - if ("slave".equals(tgt)) { - slaveCol.put(colName, value); - } else { - masterCol.put(colName, value); + try { + value = resolver.coerce(dt, v); + } catch (IllegalArgumentException ex) { + return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。"); } + if (value == null) { + return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。"); + } + } + if ("slave".equals(tgt)) { + slaveCol.put(colName, value); + } else { + masterCol.put(colName, value); } - descParts.add(zh + "=" + v); + // 摘要写真正入库的值(FK 显示用户给的名称,数值显示规范化后的数) + String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(value); + descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")")); } } } catch (Exception ex) { diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 5bb2143..189a567 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -96,26 +97,27 @@ public class AgentChatController { public static class ChatReq { public String text; - public String userid; public String conversationId; - // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt) + /** 透传的 ERP 会话 token(token 绝不进 prompt)。身份一律由它内省得出,不接受客户端自报。 */ public String authorization; - public String brandsid; - public String subsidiaryid; - public String usertype; } @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") - public SseEmitter chat(@RequestBody ChatReq req) { + public SseEmitter chat(@RequestBody ChatReq req, + @RequestHeader(value = "Authorization", required = false) String authHeader) { SseEmitter emitter = new SseEmitter(180_000L); final String userInput = req.text == null ? "" : req.text; - final String convId = (req.conversationId != null && !req.conversationId.isBlank()) - ? req.conversationId - : ((req.userid == null ? "anon" : req.userid) + ":default"); + final AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization)); + if (identity == null) { + send(emitter, "error", "登录已过期或未登录,请重新登录后再试。"); + emitter.complete(); + return emitter; + } + // 会话键绑定服务端身份 → 换个 conversationId 只能命中自己的会话,碰不到别人的记忆 + final String convId = conversations.scopedId(identity, req.conversationId); - conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); + conversations.touch(identity.userId(), convId, userInput); ledger.append(convId, "user", Map.of("text", userInput)); - final AgentIdentity identity = resolveIdentity(req); exec.submit(() -> { try { @@ -132,12 +134,8 @@ public class AgentChatController { public static class FormSubmitReq { public String entity; public Map fields; // 字段中文名 -> 值 - public String userid; public String conversationId; public String authorization; - public String brandsid; - public String subsidiaryid; - public String usertype; } /** @@ -145,24 +143,21 @@ public class AgentChatController { * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。 */ @PostMapping("/form/submit") - public Map formSubmit(@RequestBody FormSubmitReq req) { - final String convId = (req.conversationId != null && !req.conversationId.isBlank()) - ? req.conversationId - : ((req.userid == null ? "anon" : req.userid) + ":default"); + public Map formSubmit(@RequestBody FormSubmitReq req, + @RequestHeader(value = "Authorization", required = false) String authHeader) { Map out = new LinkedHashMap<>(); + AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization)); + if (identity == null) { + out.put("error", "登录已过期或未登录,请重新登录后再试。"); + return out; + } + final String convId = conversations.scopedId(identity, req.conversationId); String entity = req.entity == null ? "" : req.entity.trim(); Map fields = req.fields == null ? Map.of() : req.fields; if (entity.isEmpty() || fields.isEmpty()) { out.put("error", "缺少单据类型或表单字段。"); return out; } - ChatReq idReq = new ChatReq(); - idReq.userid = req.userid; - idReq.authorization = req.authorization; - idReq.brandsid = req.brandsid; - idReq.subsidiaryid = req.subsidiaryid; - idReq.usertype = req.usertype; - AgentIdentity identity = resolveIdentity(idReq); StringBuilder parts = new StringBuilder(); fields.forEach((k, v) -> { @@ -172,7 +167,7 @@ public class AgentChatController { } }); String userText = "提交「" + entity + "」新增表单:" + parts; - conversations.touch(req.userid == null ? "anon" : req.userid, convId, userText); + conversations.touch(identity.userId(), convId, userText); ledger.append(convId, "user", Map.of("text", userText)); String fieldsJson; @@ -233,8 +228,8 @@ public class AgentChatController { runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); return; default: - // 其他/分类失败:原文交给 agent,尽量不丢能力。 - runAgent(emitter, convId, identity, withState(userInput, digest), false); + // 其他/分类失败:原文交给 agent,尽量不丢能力。门失败时不能顺带关掉反编造护栏。 + runAgent(emitter, convId, identity, withState(userInput, digest), it.failed); } } @@ -313,8 +308,8 @@ public class AgentChatController { /** * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行): - * 查询轮**零工具调用**却答出数字 → 重试一次强制先查数,仍复发则标注「未经核实」; - * 非查询轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示(无提议卡片即未生效)。 + * queryGuard 轮(查询 / 意图门失败)**零工具调用**却答出数字 → 重试一次强制先查数, + * 仍复发则标注「未经核实」;任意轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示。 */ private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, String text, boolean queryGuard, boolean allowRetry) { @@ -342,7 +337,7 @@ public class AgentChatController { } send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); } - if (!queryGuard && !proposed.get() && WRITE_CLAIM.matcher(answer).find()) { + if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) { send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); } if (!answer.isBlank()) { @@ -511,17 +506,9 @@ public class AgentChatController { return s == null || s.isBlank(); } - /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ - private AgentIdentity resolveIdentity(ChatReq req) { - try { - if (req.authorization != null && !req.authorization.isBlank()) { - return authz.userIdentity(req.authorization.trim(), req.userid, - req.brandsid, req.subsidiaryid, req.usertype); - } - } catch (Exception e) { - log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage()); - } - return authz.devIdentity(); + private static String firstNonBlank(String a, String b) { + if (a != null && !a.isBlank()) return a; + return b; } /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */ diff --git a/src/main/java/com/xly/web/ConversationController.java b/src/main/java/com/xly/web/ConversationController.java index b704163..05ff6c4 100644 --- a/src/main/java/com/xly/web/ConversationController.java +++ b/src/main/java/com/xly/web/ConversationController.java @@ -1,55 +1,81 @@ package com.xly.web; +import com.xly.agent.AgentIdentity; +import com.xly.service.AuthzService; import com.xly.service.ConversationService; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; /** * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。 + * + *

身份:一律由 {@code Authorization} 头经 {@link AuthzService#resolveIdentity} 服务端内省得出, + * 不接受客户端自报的 userid;会话 id 绑定用户命名空间,越权访问他人会话直接 403。 */ @RestController @RequestMapping("/api/agent/conversations") public class ConversationController { private final ConversationService conversations; + private final AuthzService authz; - public ConversationController(ConversationService conversations) { + public ConversationController(ConversationService conversations, AuthzService authz) { this.conversations = conversations; + this.authz = authz; } - /** 列出某用户的会话(最近更新在前)。 */ + /** 列出当前登录用户的会话(最近更新在前)。 */ @GetMapping - public List> list(@RequestParam("userid") String userid) { - return conversations.list(userid); + public List> list(@RequestHeader(value = "Authorization", required = false) String auth) { + return conversations.list(identity(auth).userId()); } /** 新建空会话,返回 conversationId。 */ @PostMapping - public Map create(@RequestBody Map body) { - String userid = body.getOrDefault("userid", "anon"); - return Map.of("conversationId", conversations.create(userid)); + public Map create(@RequestHeader(value = "Authorization", required = false) String auth) { + return Map.of("conversationId", conversations.create(identity(auth).userId())); } - /** 删除会话(含其消息记忆)。 */ + /** 删除自己的会话(含消息记忆、账本、状态槽)。 */ @DeleteMapping("/{convId}") public Map delete(@PathVariable("convId") String convId, - @RequestParam("userid") String userid) { - conversations.delete(userid, convId); + @RequestHeader(value = "Authorization", required = false) String auth) { + String uid = requireOwner(auth, convId); + conversations.delete(uid, convId); return Map.of("ok", true); } - /** 会话历史消息(用于切换会话时回填)。 */ + /** 自己会话的历史消息(用于切换会话时回填)。 */ @GetMapping("/{convId}/messages") - public List> messages(@PathVariable("convId") String convId) { + public List> messages(@PathVariable("convId") String convId, + @RequestHeader(value = "Authorization", required = false) String auth) { + requireOwner(auth, convId); return conversations.history(convId); } + + private AgentIdentity identity(String auth) { + AgentIdentity id = authz.resolveIdentity(auth); + if (id == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); + } + return id; + } + + private String requireOwner(String auth, String convId) { + AgentIdentity id = identity(auth); + if (!conversations.owns(id.userId(), convId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话"); + } + return id.userId(); + } } diff --git a/src/main/java/com/xly/web/FormController.java b/src/main/java/com/xly/web/FormController.java index f97fff2..81743e3 100644 --- a/src/main/java/com/xly/web/FormController.java +++ b/src/main/java/com/xly/web/FormController.java @@ -1,10 +1,15 @@ package com.xly.web; +import com.xly.agent.AgentIdentity; +import com.xly.service.AuthzService; import com.xly.service.FormResolverService; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import java.util.Map; @@ -12,28 +17,34 @@ import java.util.Map; * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取, * 多列展示 + 名称搜索 + 分页。 * - *

前端渲染 {@code type=fkselect} 字段时调 - * {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20}, + *

前端调 {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20}, * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。 - * 安全:{@link FormResolverService#fkOptionPage} 只允许「在字段字典里作为外键目标出现过」的表, - * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。 + * + *

安全:需登录;**租户来自服务端内省的身份**(不接受客户端传 brandsid,否则不传即可跨租户全库翻页); + * 表名限于「在字段字典里作为外键目标出现过」的白名单(见 {@link FormResolverService#fkOptionPage})。 */ @RestController @RequestMapping("/api/agent/form") public class FormController { private final FormResolverService resolver; + private final AuthzService authz; - public FormController(FormResolverService resolver) { + public FormController(FormResolverService resolver, AuthzService authz) { this.resolver = resolver; + this.authz = authz; } @GetMapping("/options") public Map options(@RequestParam("table") String table, @RequestParam(value = "q", required = false) String q, - @RequestParam(value = "brandsid", required = false) String brandsid, @RequestParam(value = "page", defaultValue = "1") int page, - @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { - return resolver.fkOptionPage(table, brandsid, q, page, pageSize); + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize, + @RequestHeader(value = "Authorization", required = false) String auth) { + AgentIdentity id = authz.resolveIdentity(auth); + if (id == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); + } + return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize); } } diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index 073f8f8..d3c3ad8 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -2,12 +2,18 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; import com.xly.config.RedisChatMemoryStore; import com.xly.service.AuditService; +import com.xly.service.AuthzService; +import com.xly.service.ConversationService; import com.xly.service.ErpClient; +import com.xly.service.FormResolverService; import com.xly.service.LedgerService; import com.xly.service.OpService; import com.xly.service.StateService; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; @@ -48,13 +54,18 @@ public class OpController { private final LedgerService ledger; private final StateService state; private final RedisChatMemoryStore memoryStore; + private final AuthzService authz; + private final ConversationService conversations; + private final FormResolverService resolver; /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") private boolean execStagingEnabled; public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, - LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore) { + LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore, + AuthzService authz, ConversationService conversations, + FormResolverService resolver) { this.ops = ops; this.erp = erp; this.audit = audit; @@ -62,6 +73,9 @@ public class OpController { this.ledger = ledger; this.state = state; this.memoryStore = memoryStore; + this.authz = authz; + this.conversations = conversations; + this.resolver = resolver; } /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */ @@ -90,11 +104,26 @@ public class OpController { } } - /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ + /** + * 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 + * 需登录且只能查自己的会话;只回渲染必需的字段——绝不外泄 sPayload/内部表名等。 + */ @GetMapping("/pending") - public Map pending(@RequestParam("conversationId") String conversationId) { + public Map pending(@RequestParam("conversationId") String conversationId, + @RequestHeader(value = "Authorization", required = false) String authToken) { + String uid = requireLogin(authToken).userId(); + if (!conversations.owns(uid, conversationId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话"); + } Map op = ops.pending(conversationId); - return op == null ? Map.of() : op; + if (op == null) { + return Map.of(); + } + Map out = new LinkedHashMap<>(); + out.put("opId", op.get("sId")); + out.put("summary", op.get("sDescription")); + out.put("status", op.get("sStatus")); + return out; } /** @@ -105,13 +134,21 @@ public class OpController { @PostMapping("/{id}/confirm") public Map confirm(@PathVariable("id") String id, @RequestHeader(value = "Authorization", required = false) String authToken) { + AgentIdentity me = requireLogin(authToken); Map op = ops.get(id); if (op == null) { return result("failed", "找不到该操作", null); } + requireOwner(me, op); if (!"draft".equals(String.valueOf(op.get("sStatus")))) { return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); } + // CAS 抢占:并发/重复确认只有一个能真正执行,杜绝同一张单被执行两次 + if (!ops.claim(id)) { + Map cur = ops.get(id); + String st = cur == null ? "unknown" : String.valueOf(cur.get("sStatus")); + return result(st, "该操作正在处理或已处理过(" + st + ")", op); + } String uid = str(op.get("sUserId")); String conv = str(op.get("sConversationId")); String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")); @@ -129,10 +166,16 @@ public class OpController { if (payload.has("__tables__")) { // 报价等多表主-从创建 @SuppressWarnings("unchecked") List> tables = mapper.convertValue(payload.get("__tables__"), List.class); + if (!tables.isEmpty()) { + @SuppressWarnings("unchecked") + Map masterCol = (Map) tables.get(0).get("column"); + refreshBillNo(masterCol, str(op.get("sTargetTable")), me.brandsId()); + } r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables); } else { @SuppressWarnings("unchecked") Map columns = mapper.convertValue(payload, Map.class); + refreshBillNo(columns, str(op.get("sTargetTable")), me.brandsId()); r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns); } } else if ("delete".equals(opType)) { @@ -189,10 +232,15 @@ public class OpController { } } - /** 取消。 */ + /** 取消(需登录且必须是本人的操作)。 */ @PostMapping("/{id}/cancel") - public Map cancel(@PathVariable("id") String id) { + public Map cancel(@PathVariable("id") String id, + @RequestHeader(value = "Authorization", required = false) String authToken) { + AgentIdentity me = requireLogin(authToken); Map op = ops.get(id); + if (op != null) { + requireOwner(me, op); + } if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { ops.setStatus(id, "cancelled", "用户取消"); audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", @@ -203,6 +251,37 @@ public class OpController { return result("cancelled", "已取消", op); } + /** + * 单号在 propose 时算的是当时的 MAX+1;期间别人建过单就会撞号,所以**执行前重新生成**。 + * 拿不到新号时保留旧号(由 ERP 侧唯一约束兜底),不因此中断执行。 + */ + private void refreshBillNo(Map columns, String table, String brandsId) { + if (columns == null || !columns.containsKey("sBillNo")) { + return; + } + String fresh = resolver.nextBillNo(table, brandsId); + if (fresh != null && !fresh.isBlank()) { + columns.put("sBillNo", fresh); + } + } + + /** 需登录(token 服务端内省);未登录/过期 → 401。 */ + private AgentIdentity requireLogin(String authToken) { + AgentIdentity id = authz.resolveIdentity(authToken); + if (id == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); + } + return id; + } + + /** 只有提议的发起人本人能确认/取消——否则任何人拿到 opId 就能替别人写库。 */ + private void requireOwner(AgentIdentity me, Map op) { + String owner = str(op.get("sUserId")); + if (owner == null || !owner.equals(me.userId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权处理该操作"); + } + } + private Map result(String status, String msg, Map op) { Map m = new LinkedHashMap<>(); m.put("status", status); diff --git a/src/main/resources/application-saaslocal.yml b/src/main/resources/application-saaslocal.yml index fb41560..678ff99 100644 --- a/src/main/resources/application-saaslocal.yml +++ b/src/main/resources/application-saaslocal.yml @@ -12,10 +12,13 @@ logging: spring: datasource: - # Local saas DB from docker-compose.saas.yml (mysql-saas, root/local). - 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 + # Local saas DB from docker-compose.saas.yml (mysql-saas, root/local) — local throwaway creds only. + 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 username: root password: local + data: + redis: + password: xlyXLY2015 # Point ERP form-read API at the LOCAL xlyEntry (:8697, ctx /xlyEntry) which uses the # same local DB. The committed application.yml erp.baseurl targets the remote deploy. @@ -23,6 +26,7 @@ spring: # production should instead pass through the user's own browser ERP token per request. erp: baseurl: http://127.0.0.1:8697/xlyEntry + # 仅本地:无 token 时以 dev 账号执行。生产 profile 必须 false(application.yml 默认即 false)。 dev-login: enabled: true brand: "1111111111" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 324abfa..03a0730 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,9 +26,11 @@ spring: cache: false # 开发时关闭缓存 datasource: driver-class-name: com.mysql.cj.jdbc.Driver - 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 - username: xlyprint - password: xlyXLYprint2016 + # 连接串与口令一律来自环境变量(DB_URL/DB_USERNAME/DB_PASSWORD)——仓库里不放任何生产凭据。 + # 单语句执行:allowMultiQueries 关闭,堆叠语句在驱动层就被拒。 + 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} + username: ${DB_USERNAME:} + password: ${DB_PASSWORD:} # 连接池配置(使用HikariCP) hikari: maximum-pool-size: 20 @@ -39,9 +41,9 @@ spring: # REDIS (RedisProperties) data: redis: - host: 127.0.0.1 - password: xlyXLY2015 - port: 16379 + host: ${REDIS_HOST:127.0.0.1} + password: ${REDIS_PASSWORD:} + port: ${REDIS_PORT:16379} database: 0 # index timeout: 30000ms # 连接超时时长(毫秒) block-when-exhausted: true @@ -63,9 +65,13 @@ management: # LLM:OpenAI 兼容协议(Ollama /v1、vLLM、云端网关皆可,换供应商只改这里) llm: - base-url: http://112.82.245.194:41434/v1 - api-key: ollama # Ollama 不校验(任意非空);云端填真实 key - chat-model: qwen3.6-27b-iq3:latest + base-url: ${LLM_BASE_URL:http://112.82.245.194:41434/v1} + api-key: ${LLM_API_KEY:ollama} # Ollama 不校验(任意非空);云端填真实 key + chat-model: ${LLM_CHAT_MODEL:qwen3.6-27b-iq3:latest} erp: - baseurl: http://118.178.19.35:8080/xlyEntry_saas + baseurl: ${ERP_BASEURL:http://118.178.19.35:8080/xlyEntry_saas} + # dev-login = 无 token 时以 dev 账号(管理员)执行。生产必须保持 false, + # 否则任何未登录请求都会拿到管理员身份。本地开发在 application-saaslocal.yml 里开。 + dev-login: + enabled: false diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index dc86c0e..d8b299a 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -338,14 +338,23 @@ body {