diff --git a/docs/ai/README.md b/docs/ai/README.md
new file mode 100644
index 0000000..29bc1b3
--- /dev/null
+++ b/docs/ai/README.md
@@ -0,0 +1,61 @@
+# AI 模块(Ollama + RAG + 订单分析)
+
+## 1. 配置
+
+见 `src/main/resources/application.yml`:
+
+- `ai.ollama.base-url`:主地址(例如 `https://ai-api.websoft.top`)
+- `ai.ollama.fallback-url`:备用地址(例如 `http://47.119.165.234:11434`)
+- `ai.ollama.chat-model`:对话模型(`qwen3.5:cloud`)
+- `ai.ollama.embed-model`:向量模型(`qwen3-embedding:4b`)
+
+## 2. 建表(知识库)
+
+执行:`docs/ai/ai_kb_tables.sql`
+
+## 3. API
+
+说明:所有接口默认需要登录(`@PreAuthorize("isAuthenticated()")`),并且要求能够拿到 `tenantId`(header 或登录用户)。
+
+### 3.1 对话
+
+- `GET /api/ai/models`:获取 Ollama 模型列表
+- `POST /api/ai/chat`:非流式对话
+- `POST /api/ai/chat/stream`:流式对话(SSE)
+- `GET /api/ai/chat/stream?prompt=...`:流式对话(SSE,适配 EventSource)
+
+请求示例(非流式):
+```json
+{
+ "prompt": "帮我写一个退款流程说明"
+}
+```
+
+### 3.2 知识库(RAG)
+
+- `POST /api/ai/kb/upload`:上传文档入库(建议 txt/md/html)
+- `POST /api/ai/kb/sync/cms`:同步 CMS 已发布文章到知识库(当前租户)
+- `POST /api/ai/kb/query`:仅检索 topK
+- `POST /api/ai/kb/ask`:检索 + 生成答案(答案要求引用 chunk_id)
+
+请求示例(ask):
+```json
+{
+ "question": "怎么开具发票?",
+ "topK": 5
+}
+```
+
+### 3.3 商城订单分析(按租户/按天)
+
+- `POST /api/ai/analytics/query`:返回按天指标数据
+- `POST /api/ai/analytics/ask`:基于指标数据生成分析结论
+
+请求示例(ask):
+```json
+{
+ "question": "最近30天支付率有没有明显下滑?请给出原因排查建议。",
+ "startDate": "2026-02-01",
+ "endDate": "2026-02-27"
+}
+```
diff --git a/docs/ai/ai_kb_tables.sql b/docs/ai/ai_kb_tables.sql
new file mode 100644
index 0000000..39d70dd
--- /dev/null
+++ b/docs/ai/ai_kb_tables.sql
@@ -0,0 +1,39 @@
+-- AI 知识库(RAG)建表脚本(MySQL)
+-- 说明:本项目使用 MyBatis-Plus 默认命名规则(AiKbDocument -> ai_kb_document)。
+
+CREATE TABLE IF NOT EXISTS `ai_kb_document` (
+ `document_id` INT NOT NULL AUTO_INCREMENT COMMENT '文档ID',
+ `title` VARCHAR(255) NULL COMMENT '标题',
+ `source_type` VARCHAR(32) NULL COMMENT '来源类型:upload/cms',
+ `source_id` INT NULL COMMENT '来源ID(如 cms.article_id)',
+ `source_ref` VARCHAR(255) NULL COMMENT '来源引用(如文件名、文章 code)',
+ `content_hash` CHAR(64) NULL COMMENT '内容hash(SHA-256),用于增量同步',
+ `status` TINYINT NULL DEFAULT 0 COMMENT '状态',
+ `deleted` TINYINT NULL DEFAULT 0 COMMENT '逻辑删除:0否1是',
+ `tenant_id` INT NOT NULL COMMENT '租户ID',
+ `update_time` DATETIME NULL COMMENT '更新时间',
+ `create_time` DATETIME NULL COMMENT '创建时间',
+ PRIMARY KEY (`document_id`),
+ KEY `idx_ai_kb_document_tenant` (`tenant_id`),
+ KEY `idx_ai_kb_document_source` (`tenant_id`, `source_type`, `source_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 知识库文档';
+
+CREATE TABLE IF NOT EXISTS `ai_kb_chunk` (
+ `id` INT NOT NULL AUTO_INCREMENT COMMENT '自增ID',
+ `document_id` INT NOT NULL COMMENT '文档ID',
+ `chunk_id` VARCHAR(64) NOT NULL COMMENT 'chunk 唯一ID(用于引用)',
+ `chunk_index` INT NULL COMMENT 'chunk 序号',
+ `title` VARCHAR(255) NULL COMMENT '标题(冗余,便于展示)',
+ `content` LONGTEXT NULL COMMENT 'chunk 文本',
+ `content_hash` CHAR(64) NULL COMMENT 'chunk 内容hash',
+ `embedding` LONGTEXT NULL COMMENT 'embedding(JSON数组)',
+ `embedding_norm` DOUBLE NULL COMMENT 'embedding L2 范数',
+ `deleted` TINYINT NULL DEFAULT 0 COMMENT '逻辑删除:0否1是',
+ `tenant_id` INT NOT NULL COMMENT '租户ID',
+ `create_time` DATETIME NULL COMMENT '创建时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_ai_kb_chunk_chunk_id` (`chunk_id`),
+ KEY `idx_ai_kb_chunk_tenant` (`tenant_id`),
+ KEY `idx_ai_kb_chunk_document` (`document_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 知识库 chunk';
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/OllamaClient.java b/src/main/java/com/gxwebsoft/ai/client/OllamaClient.java
new file mode 100644
index 0000000..6e08d1b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/OllamaClient.java
@@ -0,0 +1,194 @@
+package com.gxwebsoft.ai.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.ai.client.dto.*;
+import com.gxwebsoft.ai.config.AiOllamaProperties;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.utils.JSONUtil;
+import okhttp3.*;
+import okio.BufferedSource;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * 轻量 Ollama HTTP Client(兼容 /api/chat、/api/embeddings、/api/tags)。
+ */
+@Component
+public class OllamaClient {
+ @Resource
+ private AiOllamaProperties props;
+
+ @Resource
+ private ObjectMapper objectMapper;
+
+ private volatile OkHttpClient http;
+
+ private OkHttpClient http() {
+ OkHttpClient c = http;
+ if (c != null) {
+ return c;
+ }
+ synchronized (this) {
+ if (http == null) {
+ http = new OkHttpClient.Builder()
+ .connectTimeout(Duration.ofMillis(props.getConnectTimeoutMs()))
+ .readTimeout(Duration.ofMillis(props.getReadTimeoutMs()))
+ .writeTimeout(Duration.ofMillis(props.getWriteTimeoutMs()))
+ .build();
+ }
+ return http;
+ }
+ }
+
+ public OllamaTagsResponse tags() {
+ return getJson("/api/tags", OllamaTagsResponse.class);
+ }
+
+ public OllamaChatResponse chat(OllamaChatRequest req) {
+ if (req.getStream() == null) {
+ req.setStream(false);
+ }
+ return postJson("/api/chat", req, OllamaChatResponse.class);
+ }
+
+ /**
+ * 流式对话:Ollama 会返回按行分隔的 JSON。
+ */
+ public void chatStream(OllamaChatRequest req, Consumer onEvent) {
+ Objects.requireNonNull(onEvent, "onEvent");
+ if (req.getStream() == null) {
+ req.setStream(true);
+ }
+
+ Request request = buildPost(baseUrl(), "/api/chat", JSONUtil.toJSONString(req));
+ try (Response resp = http().newCall(request).execute()) {
+ if (!resp.isSuccessful()) {
+ throw new BusinessException("Ollama chat stream failed: HTTP " + resp.code());
+ }
+ ResponseBody body = resp.body();
+ if (body == null) {
+ throw new BusinessException("Ollama chat stream failed: empty body");
+ }
+
+ BufferedSource source = body.source();
+ String line;
+ while ((line = source.readUtf8Line()) != null) {
+ line = line.trim();
+ if (line.isEmpty()) {
+ continue;
+ }
+ OllamaChatResponse event = objectMapper.readValue(line, OllamaChatResponse.class);
+ onEvent.accept(event);
+ if (Boolean.TRUE.equals(event.getDone())) {
+ break;
+ }
+ }
+ } catch (IOException e) {
+ throw new BusinessException("Ollama chat stream IO error: " + e.getMessage());
+ }
+ }
+
+ public OllamaEmbeddingResponse embedding(String prompt) {
+ OllamaEmbeddingRequest req = new OllamaEmbeddingRequest();
+ req.setModel(props.getEmbedModel());
+ req.setPrompt(prompt);
+ return postJson("/api/embeddings", req, OllamaEmbeddingResponse.class);
+ }
+
+ private String baseUrl() {
+ if (props.getBaseUrl() == null || props.getBaseUrl().trim().isEmpty()) {
+ throw new BusinessException("ai.ollama.base-url 未配置");
+ }
+ return props.getBaseUrl().trim();
+ }
+
+ private String fallbackUrl() {
+ if (props.getFallbackUrl() == null || props.getFallbackUrl().trim().isEmpty()) {
+ return null;
+ }
+ return props.getFallbackUrl().trim();
+ }
+
+ private T getJson(String path, Class clazz) {
+ try {
+ return getJsonOnce(baseUrl(), path, clazz);
+ } catch (Exception e) {
+ String fb = fallbackUrl();
+ if (fb == null) {
+ throw e;
+ }
+ return getJsonOnce(fb, path, clazz);
+ }
+ }
+
+ private T getJsonOnce(String base, String path, Class clazz) {
+ Request req = new Request.Builder()
+ .url(join(base, path))
+ .get()
+ .build();
+ try (Response resp = http().newCall(req).execute()) {
+ if (!resp.isSuccessful()) {
+ throw new BusinessException("Ollama GET failed: HTTP " + resp.code());
+ }
+ ResponseBody body = resp.body();
+ if (body == null) {
+ throw new BusinessException("Ollama GET failed: empty body");
+ }
+ return objectMapper.readValue(body.string(), clazz);
+ } catch (IOException e) {
+ throw new BusinessException("Ollama GET IO error: " + e.getMessage());
+ }
+ }
+
+ private T postJson(String path, Object payload, Class clazz) {
+ String json = JSONUtil.toJSONString(payload);
+ try {
+ return postJsonOnce(baseUrl(), path, json, clazz);
+ } catch (Exception e) {
+ String fb = fallbackUrl();
+ if (fb == null) {
+ throw e;
+ }
+ return postJsonOnce(fb, path, json, clazz);
+ }
+ }
+
+ private T postJsonOnce(String base, String path, String json, Class clazz) {
+ Request req = buildPost(base, path, json);
+ try (Response resp = http().newCall(req).execute()) {
+ if (!resp.isSuccessful()) {
+ throw new BusinessException("Ollama POST failed: HTTP " + resp.code());
+ }
+ ResponseBody body = resp.body();
+ if (body == null) {
+ throw new BusinessException("Ollama POST failed: empty body");
+ }
+ return objectMapper.readValue(body.string(), clazz);
+ } catch (IOException e) {
+ throw new BusinessException("Ollama POST IO error: " + e.getMessage());
+ }
+ }
+
+ private Request buildPost(String base, String path, String json) {
+ RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
+ return new Request.Builder()
+ .url(join(base, path))
+ .post(body)
+ .build();
+ }
+
+ private static String join(String base, String path) {
+ String b = base;
+ if (b.endsWith("/")) {
+ b = b.substring(0, b.length() - 1);
+ }
+ String p = path.startsWith("/") ? path : ("/" + path);
+ return b + p;
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatRequest.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatRequest.java
new file mode 100644
index 0000000..938894a
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatRequest.java
@@ -0,0 +1,19 @@
+package com.gxwebsoft.ai.client.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class OllamaChatRequest {
+ private String model;
+ private List messages;
+ private Boolean stream;
+
+ /**
+ * Ollama options,例如:temperature、top_k、top_p、num_predict...
+ */
+ private Map options;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatResponse.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatResponse.java
new file mode 100644
index 0000000..ec9291b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatResponse.java
@@ -0,0 +1,28 @@
+package com.gxwebsoft.ai.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OllamaChatResponse {
+ private String model;
+
+ @JsonProperty("created_at")
+ private String createdAt;
+
+ private OllamaMessage message;
+
+ private Boolean done;
+
+ @JsonProperty("total_duration")
+ private Long totalDuration;
+
+ @JsonProperty("prompt_eval_count")
+ private Integer promptEvalCount;
+
+ @JsonProperty("eval_count")
+ private Integer evalCount;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingRequest.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingRequest.java
new file mode 100644
index 0000000..5629eba
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingRequest.java
@@ -0,0 +1,14 @@
+package com.gxwebsoft.ai.client.dto;
+
+import lombok.Data;
+
+@Data
+public class OllamaEmbeddingRequest {
+ private String model;
+
+ /**
+ * Ollama embeddings 目前常用字段为 prompt。
+ */
+ private String prompt;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingResponse.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingResponse.java
new file mode 100644
index 0000000..233e863
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingResponse.java
@@ -0,0 +1,13 @@
+package com.gxwebsoft.ai.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OllamaEmbeddingResponse {
+ private List embedding;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java
new file mode 100644
index 0000000..6606c69
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java
@@ -0,0 +1,14 @@
+package com.gxwebsoft.ai.client.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OllamaMessage {
+ private String role;
+ private String content;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaTagsResponse.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaTagsResponse.java
new file mode 100644
index 0000000..c1fe7c0
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaTagsResponse.java
@@ -0,0 +1,22 @@
+package com.gxwebsoft.ai.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OllamaTagsResponse {
+ private List models;
+
+ @Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Model {
+ private String name;
+ private Long size;
+ private String digest;
+ private String modified_at;
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/config/AiOllamaProperties.java b/src/main/java/com/gxwebsoft/ai/config/AiOllamaProperties.java
new file mode 100644
index 0000000..e0c1732
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/config/AiOllamaProperties.java
@@ -0,0 +1,68 @@
+package com.gxwebsoft.ai.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Ollama API 配置。
+ *
+ * 说明:本项目通过自建 Ollama 网关提供服务,因此这里用 baseUrl + fallbackUrl。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "ai.ollama")
+public class AiOllamaProperties {
+ /**
+ * 主地址,例如:https://ai-api.websoft.top
+ */
+ private String baseUrl;
+
+ /**
+ * 备用地址,例如:http://47.119.165.234:11434
+ */
+ private String fallbackUrl;
+
+ /**
+ * 对话模型,例如:qwen3.5:cloud
+ */
+ private String chatModel;
+
+ /**
+ * 向量模型,例如:qwen3-embedding:4b
+ */
+ private String embedModel;
+
+ /**
+ * HTTP 超时(毫秒)。
+ */
+ private long connectTimeoutMs = 10_000;
+ private long readTimeoutMs = 300_000;
+ private long writeTimeoutMs = 60_000;
+
+ /**
+ * 并发上限(用于 embedding/入库等批处理场景)。
+ */
+ private int maxConcurrency = 4;
+
+ /**
+ * RAG:检索候选 chunk 最大数量(避免一次性拉取过多数据导致内存/耗时过高)。
+ */
+ private int ragMaxCandidates = 2000;
+
+ /**
+ * RAG:检索返回 topK。
+ */
+ private int ragTopK = 5;
+
+ /**
+ * RAG:单个 chunk 最大字符数(用于入库切分)。
+ */
+ private int ragChunkSize = 800;
+
+ /**
+ * RAG:chunk 重叠字符数(用于减少语义断裂)。
+ */
+ private int ragChunkOverlap = 120;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiAnalyticsController.java b/src/main/java/com/gxwebsoft/ai/controller/AiAnalyticsController.java
new file mode 100644
index 0000000..fe72b49
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/controller/AiAnalyticsController.java
@@ -0,0 +1,37 @@
+package com.gxwebsoft.ai.controller;
+
+import com.gxwebsoft.ai.dto.*;
+import com.gxwebsoft.ai.service.AiAnalyticsService;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+@Tag(name = "AI - 订单数据分析")
+@RestController
+@RequestMapping("/api/ai/analytics")
+public class AiAnalyticsController extends BaseController {
+ @Resource
+ private AiAnalyticsService analyticsService;
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "查询商城订单按天指标(当前租户)")
+ @PostMapping("/query")
+ public ApiResult query(@RequestBody AiShopMetricsQueryRequest request) {
+ Integer tenantId = getTenantId();
+ return success(analyticsService.queryShopMetrics(tenantId, request));
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "AI 解析并输出订单分析结论(当前租户)")
+ @PostMapping("/ask")
+ public ApiResult ask(@RequestBody AiAnalyticsAskRequest request) {
+ Integer tenantId = getTenantId();
+ return success(analyticsService.askShopAnalytics(tenantId, request));
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiChatController.java b/src/main/java/com/gxwebsoft/ai/controller/AiChatController.java
new file mode 100644
index 0000000..45d5564
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/controller/AiChatController.java
@@ -0,0 +1,95 @@
+package com.gxwebsoft.ai.controller;
+
+import com.gxwebsoft.ai.client.OllamaClient;
+import com.gxwebsoft.ai.client.dto.OllamaChatResponse;
+import com.gxwebsoft.ai.client.dto.OllamaTagsResponse;
+import com.gxwebsoft.ai.dto.AiChatRequest;
+import com.gxwebsoft.ai.dto.AiChatResult;
+import com.gxwebsoft.ai.dto.AiMessage;
+import com.gxwebsoft.ai.service.AiChatService;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+@Tag(name = "AI - 对话")
+@RestController
+@RequestMapping("/api/ai")
+public class AiChatController extends BaseController {
+ @Resource
+ private OllamaClient ollamaClient;
+
+ @Resource
+ private AiChatService aiChatService;
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "获取 Ollama 模型列表")
+ @GetMapping("/models")
+ public ApiResult models() {
+ return success(ollamaClient.tags());
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "非流式对话")
+ @PostMapping("/chat")
+ public ApiResult chat(@RequestBody AiChatRequest request) {
+ return success(aiChatService.chat(request));
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "流式对话(SSE)")
+ @PostMapping("/chat/stream")
+ public SseEmitter chatStream(@RequestBody AiChatRequest request) {
+ // 10 分钟超时(可根据前端需要调整)
+ SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
+
+ CompletableFuture.runAsync(() -> {
+ try {
+ aiChatService.chatStream(request,
+ delta -> {
+ try {
+ emitter.send(SseEmitter.event().name("delta").data(delta));
+ } catch (Exception e) {
+ // 客户端断开会触发 send 异常,这里直接结束即可
+ emitter.complete();
+ }
+ },
+ (OllamaChatResponse done) -> {
+ try {
+ Map meta = new LinkedHashMap<>();
+ meta.put("model", done.getModel());
+ meta.put("prompt_eval_count", done.getPromptEvalCount());
+ meta.put("eval_count", done.getEvalCount());
+ meta.put("total_duration", done.getTotalDuration());
+ emitter.send(SseEmitter.event().name("done").data(meta));
+ } catch (Exception ignored) {
+ } finally {
+ emitter.complete();
+ }
+ });
+ } catch (Exception e) {
+ emitter.completeWithError(e);
+ }
+ });
+
+ return emitter;
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "流式对话(SSE, GET 版本,便于 EventSource)")
+ @GetMapping("/chat/stream")
+ public SseEmitter chatStreamGet(@RequestParam("prompt") String prompt) {
+ AiChatRequest req = new AiChatRequest();
+ req.setMessages(Collections.singletonList(new AiMessage("user", prompt)));
+ return chatStream(req);
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiKbController.java b/src/main/java/com/gxwebsoft/ai/controller/AiKbController.java
new file mode 100644
index 0000000..afd2078
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/controller/AiKbController.java
@@ -0,0 +1,54 @@
+package com.gxwebsoft.ai.controller;
+
+import com.gxwebsoft.ai.dto.*;
+import com.gxwebsoft.ai.service.AiKbRagService;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+
+@Tag(name = "AI - 知识库(RAG)")
+@RestController
+@RequestMapping("/api/ai/kb")
+public class AiKbController extends BaseController {
+ @Resource
+ private AiKbRagService ragService;
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "上传文档入库(txt/md/html 优先)")
+ @PostMapping("/upload")
+ public ApiResult upload(@RequestParam("file") MultipartFile file) {
+ Integer tenantId = getTenantId();
+ return success(ragService.ingestUpload(tenantId, file));
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "同步 CMS 文章到知识库(当前租户)")
+ @PostMapping("/sync/cms")
+ public ApiResult syncCms() {
+ Integer tenantId = getTenantId();
+ return success(ragService.syncCms(tenantId));
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "仅检索(返回 topK 命中)")
+ @PostMapping("/query")
+ public ApiResult query(@RequestBody AiKbQueryRequest request) {
+ Integer tenantId = getTenantId();
+ return success(ragService.query(tenantId, request));
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "知识库问答(RAG 生成 + 返回检索结果)")
+ @PostMapping("/ask")
+ public ApiResult ask(@RequestBody AiKbAskRequest request) {
+ Integer tenantId = getTenantId();
+ return success(ragService.ask(tenantId, request));
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskRequest.java
new file mode 100644
index 0000000..085472a
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskRequest.java
@@ -0,0 +1,13 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiAnalyticsAskRequest", description = "AI 数据分析提问")
+public class AiAnalyticsAskRequest {
+ private String question;
+ private String startDate;
+ private String endDate;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java
new file mode 100644
index 0000000..f11b61d
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiAnalyticsAskResult", description = "AI 数据分析结果")
+public class AiAnalyticsAskResult {
+ private String analysis;
+ private AiShopMetricsQueryResult data;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java
new file mode 100644
index 0000000..eaa90ff
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java
@@ -0,0 +1,35 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(name = "AiChatRequest", description = "AI 对话请求")
+public class AiChatRequest {
+ @Schema(description = "可选:直接传一句话。若 messages 为空则使用该字段构造 user message")
+ private String prompt;
+
+ @Schema(description = "可选:OpenAI 风格 messages(role: system/user/assistant)")
+ private List messages;
+
+ @Schema(description = "可选:覆盖默认模型")
+ private String model;
+
+ @Schema(description = "是否流式输出(/chat/stream 端点通常忽略此字段)")
+ private Boolean stream;
+
+ @Schema(description = "temperature")
+ private Double temperature;
+
+ @Schema(description = "top_k")
+ private Integer topK;
+
+ @Schema(description = "top_p")
+ private Double topP;
+
+ @Schema(description = "num_predict(类似 max_tokens)")
+ private Integer numPredict;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java
new file mode 100644
index 0000000..c38dd41
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java
@@ -0,0 +1,15 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(name = "AiChatResult", description = "AI 对话结果")
+public class AiChatResult {
+ private String content;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java
new file mode 100644
index 0000000..199134c
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiKbAskRequest", description = "知识库问答请求")
+public class AiKbAskRequest {
+ private String question;
+ private Integer topK;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java
new file mode 100644
index 0000000..a23a480
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiKbAskResult", description = "知识库问答结果")
+public class AiKbAskResult {
+ private String answer;
+ private AiKbQueryResult retrieval;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java
new file mode 100644
index 0000000..e204685
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java
@@ -0,0 +1,15 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiKbHit", description = "知识库命中")
+public class AiKbHit {
+ private String chunkId;
+ private Integer documentId;
+ private String title;
+ private Double score;
+ private String content;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java
new file mode 100644
index 0000000..c14778d
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java
@@ -0,0 +1,15 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiKbIngestResult", description = "知识库入库结果")
+public class AiKbIngestResult {
+ private Integer documentId;
+ private String title;
+ private Integer chunks;
+ private Integer updatedDocuments;
+ private Integer skippedDocuments;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java
new file mode 100644
index 0000000..c4ad3c6
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiKbQueryRequest", description = "知识库检索请求")
+public class AiKbQueryRequest {
+ private String query;
+ private Integer topK;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java
new file mode 100644
index 0000000..33b082e
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java
@@ -0,0 +1,13 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(name = "AiKbQueryResult", description = "知识库检索结果")
+public class AiKbQueryResult {
+ private List hits;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiMessage.java b/src/main/java/com/gxwebsoft/ai/dto/AiMessage.java
new file mode 100644
index 0000000..1e9ba61
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiMessage.java
@@ -0,0 +1,14 @@
+package com.gxwebsoft.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class AiMessage {
+ private String role;
+ private String content;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryRequest.java
new file mode 100644
index 0000000..ba31384
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryRequest.java
@@ -0,0 +1,18 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(name = "AiShopMetricsQueryRequest", description = "商城订单指标查询请求")
+public class AiShopMetricsQueryRequest {
+ @Schema(description = "开始日期(YYYY-MM-DD)")
+ private String startDate;
+
+ @Schema(description = "结束日期(YYYY-MM-DD),包含该天")
+ private String endDate;
+
+ @Schema(description = "是否按天分组,默认 true")
+ private Boolean groupByDay;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryResult.java
new file mode 100644
index 0000000..f4086cc
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryResult.java
@@ -0,0 +1,16 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(name = "AiShopMetricsQueryResult", description = "商城订单指标查询结果")
+public class AiShopMetricsQueryResult {
+ private Integer tenantId;
+ private String startDate;
+ private String endDate;
+ private List rows;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java
new file mode 100644
index 0000000..747ecdf
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java
@@ -0,0 +1,23 @@
+package com.gxwebsoft.ai.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+@Schema(name = "AiShopMetricsRow", description = "商城订单指标行(按 tenant/day)")
+public class AiShopMetricsRow {
+ private Integer tenantId;
+ private String day;
+
+ private Long orderCnt;
+ private Long paidOrderCnt;
+ private BigDecimal gmv;
+ private BigDecimal refundAmt;
+ private Long payUserCnt;
+
+ private BigDecimal aov;
+ private BigDecimal payRate;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java b/src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java
new file mode 100644
index 0000000..47ffd0c
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java
@@ -0,0 +1,57 @@
+package com.gxwebsoft.ai.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 知识库分段(chunk)。
+ */
+@Data
+@Schema(name = "AiKbChunk", description = "AI 知识库分段")
+public class AiKbChunk implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Integer id;
+
+ private Integer documentId;
+
+ /**
+ * 外部引用用的唯一 ID(便于在答案里引用)。
+ */
+ private String chunkId;
+
+ private Integer chunkIndex;
+
+ private String title;
+
+ private String content;
+
+ private String contentHash;
+
+ /**
+ * embedding JSON(数组),存成文本便于快速落库。
+ */
+ private String embedding;
+
+ /**
+ * embedding 的 L2 范数,用于余弦相似度。
+ */
+ private Double embeddingNorm;
+
+ @TableLogic
+ private Integer deleted;
+
+ private Integer tenantId;
+
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime createTime;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java b/src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java
new file mode 100644
index 0000000..dac0c3b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java
@@ -0,0 +1,59 @@
+package com.gxwebsoft.ai.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 知识库文档(来源:上传、CMS 等)。
+ */
+@Data
+@Schema(name = "AiKbDocument", description = "AI 知识库文档")
+public class AiKbDocument implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "document_id", type = IdType.AUTO)
+ private Integer documentId;
+
+ private String title;
+
+ /**
+ * upload / cms
+ */
+ private String sourceType;
+
+ /**
+ * 例如 CMS article_id
+ */
+ private Integer sourceId;
+
+ /**
+ * 例如文件名、路径等
+ */
+ private String sourceRef;
+
+ /**
+ * 文档文本内容哈希(用于增量同步/去重)。
+ */
+ private String contentHash;
+
+ private Integer status;
+
+ @TableLogic
+ private Integer deleted;
+
+ private Integer tenantId;
+
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime updateTime;
+
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime createTime;
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java
new file mode 100644
index 0000000..1628de5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.ai.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.ai.entity.AiKbChunk;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiKbChunkMapper extends BaseMapper {
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiKbDocumentMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiKbDocumentMapper.java
new file mode 100644
index 0000000..8dc61c6
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/mapper/AiKbDocumentMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.ai.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.ai.entity.AiKbDocument;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiKbDocumentMapper extends BaseMapper {
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiShopAnalyticsMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiShopAnalyticsMapper.java
new file mode 100644
index 0000000..27221ac
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/mapper/AiShopAnalyticsMapper.java
@@ -0,0 +1,16 @@
+package com.gxwebsoft.ai.mapper;
+
+import com.gxwebsoft.ai.dto.AiShopMetricsRow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Mapper
+public interface AiShopAnalyticsMapper {
+ List queryMetrics(@Param("tenantId") Integer tenantId,
+ @Param("start") LocalDateTime start,
+ @Param("end") LocalDateTime end);
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/mapper/xml/AiShopAnalyticsMapper.xml b/src/main/java/com/gxwebsoft/ai/mapper/xml/AiShopAnalyticsMapper.xml
new file mode 100644
index 0000000..0e745b6
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/mapper/xml/AiShopAnalyticsMapper.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java b/src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java
new file mode 100644
index 0000000..106ae56
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java
@@ -0,0 +1,24 @@
+package com.gxwebsoft.ai.prompt;
+
+/**
+ * 统一提示词模板(尽量简短、可控)。
+ */
+public class AiPrompts {
+ private AiPrompts() {
+ }
+
+ public static final String SYSTEM_SUPPORT =
+ "你是 WebSoft 客服AI。规则:\n" +
+ "- 只使用给定的“上下文资料”回答,禁止编造。\n" +
+ "- 如果资料不足,直接说“资料不足”,并列出需要补充的信息。\n" +
+ "- 答案末尾必须给引用,格式:[source:chunk_id]。\n" +
+ "- 输出中文,简洁可执行。\n";
+
+ public static final String SYSTEM_ANALYTICS =
+ "你是商城订单数据分析助手。你将基于提供的按天指标数据给出结论。\n" +
+ "要求:\n" +
+ "- 只基于数据陈述,不要编造不存在的数字。\n" +
+ "- 输出包含:结论、关键指标变化、异常点、建议的下一步核查。\n" +
+ "- 输出中文,简洁。\n";
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/AiAnalyticsService.java b/src/main/java/com/gxwebsoft/ai/service/AiAnalyticsService.java
new file mode 100644
index 0000000..0f37aec
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/AiAnalyticsService.java
@@ -0,0 +1,82 @@
+package com.gxwebsoft.ai.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.gxwebsoft.ai.dto.*;
+import com.gxwebsoft.ai.prompt.AiPrompts;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.utils.JSONUtil;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.Arrays;
+
+@Service
+public class AiAnalyticsService {
+ @Resource
+ private AiShopAnalyticsService shopAnalyticsService;
+ @Resource
+ private AiChatService aiChatService;
+
+ public AiShopMetricsQueryResult queryShopMetrics(Integer tenantId, AiShopMetricsQueryRequest request) {
+ if (tenantId == null) {
+ throw new BusinessException("tenantId 不能为空");
+ }
+ if (request == null) {
+ throw new BusinessException("请求不能为空");
+ }
+ LocalDate start = parseDate(request.getStartDate(), "startDate");
+ LocalDate end = parseDate(request.getEndDate(), "endDate");
+ if (end.isBefore(start)) {
+ throw new BusinessException("endDate 不能早于 startDate");
+ }
+
+ AiShopMetricsQueryResult r = new AiShopMetricsQueryResult();
+ r.setTenantId(tenantId);
+ r.setStartDate(start.toString());
+ r.setEndDate(end.toString());
+ r.setRows(shopAnalyticsService.queryTenantDaily(tenantId, start, end));
+ return r;
+ }
+
+ public AiAnalyticsAskResult askShopAnalytics(Integer tenantId, AiAnalyticsAskRequest request) {
+ if (request == null || StrUtil.isBlank(request.getQuestion())) {
+ throw new BusinessException("question 不能为空");
+ }
+ AiShopMetricsQueryRequest q = new AiShopMetricsQueryRequest();
+ q.setStartDate(request.getStartDate());
+ q.setEndDate(request.getEndDate());
+ q.setGroupByDay(true);
+ AiShopMetricsQueryResult data = queryShopMetrics(tenantId, q);
+
+ String userPrompt =
+ "用户问题:\n" + request.getQuestion() + "\n\n" +
+ "数据(JSON,字段含义:order_cnt=订单数,paid_order_cnt=已支付订单数,gmv=已支付金额,refund_amt=退款金额,pay_user_cnt=支付用户数,aov=客单价,pay_rate=支付率):\n" +
+ JSONUtil.toJSONString(data, true);
+
+ AiChatRequest chat = new AiChatRequest();
+ chat.setMessages(Arrays.asList(
+ new AiMessage("system", AiPrompts.SYSTEM_ANALYTICS),
+ new AiMessage("user", userPrompt)
+ ));
+
+ AiChatResult resp = aiChatService.chat(chat);
+ AiAnalyticsAskResult r = new AiAnalyticsAskResult();
+ r.setAnalysis(resp.getContent());
+ r.setData(data);
+ return r;
+ }
+
+ private static LocalDate parseDate(String s, String field) {
+ if (StrUtil.isBlank(s)) {
+ throw new BusinessException(field + " 不能为空,格式 YYYY-MM-DD");
+ }
+ try {
+ return LocalDate.parse(s.trim());
+ } catch (DateTimeParseException e) {
+ throw new BusinessException(field + " 格式错误,需 YYYY-MM-DD");
+ }
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/AiChatService.java b/src/main/java/com/gxwebsoft/ai/service/AiChatService.java
new file mode 100644
index 0000000..c457607
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/AiChatService.java
@@ -0,0 +1,94 @@
+package com.gxwebsoft.ai.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.gxwebsoft.ai.client.OllamaClient;
+import com.gxwebsoft.ai.client.dto.OllamaChatRequest;
+import com.gxwebsoft.ai.client.dto.OllamaChatResponse;
+import com.gxwebsoft.ai.client.dto.OllamaMessage;
+import com.gxwebsoft.ai.config.AiOllamaProperties;
+import com.gxwebsoft.ai.dto.AiChatRequest;
+import com.gxwebsoft.ai.dto.AiChatResult;
+import com.gxwebsoft.ai.dto.AiMessage;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.function.Consumer;
+
+@Service
+public class AiChatService {
+ @Resource
+ private AiOllamaProperties props;
+
+ @Resource
+ private OllamaClient ollamaClient;
+
+ public AiChatResult chat(AiChatRequest request) {
+ OllamaChatRequest req = buildChatRequest(request, false);
+ OllamaChatResponse resp = ollamaClient.chat(req);
+ String content = resp != null && resp.getMessage() != null ? resp.getMessage().getContent() : null;
+ return new AiChatResult(content == null ? "" : content);
+ }
+
+ public void chatStream(AiChatRequest request, Consumer onDelta, Consumer onFinal) {
+ Objects.requireNonNull(onDelta, "onDelta");
+ OllamaChatRequest req = buildChatRequest(request, true);
+ ollamaClient.chatStream(req, event -> {
+ String delta = event != null && event.getMessage() != null ? event.getMessage().getContent() : null;
+ if (StrUtil.isNotBlank(delta)) {
+ onDelta.accept(delta);
+ }
+ if (Boolean.TRUE.equals(event.getDone()) && onFinal != null) {
+ onFinal.accept(event);
+ }
+ });
+ }
+
+ private OllamaChatRequest buildChatRequest(AiChatRequest request, boolean stream) {
+ if (request == null) {
+ throw new BusinessException("请求不能为空");
+ }
+
+ List messages = request.getMessages();
+ if ((messages == null || messages.isEmpty()) && StrUtil.isBlank(request.getPrompt())) {
+ throw new BusinessException("prompt 或 messages 不能为空");
+ }
+ if (messages == null || messages.isEmpty()) {
+ messages = Collections.singletonList(new AiMessage("user", request.getPrompt()));
+ }
+
+ List ollamaMessages = new ArrayList<>();
+ for (AiMessage m : messages) {
+ if (m == null || StrUtil.isBlank(m.getRole()) || m.getContent() == null) {
+ continue;
+ }
+ ollamaMessages.add(new OllamaMessage(m.getRole(), m.getContent()));
+ }
+ if (ollamaMessages.isEmpty()) {
+ throw new BusinessException("messages 为空或无有效内容");
+ }
+
+ Map options = new HashMap<>();
+ if (request.getTemperature() != null) {
+ options.put("temperature", request.getTemperature());
+ }
+ if (request.getTopK() != null) {
+ options.put("top_k", request.getTopK());
+ }
+ if (request.getTopP() != null) {
+ options.put("top_p", request.getTopP());
+ }
+ if (request.getNumPredict() != null) {
+ options.put("num_predict", request.getNumPredict());
+ }
+
+ OllamaChatRequest req = new OllamaChatRequest();
+ req.setModel(StrUtil.blankToDefault(request.getModel(), props.getChatModel()));
+ req.setMessages(ollamaMessages);
+ req.setStream(stream);
+ req.setOptions(options.isEmpty() ? null : options);
+ return req;
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/AiKbChunkService.java b/src/main/java/com/gxwebsoft/ai/service/AiKbChunkService.java
new file mode 100644
index 0000000..0bcc717
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/AiKbChunkService.java
@@ -0,0 +1,8 @@
+package com.gxwebsoft.ai.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.gxwebsoft.ai.entity.AiKbChunk;
+
+public interface AiKbChunkService extends IService {
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/AiKbDocumentService.java b/src/main/java/com/gxwebsoft/ai/service/AiKbDocumentService.java
new file mode 100644
index 0000000..601f778
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/AiKbDocumentService.java
@@ -0,0 +1,8 @@
+package com.gxwebsoft.ai.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.gxwebsoft.ai.entity.AiKbDocument;
+
+public interface AiKbDocumentService extends IService {
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java b/src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java
new file mode 100644
index 0000000..04c6c1f
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java
@@ -0,0 +1,426 @@
+package com.gxwebsoft.ai.service;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.gxwebsoft.ai.client.OllamaClient;
+import com.gxwebsoft.ai.client.dto.OllamaEmbeddingResponse;
+import com.gxwebsoft.ai.config.AiOllamaProperties;
+import com.gxwebsoft.ai.dto.*;
+import com.gxwebsoft.ai.entity.AiKbChunk;
+import com.gxwebsoft.ai.entity.AiKbDocument;
+import com.gxwebsoft.ai.prompt.AiPrompts;
+import com.gxwebsoft.ai.util.AiTextUtil;
+import com.gxwebsoft.cms.entity.CmsArticle;
+import com.gxwebsoft.cms.entity.CmsArticleContent;
+import com.gxwebsoft.cms.service.CmsArticleContentService;
+import com.gxwebsoft.cms.service.CmsArticleService;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.utils.JSONUtil;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.tika.Tika;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+@Service
+public class AiKbRagService {
+ @Resource
+ private AiOllamaProperties props;
+ @Resource
+ private OllamaClient ollamaClient;
+ @Resource
+ private AiKbDocumentService documentService;
+ @Resource
+ private AiKbChunkService chunkService;
+ @Resource
+ private CmsArticleService cmsArticleService;
+ @Resource
+ private CmsArticleContentService cmsArticleContentService;
+ @Resource
+ private AiChatService aiChatService;
+ @Resource
+ private ObjectMapper objectMapper;
+
+ private final Tika tika = new Tika();
+ private volatile ExecutorService embedPool;
+
+ private ExecutorService pool() {
+ ExecutorService p = embedPool;
+ if (p != null) {
+ return p;
+ }
+ synchronized (this) {
+ if (embedPool == null) {
+ embedPool = Executors.newFixedThreadPool(Math.max(1, props.getMaxConcurrency()));
+ }
+ return embedPool;
+ }
+ }
+
+ public AiKbIngestResult ingestUpload(Integer tenantId, MultipartFile file) {
+ if (tenantId == null) {
+ throw new BusinessException("tenantId 不能为空");
+ }
+ if (file == null || file.isEmpty()) {
+ throw new BusinessException("文件不能为空");
+ }
+ String title = StrUtil.blankToDefault(file.getOriginalFilename(), "upload");
+ String text = extractText(file);
+ if (StrUtil.isBlank(text)) {
+ throw new BusinessException("无法解析文件内容,请上传 txt/md/html 等可解析文本");
+ }
+
+ String contentHash = AiTextUtil.sha256(text);
+ AiKbDocument doc = new AiKbDocument();
+ doc.setTitle(title);
+ doc.setSourceType("upload");
+ doc.setSourceRef(title);
+ doc.setContentHash(contentHash);
+ doc.setStatus(0);
+ doc.setTenantId(tenantId);
+ doc.setCreateTime(LocalDateTime.now());
+ doc.setUpdateTime(LocalDateTime.now());
+ documentService.save(doc);
+
+ int chunks = ingestChunks(doc, text);
+
+ AiKbIngestResult r = new AiKbIngestResult();
+ r.setDocumentId(doc.getDocumentId());
+ r.setTitle(doc.getTitle());
+ r.setChunks(chunks);
+ r.setUpdatedDocuments(1);
+ r.setSkippedDocuments(0);
+ return r;
+ }
+
+ /**
+ * 同步 CMS(仅当前 tenant)。
+ */
+ public AiKbIngestResult syncCms(Integer tenantId) {
+ if (tenantId == null) {
+ throw new BusinessException("tenantId 不能为空");
+ }
+ // 仅同步“已发布且未删除”的文章
+ List articles = cmsArticleService.list(new LambdaQueryWrapper()
+ .eq(CmsArticle::getTenantId, tenantId)
+ .eq(CmsArticle::getDeleted, 0)
+ .eq(CmsArticle::getStatus, 0));
+ if (articles == null || articles.isEmpty()) {
+ AiKbIngestResult r = new AiKbIngestResult();
+ r.setUpdatedDocuments(0);
+ r.setSkippedDocuments(0);
+ r.setChunks(0);
+ return r;
+ }
+
+ Set articleIds = articles.stream().map(CmsArticle::getArticleId).collect(Collectors.toSet());
+ List contents = cmsArticleContentService.list(new LambdaQueryWrapper()
+ .in(CmsArticleContent::getArticleId, articleIds));
+
+ Map contentByArticle = contents.stream()
+ .collect(Collectors.toMap(
+ CmsArticleContent::getArticleId,
+ c -> c,
+ (a, b) -> (a.getCreateTime() != null && b.getCreateTime() != null && a.getCreateTime().isAfter(b.getCreateTime())) ? a : b
+ ));
+
+ int updatedDocs = 0;
+ int skippedDocs = 0;
+ int totalChunks = 0;
+
+ for (CmsArticle a : articles) {
+ CmsArticleContent c = contentByArticle.get(a.getArticleId());
+ String raw = "";
+ if (a.getOverview() != null) {
+ raw += a.getOverview() + "\n";
+ }
+ if (c != null && c.getContent() != null) {
+ raw += c.getContent();
+ }
+ String text = a.getTitle() + "\n" + AiTextUtil.stripHtml(raw);
+ text = AiTextUtil.normalizeWhitespace(text);
+ if (StrUtil.isBlank(text)) {
+ continue;
+ }
+ String hash = AiTextUtil.sha256(text);
+
+ AiKbDocument existing = documentService.getOne(new LambdaQueryWrapper()
+ .eq(AiKbDocument::getTenantId, tenantId)
+ .eq(AiKbDocument::getSourceType, "cms")
+ .eq(AiKbDocument::getSourceId, a.getArticleId())
+ .last("limit 1"));
+
+ if (existing != null && StrUtil.equals(existing.getContentHash(), hash)) {
+ skippedDocs++;
+ continue;
+ }
+
+ AiKbDocument doc;
+ if (existing == null) {
+ doc = new AiKbDocument();
+ doc.setTitle(a.getTitle());
+ doc.setSourceType("cms");
+ doc.setSourceId(a.getArticleId());
+ doc.setSourceRef(a.getCode());
+ doc.setContentHash(hash);
+ doc.setStatus(0);
+ doc.setTenantId(tenantId);
+ doc.setCreateTime(LocalDateTime.now());
+ doc.setUpdateTime(LocalDateTime.now());
+ documentService.save(doc);
+ } else {
+ doc = existing;
+ doc.setTitle(a.getTitle());
+ doc.setSourceRef(a.getCode());
+ doc.setContentHash(hash);
+ doc.setUpdateTime(LocalDateTime.now());
+ documentService.updateById(doc);
+ // 重新入库:先删除旧 chunk
+ chunkService.remove(new LambdaQueryWrapper().eq(AiKbChunk::getDocumentId, doc.getDocumentId()));
+ }
+
+ int chunks = ingestChunks(doc, text);
+ totalChunks += chunks;
+ updatedDocs++;
+ }
+
+ AiKbIngestResult r = new AiKbIngestResult();
+ r.setUpdatedDocuments(updatedDocs);
+ r.setSkippedDocuments(skippedDocs);
+ r.setChunks(totalChunks);
+ return r;
+ }
+
+ public AiKbQueryResult query(Integer tenantId, AiKbQueryRequest request) {
+ if (tenantId == null) {
+ throw new BusinessException("tenantId 不能为空");
+ }
+ if (request == null || StrUtil.isBlank(request.getQuery())) {
+ throw new BusinessException("query 不能为空");
+ }
+ int topK = request.getTopK() != null ? request.getTopK() : props.getRagTopK();
+ topK = Math.max(1, Math.min(20, topK));
+
+ float[] qEmb = embedding(request.getQuery());
+ float qNorm = l2(qEmb);
+ if (qNorm == 0f) {
+ throw new BusinessException("query embedding 为空");
+ }
+
+ // MVP:按 tenant 拉取最新 N 条候选 chunk,再做余弦相似度排序
+ List candidates = chunkService.list(new LambdaQueryWrapper()
+ .eq(AiKbChunk::getTenantId, tenantId)
+ .orderByDesc(AiKbChunk::getId)
+ .last("limit " + props.getRagMaxCandidates()));
+
+ PriorityQueue pq = new PriorityQueue<>(Comparator.comparingDouble(h -> h.getScore() == null ? -1d : h.getScore()));
+ for (AiKbChunk c : candidates) {
+ if (StrUtil.isBlank(c.getEmbedding())) {
+ continue;
+ }
+ float[] cEmb = parseEmbedding(c.getEmbedding());
+ if (cEmb == null || cEmb.length == 0) {
+ continue;
+ }
+ Double cNormD = c.getEmbeddingNorm();
+ float cNorm = cNormD == null ? l2(cEmb) : cNormD.floatValue();
+ if (cNorm == 0f) {
+ continue;
+ }
+ double score = dot(qEmb, cEmb) / (qNorm * cNorm);
+ AiKbHit hit = new AiKbHit();
+ hit.setChunkId(c.getChunkId());
+ hit.setDocumentId(c.getDocumentId());
+ hit.setTitle(StrUtil.blankToDefault(c.getTitle(), ""));
+ hit.setScore(score);
+ // 返回给前端时避免过长
+ hit.setContent(clip(c.getContent(), 900));
+
+ if (pq.size() < topK) {
+ pq.add(hit);
+ } else if (hit.getScore() != null && hit.getScore() > pq.peek().getScore()) {
+ pq.poll();
+ pq.add(hit);
+ }
+ }
+
+ List hits = new ArrayList<>(pq);
+ hits.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
+ AiKbQueryResult r = new AiKbQueryResult();
+ r.setHits(hits);
+ return r;
+ }
+
+ public AiKbAskResult ask(Integer tenantId, AiKbAskRequest request) {
+ if (request == null || StrUtil.isBlank(request.getQuestion())) {
+ throw new BusinessException("question 不能为空");
+ }
+ AiKbQueryRequest q = new AiKbQueryRequest();
+ q.setQuery(request.getQuestion());
+ q.setTopK(request.getTopK());
+ AiKbQueryResult retrieval = query(tenantId, q);
+
+ String context = buildContext(retrieval);
+ String userPrompt = "上下文资料:\n" + context + "\n\n用户问题:\n" + request.getQuestion();
+
+ AiChatRequest chatReq = new AiChatRequest();
+ chatReq.setMessages(Arrays.asList(
+ new AiMessage("system", AiPrompts.SYSTEM_SUPPORT),
+ new AiMessage("user", userPrompt)
+ ));
+ AiChatResult chat = aiChatService.chat(chatReq);
+
+ AiKbAskResult r = new AiKbAskResult();
+ r.setAnswer(chat.getContent());
+ r.setRetrieval(retrieval);
+ return r;
+ }
+
+ private int ingestChunks(AiKbDocument doc, String text) {
+ List chunks = AiTextUtil.chunkText(text, props.getRagChunkSize(), props.getRagChunkOverlap());
+ if (chunks.isEmpty()) {
+ return 0;
+ }
+ LocalDateTime now = LocalDateTime.now();
+
+ List> futures = new ArrayList<>(chunks.size());
+ for (int i = 0; i < chunks.size(); i++) {
+ final int idx = i;
+ final String chunkText = chunks.get(i);
+ futures.add(CompletableFuture.supplyAsync(() -> {
+ OllamaEmbeddingResponse emb = ollamaClient.embedding(chunkText);
+ if (emb == null || emb.getEmbedding() == null || emb.getEmbedding().isEmpty()) {
+ throw new BusinessException("embedding 生成失败");
+ }
+ float[] v = toFloat(emb.getEmbedding());
+ float norm = l2(v);
+ AiKbChunk c = new AiKbChunk();
+ c.setDocumentId(doc.getDocumentId());
+ c.setChunkId(UUID.randomUUID().toString().replace("-", ""));
+ c.setChunkIndex(idx);
+ c.setTitle(doc.getTitle());
+ c.setContent(chunkText);
+ c.setContentHash(AiTextUtil.sha256(chunkText));
+ c.setEmbedding(JSONUtil.toJSONString(emb.getEmbedding()));
+ c.setEmbeddingNorm((double) norm);
+ c.setTenantId(doc.getTenantId());
+ c.setCreateTime(now);
+ c.setDeleted(0);
+ return c;
+ }, pool()));
+ }
+
+ List entities = futures.stream().map(f -> {
+ try {
+ return f.get(10, TimeUnit.MINUTES);
+ } catch (Exception e) {
+ throw new BusinessException("embedding 批处理失败: " + e.getMessage());
+ }
+ }).collect(Collectors.toList());
+
+ chunkService.saveBatch(entities);
+ return entities.size();
+ }
+
+ private String extractText(MultipartFile file) {
+ try {
+ String contentType = file.getContentType();
+ String filename = file.getOriginalFilename();
+
+ // 优先:对纯文本直接读 UTF-8
+ if ((contentType != null && contentType.startsWith("text/"))
+ || (filename != null && (filename.endsWith(".txt") || filename.endsWith(".md") || filename.endsWith(".html") || filename.endsWith(".htm")))) {
+ return AiTextUtil.normalizeWhitespace(new String(file.getBytes(), StandardCharsets.UTF_8));
+ }
+
+ // 尝试用 tika 解析(注意:当前依赖为 tika-core,解析能力有限)
+ String parsed = tika.parseToString(file.getInputStream());
+ return AiTextUtil.normalizeWhitespace(parsed);
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ private float[] embedding(String text) {
+ OllamaEmbeddingResponse emb = ollamaClient.embedding(text);
+ if (emb == null || emb.getEmbedding() == null || emb.getEmbedding().isEmpty()) {
+ throw new BusinessException("embedding 生成失败");
+ }
+ return toFloat(emb.getEmbedding());
+ }
+
+ private float[] parseEmbedding(String json) {
+ try {
+ // embedding 是一维数组,存储为 JSON 文本
+ double[] d = ObjectUtil.isEmpty(json) ? null : objectMapper.readValue(json, double[].class);
+ if (d == null || d.length == 0) {
+ return null;
+ }
+ float[] f = new float[d.length];
+ for (int i = 0; i < d.length; i++) {
+ f[i] = (float) d[i];
+ }
+ return f;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static float[] toFloat(List v) {
+ float[] out = new float[v.size()];
+ for (int i = 0; i < v.size(); i++) {
+ Double d = v.get(i);
+ out[i] = d == null ? 0f : d.floatValue();
+ }
+ return out;
+ }
+
+ private static float dot(float[] a, float[] b) {
+ int n = Math.min(a.length, b.length);
+ double s = 0d;
+ for (int i = 0; i < n; i++) {
+ s += (double) a[i] * (double) b[i];
+ }
+ return (float) s;
+ }
+
+ private static float l2(float[] a) {
+ double s = 0d;
+ for (float v : a) {
+ s += (double) v * (double) v;
+ }
+ return (float) Math.sqrt(s);
+ }
+
+ private static String clip(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ if (s.length() <= max) {
+ return s;
+ }
+ return s.substring(0, max) + "...";
+ }
+
+ private static String buildContext(AiKbQueryResult retrieval) {
+ if (retrieval == null || retrieval.getHits() == null || retrieval.getHits().isEmpty()) {
+ return "(无)";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (AiKbHit h : retrieval.getHits()) {
+ sb.append("[source:").append(h.getChunkId()).append("] ");
+ if (StrUtil.isNotBlank(h.getTitle())) {
+ sb.append(h.getTitle()).append("\n");
+ }
+ sb.append(h.getContent()).append("\n\n");
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/ai/service/AiShopAnalyticsService.java b/src/main/java/com/gxwebsoft/ai/service/AiShopAnalyticsService.java
new file mode 100644
index 0000000..09aad90
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/AiShopAnalyticsService.java
@@ -0,0 +1,46 @@
+package com.gxwebsoft.ai.service;
+
+import com.gxwebsoft.ai.dto.AiShopMetricsRow;
+import com.gxwebsoft.ai.mapper.AiShopAnalyticsMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+public class AiShopAnalyticsService {
+ @Resource
+ private AiShopAnalyticsMapper mapper;
+
+ public List queryTenantDaily(Integer tenantId, LocalDate startDate, LocalDate endDateInclusive) {
+ LocalDateTime start = startDate.atStartOfDay();
+ LocalDateTime endExclusive = endDateInclusive.plusDays(1).atStartOfDay();
+ List rows = mapper.queryMetrics(tenantId, start, endExclusive);
+ if (rows == null) {
+ return null;
+ }
+ for (AiShopMetricsRow r : rows) {
+ long orderCnt = r.getOrderCnt() == null ? 0L : r.getOrderCnt();
+ long paidCnt = r.getPaidOrderCnt() == null ? 0L : r.getPaidOrderCnt();
+ BigDecimal gmv = r.getGmv() == null ? BigDecimal.ZERO : r.getGmv();
+
+ if (paidCnt > 0) {
+ r.setAov(gmv.divide(BigDecimal.valueOf(paidCnt), 4, RoundingMode.HALF_UP));
+ } else {
+ r.setAov(BigDecimal.ZERO);
+ }
+ if (orderCnt > 0) {
+ r.setPayRate(BigDecimal.valueOf(paidCnt)
+ .divide(BigDecimal.valueOf(orderCnt), 4, RoundingMode.HALF_UP));
+ } else {
+ r.setPayRate(BigDecimal.ZERO);
+ }
+ }
+ return rows;
+ }
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/impl/AiKbChunkServiceImpl.java b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbChunkServiceImpl.java
new file mode 100644
index 0000000..de9e157
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbChunkServiceImpl.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.ai.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.gxwebsoft.ai.entity.AiKbChunk;
+import com.gxwebsoft.ai.mapper.AiKbChunkMapper;
+import com.gxwebsoft.ai.service.AiKbChunkService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AiKbChunkServiceImpl extends ServiceImpl implements AiKbChunkService {
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/service/impl/AiKbDocumentServiceImpl.java b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbDocumentServiceImpl.java
new file mode 100644
index 0000000..7295e96
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbDocumentServiceImpl.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.ai.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.gxwebsoft.ai.entity.AiKbDocument;
+import com.gxwebsoft.ai.mapper.AiKbDocumentMapper;
+import com.gxwebsoft.ai.service.AiKbDocumentService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AiKbDocumentServiceImpl extends ServiceImpl implements AiKbDocumentService {
+}
+
diff --git a/src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java b/src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java
new file mode 100644
index 0000000..d7044f6
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java
@@ -0,0 +1,100 @@
+package com.gxwebsoft.ai.util;
+
+import cn.hutool.core.util.StrUtil;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.List;
+
+public class AiTextUtil {
+ private AiTextUtil() {
+ }
+
+ public static String sha256(String s) {
+ if (s == null) {
+ return null;
+ }
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] dig = md.digest(s.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(dig.length * 2);
+ for (byte b : dig) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * 很轻量的 HTML 转纯文本(不追求完美,只用于知识库入库前清洗)。
+ */
+ public static String stripHtml(String html) {
+ if (StrUtil.isBlank(html)) {
+ return "";
+ }
+ String s = html;
+ s = s.replaceAll("(?is)", " ");
+ s = s.replaceAll("(?is)", " ");
+ s = s.replaceAll("(?is)
", "\n");
+ s = s.replaceAll("(?is)
", "\n");
+ s = s.replaceAll("(?is)<[^>]+>", " ");
+ // 常见 HTML 实体最小处理
+ s = s.replace(" ", " ");
+ s = s.replace("<", "<").replace(">", ">").replace("&", "&").replace(""", "\"");
+ return normalizeWhitespace(s);
+ }
+
+ public static String normalizeWhitespace(String s) {
+ if (s == null) {
+ return "";
+ }
+ // 合并空白,保留换行用于 chunking
+ String x = s.replace("\r", "\n");
+ x = x.replaceAll("[\\t\\f\\u000B]+", " ");
+ x = x.replaceAll("[ ]{2,}", " ");
+ x = x.replaceAll("\\n{3,}", "\n\n");
+ return x.trim();
+ }
+
+ /**
+ * 按字符数切分,并做固定 overlap。
+ */
+ public static List chunkText(String text, int chunkSize, int overlap) {
+ String s = normalizeWhitespace(text);
+ List out = new ArrayList<>();
+ if (StrUtil.isBlank(s)) {
+ return out;
+ }
+ if (chunkSize <= 0) {
+ chunkSize = 800;
+ }
+ if (overlap < 0) {
+ overlap = 0;
+ }
+ if (overlap >= chunkSize) {
+ overlap = Math.max(0, chunkSize / 5);
+ }
+
+ int n = s.length();
+ int start = 0;
+ while (start < n) {
+ int end = Math.min(n, start + chunkSize);
+ String chunk = s.substring(start, end).trim();
+ if (!chunk.isEmpty()) {
+ out.add(chunk);
+ }
+ if (end >= n) {
+ break;
+ }
+ start = end - overlap;
+ if (start < 0) {
+ start = 0;
+ }
+ }
+ return out;
+ }
+}
+
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index f1a8a82..ada4ffe 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -4,7 +4,7 @@ server:
# 多环境配置
spring:
profiles:
- active: ysb2
+ active: dev
application:
name: server
@@ -201,6 +201,22 @@ springdoc:
swagger-ui:
enabled: true
+# AI 模块(Ollama)
+ai:
+ ollama:
+ base-url: https://ai-api.websoft.top
+ fallback-url: http://47.119.165.234:11434
+ chat-model: qwen3.5:cloud
+ embed-model: qwen3-embedding:4b
+ connect-timeout-ms: 10000
+ read-timeout-ms: 300000
+ write-timeout-ms: 60000
+ max-concurrency: 4
+ rag-max-candidates: 2000
+ rag-top-k: 5
+ rag-chunk-size: 800
+ rag-chunk-overlap: 120
+
# LED - 排班接口(业务中台)对接配置
led:
bme:
diff --git a/websoft-modules.log.2026-02-24.0.gz b/websoft-modules.log.2026-02-24.0.gz
new file mode 100644
index 0000000..c6ac469
Binary files /dev/null and b/websoft-modules.log.2026-02-24.0.gz differ