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