Commit 7a3da4eea208bf76263dfd49bc51c8867003024e
1 parent
8d3905a2
feat: NL2SQL table-allowlist + tenant injection (§9) + Langfuse export (§1) + ch…
…at UI (question/form_collect/token) - QueryTool: enforce table allowlist (viw_* + form data-sources + field-dict tables) via jsqlparser TablesNamesFinder -> blocks NL2SQL reading gdslogininfo/sysjurisdiction/ai_op_queue etc; single-table tenant predicate injection (sBrandsId); brand hint in prompt - TracingChatModelListener: config-gated Langfuse ingestion export (generation span) via JDK HttpClient, no new deps; docker-compose.langfuse.yml self-host - chat.html: send identity+token in chat req; render question(options) + form_collect(rich fields); forward Authorization on confirm/cancel - application-saaslocal.yml: langfuse config (off by default)
Showing
4 changed files
with
323 additions
and
25 deletions
src/main/java/com/xly/config/TracingChatModelListener.java
| 1 | package com.xly.config; | 1 | package com.xly.config; |
| 2 | 2 | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 3 | import dev.langchain4j.model.chat.listener.ChatModelErrorContext; | 4 | import dev.langchain4j.model.chat.listener.ChatModelErrorContext; |
| 4 | import dev.langchain4j.model.chat.listener.ChatModelListener; | 5 | import dev.langchain4j.model.chat.listener.ChatModelListener; |
| 5 | import dev.langchain4j.model.chat.listener.ChatModelRequestContext; | 6 | import dev.langchain4j.model.chat.listener.ChatModelRequestContext; |
| @@ -7,44 +8,147 @@ import dev.langchain4j.model.chat.listener.ChatModelResponseContext; | @@ -7,44 +8,147 @@ import dev.langchain4j.model.chat.listener.ChatModelResponseContext; | ||
| 7 | import dev.langchain4j.model.output.TokenUsage; | 8 | import dev.langchain4j.model.output.TokenUsage; |
| 8 | import org.slf4j.Logger; | 9 | import org.slf4j.Logger; |
| 9 | import org.slf4j.LoggerFactory; | 10 | import org.slf4j.LoggerFactory; |
| 11 | +import org.springframework.beans.factory.annotation.Value; | ||
| 10 | import org.springframework.stereotype.Component; | 12 | import org.springframework.stereotype.Component; |
| 11 | 13 | ||
| 14 | +import java.net.URI; | ||
| 15 | +import java.net.http.HttpClient; | ||
| 16 | +import java.net.http.HttpRequest; | ||
| 17 | +import java.net.http.HttpResponse; | ||
| 18 | +import java.nio.charset.StandardCharsets; | ||
| 19 | +import java.time.Duration; | ||
| 20 | +import java.time.Instant; | ||
| 21 | +import java.util.Base64; | ||
| 22 | +import java.util.LinkedHashMap; | ||
| 23 | +import java.util.List; | ||
| 24 | +import java.util.Map; | ||
| 25 | +import java.util.UUID; | ||
| 26 | + | ||
| 12 | /** | 27 | /** |
| 13 | - * LLM 调用观测(轻量 tracing)—— 每次模型调用记录耗时 / token 用量 / 错误到 `com.xly.trace.llm` 日志。 | 28 | + * LLM 调用观测(tracing)—— 每次模型调用记录耗时 / token 用量 / 错误。 |
| 14 | * | 29 | * |
| 15 | - * <p>这是 **Langfuse 的接入点**:Langfuse 本体是需自托管的观测服务(需要实例 + 密钥),此处的 | ||
| 16 | - * onResponse/onError 就是把 span 转发给 Langfuse 的挂钩位;在没有实例的环境下先落到日志,保证 | ||
| 17 | - * 「LLM 可观测」这一能力有实现、可随时对接 Langfuse。业务审计另见 `ai_audit_log`(与 LLM tracing 分离)。 | 30 | + * <p>默认落 {@code com.xly.trace.llm} 日志(保证「LLM 可观测」这一能力始终有实现)。当配置 |
| 31 | + * {@code langfuse.enabled=true} 且给了 host + 公私钥时,额外把一条 generation span **转发到自托管 Langfuse** | ||
| 32 | + * 的 ingestion API(不引第三方依赖,直接用 JDK HttpClient,best-effort、异步、失败不影响主流程)。 | ||
| 33 | + * 自托管方式见 {@code docker-compose.langfuse.yml}。业务审计另见 {@code ai_audit_log}(与 LLM tracing 分离)。 | ||
| 18 | */ | 34 | */ |
| 19 | @Component | 35 | @Component |
| 20 | public class TracingChatModelListener implements ChatModelListener { | 36 | public class TracingChatModelListener implements ChatModelListener { |
| 21 | 37 | ||
| 22 | private static final Logger log = LoggerFactory.getLogger("com.xly.trace.llm"); | 38 | private static final Logger log = LoggerFactory.getLogger("com.xly.trace.llm"); |
| 23 | 39 | ||
| 40 | + private final ObjectMapper mapper; | ||
| 41 | + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); | ||
| 42 | + | ||
| 43 | + @Value("${langfuse.enabled:false}") | ||
| 44 | + private boolean langfuseEnabled; | ||
| 45 | + @Value("${langfuse.host:http://localhost:3000}") | ||
| 46 | + private String langfuseHost; | ||
| 47 | + @Value("${langfuse.public-key:}") | ||
| 48 | + private String publicKey; | ||
| 49 | + @Value("${langfuse.secret-key:}") | ||
| 50 | + private String secretKey; | ||
| 51 | + @Value("${langchain4j.ollama.chat-model-name:unknown}") | ||
| 52 | + private String modelName; | ||
| 53 | + | ||
| 54 | + public TracingChatModelListener(ObjectMapper mapper) { | ||
| 55 | + this.mapper = mapper; | ||
| 56 | + } | ||
| 57 | + | ||
| 24 | @Override | 58 | @Override |
| 25 | public void onRequest(ChatModelRequestContext ctx) { | 59 | public void onRequest(ChatModelRequestContext ctx) { |
| 26 | ctx.attributes().put("t0", System.nanoTime()); | 60 | ctx.attributes().put("t0", System.nanoTime()); |
| 61 | + ctx.attributes().put("startTs", Instant.now().toString()); | ||
| 27 | } | 62 | } |
| 28 | 63 | ||
| 29 | @Override | 64 | @Override |
| 30 | public void onResponse(ChatModelResponseContext ctx) { | 65 | public void onResponse(ChatModelResponseContext ctx) { |
| 31 | long ms = elapsedMs(ctx.attributes().get("t0")); | 66 | long ms = elapsedMs(ctx.attributes().get("t0")); |
| 32 | - String tok = "?"; | 67 | + Integer in = null; |
| 68 | + Integer out = null; | ||
| 33 | try { | 69 | try { |
| 34 | TokenUsage u = ctx.chatResponse() == null ? null : ctx.chatResponse().tokenUsage(); | 70 | TokenUsage u = ctx.chatResponse() == null ? null : ctx.chatResponse().tokenUsage(); |
| 35 | if (u != null) { | 71 | if (u != null) { |
| 36 | - tok = u.inputTokenCount() + "/" + u.outputTokenCount(); | 72 | + in = u.inputTokenCount(); |
| 73 | + out = u.outputTokenCount(); | ||
| 37 | } | 74 | } |
| 38 | } catch (Exception ignore) { | 75 | } catch (Exception ignore) { |
| 39 | } | 76 | } |
| 40 | - log.info("LLM ok {}ms tokens(in/out)={}", ms, tok); | ||
| 41 | - // Langfuse 接入点:此处可 forward 一个 span(model, prompt, completion, latency, tokens)。 | 77 | + log.info("LLM ok {}ms tokens(in/out)={}/{}", ms, in, out); |
| 78 | + exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), in, out, null); | ||
| 42 | } | 79 | } |
| 43 | 80 | ||
| 44 | @Override | 81 | @Override |
| 45 | public void onError(ChatModelErrorContext ctx) { | 82 | public void onError(ChatModelErrorContext ctx) { |
| 46 | Throwable e = ctx.error(); | 83 | Throwable e = ctx.error(); |
| 47 | - log.warn("LLM error: {}", e == null ? "?" : e.getMessage()); | 84 | + String msg = e == null ? "?" : e.getMessage(); |
| 85 | + log.warn("LLM error: {}", msg); | ||
| 86 | + exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), null, null, msg); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + /** 把一条 generation span 转发到 Langfuse(best-effort,异步,失败仅告警)。未启用则直接返回。 */ | ||
| 90 | + private void exportToLangfuse(String startTs, Integer in, Integer out, String error) { | ||
| 91 | + if (!langfuseEnabled || publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) { | ||
| 92 | + return; | ||
| 93 | + } | ||
| 94 | + try { | ||
| 95 | + String traceId = UUID.randomUUID().toString(); | ||
| 96 | + String now = Instant.now().toString(); | ||
| 97 | + String start = (startTs == null || "null".equals(startTs)) ? now : startTs; | ||
| 98 | + | ||
| 99 | + Map<String, Object> usage = new LinkedHashMap<>(); | ||
| 100 | + usage.put("input", in); | ||
| 101 | + usage.put("output", out); | ||
| 102 | + usage.put("unit", "TOKENS"); | ||
| 103 | + | ||
| 104 | + Map<String, Object> genBody = new LinkedHashMap<>(); | ||
| 105 | + genBody.put("id", UUID.randomUUID().toString()); | ||
| 106 | + genBody.put("traceId", traceId); | ||
| 107 | + genBody.put("name", "xlyAi-agent"); | ||
| 108 | + genBody.put("model", modelName); | ||
| 109 | + genBody.put("startTime", start); | ||
| 110 | + genBody.put("endTime", now); | ||
| 111 | + genBody.put("usage", usage); | ||
| 112 | + if (error != null) { | ||
| 113 | + genBody.put("level", "ERROR"); | ||
| 114 | + genBody.put("statusMessage", error); | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + Map<String, Object> traceBody = new LinkedHashMap<>(); | ||
| 118 | + traceBody.put("id", traceId); | ||
| 119 | + traceBody.put("name", "xlyAi-agent"); | ||
| 120 | + traceBody.put("timestamp", start); | ||
| 121 | + | ||
| 122 | + List<Map<String, Object>> batch = List.of( | ||
| 123 | + event("trace-create", now, traceBody), | ||
| 124 | + event("generation-create", now, genBody)); | ||
| 125 | + String body = mapper.writeValueAsString(Map.of("batch", batch)); | ||
| 126 | + | ||
| 127 | + String auth = "Basic " + Base64.getEncoder().encodeToString( | ||
| 128 | + (publicKey + ":" + secretKey).getBytes(StandardCharsets.UTF_8)); | ||
| 129 | + HttpRequest req = HttpRequest.newBuilder(URI.create(langfuseHost + "/api/public/ingestion")) | ||
| 130 | + .header("Content-Type", "application/json") | ||
| 131 | + .header("Authorization", auth) | ||
| 132 | + .timeout(Duration.ofSeconds(5)) | ||
| 133 | + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) | ||
| 134 | + .build(); | ||
| 135 | + http.sendAsync(req, HttpResponse.BodyHandlers.discarding()) | ||
| 136 | + .exceptionally(t -> { | ||
| 137 | + log.debug("Langfuse export failed: {}", t.getMessage()); | ||
| 138 | + return null; | ||
| 139 | + }); | ||
| 140 | + } catch (Exception e) { | ||
| 141 | + log.debug("Langfuse export error: {}", e.getMessage()); | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + private Map<String, Object> event(String type, String ts, Map<String, Object> body) { | ||
| 146 | + Map<String, Object> ev = new LinkedHashMap<>(); | ||
| 147 | + ev.put("id", UUID.randomUUID().toString()); | ||
| 148 | + ev.put("type", type); | ||
| 149 | + ev.put("timestamp", ts); | ||
| 150 | + ev.put("body", body); | ||
| 151 | + return ev; | ||
| 48 | } | 152 | } |
| 49 | 153 | ||
| 50 | private long elapsedMs(Object t0) { | 154 | private long elapsedMs(Object t0) { |
src/main/java/com/xly/tool/QueryTool.java
| @@ -5,13 +5,23 @@ import com.xly.service.AuditService; | @@ -5,13 +5,23 @@ import com.xly.service.AuditService; | ||
| 5 | import dev.langchain4j.agent.tool.P; | 5 | import dev.langchain4j.agent.tool.P; |
| 6 | import dev.langchain4j.agent.tool.Tool; | 6 | import dev.langchain4j.agent.tool.Tool; |
| 7 | import dev.langchain4j.model.ollama.OllamaChatModel; | 7 | import dev.langchain4j.model.ollama.OllamaChatModel; |
| 8 | +import net.sf.jsqlparser.expression.StringValue; | ||
| 9 | +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; | ||
| 10 | +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; | ||
| 8 | import net.sf.jsqlparser.parser.CCJSqlParserUtil; | 11 | import net.sf.jsqlparser.parser.CCJSqlParserUtil; |
| 12 | +import net.sf.jsqlparser.schema.Column; | ||
| 13 | +import net.sf.jsqlparser.schema.Table; | ||
| 9 | import net.sf.jsqlparser.statement.Statement; | 14 | import net.sf.jsqlparser.statement.Statement; |
| 15 | +import net.sf.jsqlparser.statement.select.PlainSelect; | ||
| 10 | import net.sf.jsqlparser.statement.select.Select; | 16 | import net.sf.jsqlparser.statement.select.Select; |
| 17 | +import net.sf.jsqlparser.util.TablesNamesFinder; | ||
| 11 | import org.springframework.jdbc.core.JdbcTemplate; | 18 | import org.springframework.jdbc.core.JdbcTemplate; |
| 12 | 19 | ||
| 20 | +import java.util.HashSet; | ||
| 13 | import java.util.List; | 21 | import java.util.List; |
| 14 | import java.util.Map; | 22 | import java.util.Map; |
| 23 | +import java.util.Set; | ||
| 24 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 15 | 25 | ||
| 16 | /** | 26 | /** |
| 17 | * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 | 27 | * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 |
| @@ -60,7 +70,7 @@ public class QueryTool { | @@ -60,7 +70,7 @@ public class QueryTool { | ||
| 60 | audit.log(null, null, "query", "REJECTED", sql, false, reject); | 70 | audit.log(null, null, "query", "REJECTED", sql, false, reject); |
| 61 | return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; | 71 | return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; |
| 62 | } | 72 | } |
| 63 | - String limited = forceLimit(sql); | 73 | + String limited = forceLimit(applyTenant(sql)); |
| 64 | try { | 74 | try { |
| 65 | List<Map<String, Object>> rows = jdbc.queryForList(limited); | 75 | List<Map<String, Object>> rows = jdbc.queryForList(limited); |
| 66 | audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); | 76 | audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); |
| @@ -85,11 +95,17 @@ public class QueryTool { | @@ -85,11 +95,17 @@ public class QueryTool { | ||
| 85 | 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 | 95 | 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 |
| 86 | 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): | 96 | 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): |
| 87 | %s | 97 | %s |
| 88 | - 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); | 98 | + 规则:只用 SELECT(严禁任何写操作 / 文件操作);**只能查询上面列出的业务表/视图**,不得访问其它表; |
| 99 | + 需要时 JOIN;务必带合适的 LIMIT(<=100);**含 sBrandsId 列的表务必加 `sBrandsId='%s'` 过滤**(本企业数据); | ||
| 89 | **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; | 100 | **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; |
| 90 | 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 | 101 | 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 |
| 91 | 问题:%s%s | 102 | 问题:%s%s |
| 92 | - """.formatted(hint, question, repair); | 103 | + """.formatted(hint, brandHint(), question, repair); |
| 104 | + } | ||
| 105 | + | ||
| 106 | + private String brandHint() { | ||
| 107 | + String b = identity == null ? null : identity.brandsId(); | ||
| 108 | + return (b == null || b.isBlank()) ? "本企业" : b; | ||
| 93 | } | 109 | } |
| 94 | 110 | ||
| 95 | private String rootMsg(Throwable e) { | 111 | private String rootMsg(Throwable e) { |
| @@ -145,7 +161,15 @@ public class QueryTool { | @@ -145,7 +161,15 @@ public class QueryTool { | ||
| 145 | return s.trim(); | 161 | return s.trim(); |
| 146 | } | 162 | } |
| 147 | 163 | ||
| 148 | - /** 单条 SELECT + 挡危险构造。返回 null=通过,否则=拒绝原因。 */ | 164 | + // AI 可用业务表/视图白名单(静态元数据,跨请求缓存);含 sBrandsId 列的表缓存。 |
| 165 | + private static volatile Set<String> ALLOWED_TABLES; | ||
| 166 | + private static final ConcurrentHashMap<String, Boolean> BRAND_COL = new ConcurrentHashMap<>(); | ||
| 167 | + | ||
| 168 | + /** | ||
| 169 | + * 单条 SELECT + 挡危险构造 + **表白名单**(架构 §9 的“视图白名单”)。 | ||
| 170 | + * 白名单 = AI 可用的业务表/视图(viw_* + 表单数据源 + 字段字典表),把凭证/权限/暂存等敏感表挡在外面 | ||
| 171 | + * (如 gdslogininfo、sysjurisdiction、ai_op_queue),防 NL2SQL 击穿权限。返回 null=通过,否则=拒绝原因。 | ||
| 172 | + */ | ||
| 149 | private String validate(String sql) { | 173 | private String validate(String sql) { |
| 150 | if (sql == null || sql.isBlank()) { | 174 | if (sql == null || sql.isBlank()) { |
| 151 | return "未生成SQL"; | 175 | return "未生成SQL"; |
| @@ -158,15 +182,127 @@ public class QueryTool { | @@ -158,15 +182,127 @@ public class QueryTool { | ||
| 158 | return "含禁止构造: " + b; | 182 | return "含禁止构造: " + b; |
| 159 | } | 183 | } |
| 160 | } | 184 | } |
| 185 | + Statement stmt; | ||
| 186 | + try { | ||
| 187 | + stmt = CCJSqlParserUtil.parse(sql); | ||
| 188 | + } catch (Exception e) { | ||
| 189 | + return "SQL 解析失败"; | ||
| 190 | + } | ||
| 191 | + if (!(stmt instanceof Select)) { | ||
| 192 | + return "只允许 SELECT"; | ||
| 193 | + } | ||
| 194 | + Set<String> allowed = allowedTables(); | ||
| 195 | + if (!allowed.isEmpty()) { | ||
| 196 | + List<String> tables; | ||
| 197 | + try { | ||
| 198 | + tables = new TablesNamesFinder().getTableList(stmt); | ||
| 199 | + } catch (Exception e) { | ||
| 200 | + return "无法解析查询涉及的表"; | ||
| 201 | + } | ||
| 202 | + for (String t : tables) { | ||
| 203 | + if (!allowed.contains(normTable(t))) { | ||
| 204 | + return "涉及不允许访问的表(" + normTable(t) + ")"; | ||
| 205 | + } | ||
| 206 | + } | ||
| 207 | + } | ||
| 208 | + return null; | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + /** AI 可用表/视图集合(小写):所有 viw_* 视图 + 表单数据源 + 字段字典里出现的基础表。静态缓存。 */ | ||
| 212 | + private Set<String> allowedTables() { | ||
| 213 | + Set<String> c = ALLOWED_TABLES; | ||
| 214 | + if (c != null) { | ||
| 215 | + return c; | ||
| 216 | + } | ||
| 217 | + Set<String> s = new HashSet<>(); | ||
| 218 | + try { | ||
| 219 | + for (Map<String, Object> r : jdbc.queryForList( | ||
| 220 | + "SELECT LOWER(TABLE_NAME) t FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'viw\\_%'")) { | ||
| 221 | + s.add(String.valueOf(r.get("t"))); | ||
| 222 | + } | ||
| 223 | + for (Map<String, Object> r : jdbc.queryForList( | ||
| 224 | + "SELECT DISTINCT LOWER(sDataSource) t FROM viw_ai_useful_forms WHERE IFNULL(sDataSource,'')<>''")) { | ||
| 225 | + s.add(String.valueOf(r.get("t"))); | ||
| 226 | + } | ||
| 227 | + for (Map<String, Object> r : jdbc.queryForList( | ||
| 228 | + "SELECT DISTINCT LOWER(sTable) t FROM viw_kg_field_dict WHERE IFNULL(sTable,'')<>''")) { | ||
| 229 | + s.add(String.valueOf(r.get("t"))); | ||
| 230 | + } | ||
| 231 | + } catch (Exception ignore) { | ||
| 232 | + // 元数据不可用时返回空集 → 不启用白名单(保持可用),但仍有 SELECT-only + 禁止构造兜底。 | ||
| 233 | + } | ||
| 234 | + ALLOWED_TABLES = s; | ||
| 235 | + return s; | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + /** 规整表名:去反引号、去 schema 前缀、转小写。 */ | ||
| 239 | + private static String normTable(String t) { | ||
| 240 | + if (t == null) { | ||
| 241 | + return ""; | ||
| 242 | + } | ||
| 243 | + String x = t.replace("`", "").trim(); | ||
| 244 | + int dot = x.lastIndexOf('.'); | ||
| 245 | + if (dot >= 0) { | ||
| 246 | + x = x.substring(dot + 1); | ||
| 247 | + } | ||
| 248 | + return x.toLowerCase(); | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + /** | ||
| 252 | + * 租户注入(架构 §9):单表(无 JOIN)且该表含 sBrandsId 列、且身份带品牌时,追加 | ||
| 253 | + * {@code AND sBrandsId='<brand>'},把结果限定在本企业。视图(viw_*)通常已按品牌预筛,跳过。 | ||
| 254 | + * 任何异常都退回原 SQL(不因注入失败而阻断,白名单已是主要边界)。 | ||
| 255 | + */ | ||
| 256 | + private String applyTenant(String sql) { | ||
| 257 | + String brand = identity == null ? null : identity.brandsId(); | ||
| 258 | + if (brand == null || brand.isBlank()) { | ||
| 259 | + return sql; | ||
| 260 | + } | ||
| 161 | try { | 261 | try { |
| 162 | Statement stmt = CCJSqlParserUtil.parse(sql); | 262 | Statement stmt = CCJSqlParserUtil.parse(sql); |
| 163 | if (!(stmt instanceof Select)) { | 263 | if (!(stmt instanceof Select)) { |
| 164 | - return "只允许 SELECT"; | 264 | + return sql; |
| 265 | + } | ||
| 266 | + Select select = (Select) stmt; | ||
| 267 | + if (!(select.getSelectBody() instanceof PlainSelect)) { | ||
| 268 | + return sql; | ||
| 165 | } | 269 | } |
| 270 | + PlainSelect ps = (PlainSelect) select.getSelectBody(); | ||
| 271 | + if (ps.getJoins() != null && !ps.getJoins().isEmpty()) { | ||
| 272 | + return sql; // 多表:交给白名单,不做注入 | ||
| 273 | + } | ||
| 274 | + if (!(ps.getFromItem() instanceof Table)) { | ||
| 275 | + return sql; | ||
| 276 | + } | ||
| 277 | + String table = normTable(((Table) ps.getFromItem()).getName()); | ||
| 278 | + if (table.startsWith("viw")) { | ||
| 279 | + return sql; // 视图预筛,不注入 | ||
| 280 | + } | ||
| 281 | + if (!hasBrandCol(table)) { | ||
| 282 | + return sql; | ||
| 283 | + } | ||
| 284 | + EqualsTo eq = new EqualsTo(); | ||
| 285 | + eq.setLeftExpression(new Column("sBrandsId")); | ||
| 286 | + eq.setRightExpression(new StringValue(brand)); | ||
| 287 | + ps.setWhere(ps.getWhere() == null ? eq : new AndExpression(ps.getWhere(), eq)); | ||
| 288 | + return select.toString(); | ||
| 166 | } catch (Exception e) { | 289 | } catch (Exception e) { |
| 167 | - return "SQL 解析失败"; | 290 | + return sql; |
| 168 | } | 291 | } |
| 169 | - return null; | 292 | + } |
| 293 | + | ||
| 294 | + /** 该基础表是否有 sBrandsId 列(缓存)。 */ | ||
| 295 | + private boolean hasBrandCol(String table) { | ||
| 296 | + return BRAND_COL.computeIfAbsent(table, t -> { | ||
| 297 | + try { | ||
| 298 | + Integer n = jdbc.queryForObject( | ||
| 299 | + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + | ||
| 300 | + "AND TABLE_NAME=? AND COLUMN_NAME='sBrandsId'", Integer.class, t); | ||
| 301 | + return n != null && n > 0; | ||
| 302 | + } catch (Exception e) { | ||
| 303 | + return false; | ||
| 304 | + } | ||
| 305 | + }); | ||
| 170 | } | 306 | } |
| 171 | 307 | ||
| 172 | private String forceLimit(String sql) { | 308 | private String forceLimit(String sql) { |
src/main/resources/application-saaslocal.yml
| @@ -39,3 +39,11 @@ erp: | @@ -39,3 +39,11 @@ erp: | ||
| 39 | subsidiary: "1111111111" | 39 | subsidiary: "1111111111" |
| 40 | username: admin | 40 | username: admin |
| 41 | password: "666666" | 41 | password: "666666" |
| 42 | + | ||
| 43 | +# LLM 可观测(Langfuse,架构 §1)。默认关闭;自托管起来后填 key 开启: | ||
| 44 | +# docker compose -f docker-compose.langfuse.yml up -d → http://localhost:3000 拿 public/secret key。 | ||
| 45 | +langfuse: | ||
| 46 | + enabled: false | ||
| 47 | + host: http://localhost:3000 | ||
| 48 | + public-key: "" | ||
| 49 | + secret-key: "" |
src/main/resources/templates/chat.html
| @@ -675,7 +675,13 @@ | @@ -675,7 +675,13 @@ | ||
| 675 | body: JSON.stringify({ | 675 | body: JSON.stringify({ |
| 676 | text: message, | 676 | text: message, |
| 677 | userid: userid, | 677 | userid: userid, |
| 678 | - conversationId: conversationId | 678 | + conversationId: conversationId, |
| 679 | + // 透传身份 + ERP 登录 token(后端据此按用户真实权限收紧;token 只用于转发给 ERP,不进 prompt) | ||
| 680 | + authorization: authorization, | ||
| 681 | + username: username, | ||
| 682 | + brandsid: brandsid, | ||
| 683 | + subsidiaryid: subsidiaryid, | ||
| 684 | + usertype: usertype | ||
| 679 | }) | 685 | }) |
| 680 | }); | 686 | }); |
| 681 | if (!response.ok) throw new Error("HTTP " + response.status); | 687 | if (!response.ok) throw new Error("HTTP " + response.status); |
| @@ -713,6 +719,8 @@ | @@ -713,6 +719,8 @@ | ||
| 713 | renderProposalCard(evt.opId, evt.summary); | 719 | renderProposalCard(evt.opId, evt.summary); |
| 714 | } else if (evt.type === "form_collect") { | 720 | } else if (evt.type === "form_collect") { |
| 715 | renderFormCollect(evt.entity, evt.fields || []); | 721 | renderFormCollect(evt.entity, evt.fields || []); |
| 722 | + } else if (evt.type === "question") { | ||
| 723 | + renderQuestion(evt.question, evt.options || []); | ||
| 716 | } else if (evt.type === "error") { | 724 | } else if (evt.type === "error") { |
| 717 | if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } | 725 | if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } |
| 718 | aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; | 726 | aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; |
| @@ -773,7 +781,7 @@ | @@ -773,7 +781,7 @@ | ||
| 773 | card.find('.op-actions button').prop('disabled', true); | 781 | card.find('.op-actions button').prop('disabled', true); |
| 774 | card.find('.op-result').text('处理中…'); | 782 | card.find('.op-result').text('处理中…'); |
| 775 | try { | 783 | try { |
| 776 | - const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST' }); | 784 | + const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST', headers:{ 'Authorization': authorization } }); |
| 777 | const data = await res.json(); | 785 | const data = await res.json(); |
| 778 | if (data.status === 'executed') { | 786 | if (data.status === 'executed') { |
| 779 | card.find('.op-actions').remove(); | 787 | card.find('.op-actions').remove(); |
| @@ -790,19 +798,34 @@ | @@ -790,19 +798,34 @@ | ||
| 790 | 798 | ||
| 791 | async function cancelOp(opId) { | 799 | async function cancelOp(opId) { |
| 792 | const card = $('#op-' + opId); | 800 | const card = $('#op-' + opId); |
| 793 | - try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST' }); } catch (e) {} | 801 | + try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST', headers:{ 'Authorization': authorization } }); } catch (e) {} |
| 794 | card.find('.op-actions').remove(); | 802 | card.find('.op-actions').remove(); |
| 795 | card.find('.op-result').text('已取消'); | 803 | card.find('.op-result').text('已取消'); |
| 796 | } | 804 | } |
| 797 | 805 | ||
| 798 | - // ====================== FormCollect:对话内动态表单 ====================== | 806 | + // ====================== FormCollect:对话内动态表单(字段来自 ERP 表单元数据) ====================== |
| 807 | + // fields = [{name,label,control,required,default,options?}](新版对象)或 ["中文名",...](旧版字符串,兼容) | ||
| 799 | function renderFormCollect(entity, fields) { | 808 | function renderFormCollect(entity, fields) { |
| 800 | hideTypingIndicator(); | 809 | hideTypingIndicator(); |
| 801 | const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); | 810 | const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); |
| 802 | - const inputs = (fields || []).map(f => | ||
| 803 | - `<div style="margin-bottom:8px;"><label style="display:inline-block;width:120px;color:#555;">${escapeHtml(f)}</label>` + | ||
| 804 | - `<input data-label="${escapeHtml(f)}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;"></div>` | ||
| 805 | - ).join(''); | 811 | + const norm = (fields || []).map(f => (typeof f === 'string') |
| 812 | + ? { name: f, label: f, required: false } | ||
| 813 | + : f); | ||
| 814 | + const inputs = norm.map(f => { | ||
| 815 | + const label = escapeHtml(f.label || f.name); | ||
| 816 | + const req = f.required ? ' <span style="color:#e00;">*</span>' : ''; | ||
| 817 | + const defv = f.default != null ? escapeHtml(String(f.default)) : ''; | ||
| 818 | + let control; | ||
| 819 | + if (Array.isArray(f.options) && f.options.length > 0) { | ||
| 820 | + const opts = ['<option value=""></option>'] | ||
| 821 | + .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`)) | ||
| 822 | + .join(''); | ||
| 823 | + control = `<select data-label="${label}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">${opts}</select>`; | ||
| 824 | + } else { | ||
| 825 | + control = `<input data-label="${label}" value="${defv}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">`; | ||
| 826 | + } | ||
| 827 | + return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;">${label}${req}</label>${control}</div>`; | ||
| 828 | + }).join(''); | ||
| 806 | const html = ` | 829 | const html = ` |
| 807 | <div class="message ai-message" id="${fid}"> | 830 | <div class="message ai-message" id="${fid}"> |
| 808 | <div class="message-bubble" style="border:1px solid #b9d6ff;background:#f2f8ff;"> | 831 | <div class="message-bubble" style="border:1px solid #b9d6ff;background:#f2f8ff;"> |
| @@ -824,7 +847,34 @@ | @@ -824,7 +847,34 @@ | ||
| 824 | }); | 847 | }); |
| 825 | if (parts.length === 0) { alert('请至少填写一个字段'); return; } | 848 | if (parts.length === 0) { alert('请至少填写一个字段'); return; } |
| 826 | $(this).prop('disabled', true).text('已提交'); | 849 | $(this).prop('disabled', true).text('已提交'); |
| 827 | - $('#messageInput').val('为「' + entity + '」新增,请据此生成新增提议:' + parts.join(',')); | 850 | + $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请据此用 proposeCreate 生成待确认的新增。'); |
| 851 | + sendMessage(); | ||
| 852 | + }); | ||
| 853 | + scrollToBottom(); | ||
| 854 | + } | ||
| 855 | + | ||
| 856 | + // ====================== AskUser:带可点选项的澄清问题 ====================== | ||
| 857 | + function renderQuestion(question, options) { | ||
| 858 | + hideTypingIndicator(); | ||
| 859 | + const qid = 'q-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); | ||
| 860 | + const chips = (options || []).map(o => | ||
| 861 | + `<button class="q-opt" data-val="${escapeHtml(String(o))}" style="margin:4px 6px 0 0;padding:6px 14px;border:1px solid #667eea;border-radius:16px;background:#fff;color:#667eea;cursor:pointer;">${escapeHtml(String(o))}</button>` | ||
| 862 | + ).join(''); | ||
| 863 | + const html = ` | ||
| 864 | + <div class="message ai-message" id="${qid}"> | ||
| 865 | + <div class="message-bubble" style="border:1px solid #d9c6ff;background:#f7f2ff;"> | ||
| 866 | + <div class="message-content"> | ||
| 867 | + <div style="font-weight:600;margin-bottom:8px;">❓ ${escapeHtml(question)}</div> | ||
| 868 | + <div class="q-opts">${chips}</div> | ||
| 869 | + </div> | ||
| 870 | + </div> | ||
| 871 | + </div>`; | ||
| 872 | + $('#chatMessages').append(html); | ||
| 873 | + $(`#${qid} .q-opt`).on('click', function () { | ||
| 874 | + const val = $(this).data('val'); | ||
| 875 | + $(`#${qid} .q-opt`).prop('disabled', true).css('opacity', '0.6'); | ||
| 876 | + $(this).css({background:'#667eea', color:'#fff'}); | ||
| 877 | + $('#messageInput').val(String(val)); | ||
| 828 | sendMessage(); | 878 | sendMessage(); |
| 829 | }); | 879 | }); |
| 830 | scrollToBottom(); | 880 | scrollToBottom(); |