Browse Source

chroma 模块

ct 1 month ago
parent
commit
e58d5416b9
50 changed files with 3403 additions and 5 deletions
  1. 97 0
      fs-ai-api/pom.xml
  2. 25 0
      fs-ai-api/src/main/java/com/fs/FsAiApiApplication.java
  3. 19 0
      fs-ai-api/src/main/java/com/fs/ai/rag/AiRagConfiguration.java
  4. 81 0
      fs-ai-api/src/main/java/com/fs/ai/rag/AiRagProperties.java
  5. 22 0
      fs-ai-api/src/main/java/com/fs/ai/rag/KnowledgeVectorService.java
  6. 113 0
      fs-ai-api/src/main/java/com/fs/ai/rag/controller/AiRagController.java
  7. 14 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/CreateDatabaseReq.java
  8. 12 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/CreateTenantReq.java
  9. 16 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/DeleteReq.java
  10. 18 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/IndexReq.java
  11. 18 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/QueryReq.java
  12. 463 0
      fs-ai-api/src/main/java/com/fs/ai/rag/impl/KnowledgeVectorServiceImpl.java
  13. 182 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  14. 73 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  15. 229 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/LogAspect.java
  16. 117 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  17. 58 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java
  18. 113 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java
  19. 13 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java
  20. 24 0
      fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java
  21. 31 0
      fs-ai-api/src/main/java/com/fs/framework/config/ApplicationConfig.java
  22. 96 0
      fs-ai-api/src/main/java/com/fs/framework/config/DataSourceConfig.java
  23. 123 0
      fs-ai-api/src/main/java/com/fs/framework/config/DruidConfig.java
  24. 72 0
      fs-ai-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  25. 59 0
      fs-ai-api/src/main/java/com/fs/framework/config/FilterConfig.java
  26. 149 0
      fs-ai-api/src/main/java/com/fs/framework/config/MyBatisConfig.java
  27. 65 0
      fs-ai-api/src/main/java/com/fs/framework/config/ResourcesConfig.java
  28. 59 0
      fs-ai-api/src/main/java/com/fs/framework/config/SecurityConfig.java
  29. 33 0
      fs-ai-api/src/main/java/com/fs/framework/config/ServerConfig.java
  30. 124 0
      fs-ai-api/src/main/java/com/fs/framework/config/SwaggerConfig.java
  31. 63 0
      fs-ai-api/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  32. 77 0
      fs-ai-api/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  33. 27 0
      fs-ai-api/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  34. 44 0
      fs-ai-api/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  35. 27 0
      fs-ai-api/src/main/java/com/fs/framework/filter/AppTenantSwitchFilter.java
  36. 56 0
      fs-ai-api/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  37. 126 0
      fs-ai-api/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  38. 56 0
      fs-ai-api/src/main/java/com/fs/framework/manager/AsyncManager.java
  39. 40 0
      fs-ai-api/src/main/java/com/fs/framework/manager/ShutdownManager.java
  40. 50 0
      fs-ai-api/src/main/java/com/fs/framework/security/SecurityUtils.java
  41. 17 0
      fs-ai-api/src/main/java/com/fs/framework/security/TenantPrincipal.java
  42. 1 0
      fs-ai-api/src/main/resources/META-INF/spring-devtools.properties
  43. 52 0
      fs-ai-api/src/main/resources/application.yml
  44. 2 0
      fs-ai-api/src/main/resources/banner.txt
  45. 37 0
      fs-ai-api/src/main/resources/i18n/messages.properties
  46. 93 0
      fs-ai-api/src/main/resources/logback.xml
  47. 15 0
      fs-ai-api/src/main/resources/mybatis/mybatis-config.xml
  48. 2 0
      fs-service/src/main/java/com/fs/tenant/service/TenantInfoService.java
  49. 99 5
      fs-service/src/main/java/com/fs/tenant/service/impl/TenantInfoServiceImpl.java
  50. 1 0
      pom.xml

+ 97 - 0
fs-ai-api/pom.xml

@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>fs</artifactId>
+        <groupId>com.fs</groupId>
+        <version>1.1.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>fs-ai-api</artifactId>
+    <description>知识库向量服务:Embedding + Chroma v2</description>
+
+    <dependencies>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-devtools</artifactId>
+            <optional>true</optional> <!-- 表示依赖不会传递 -->
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+        <!-- swagger2-UI-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.xiaoymin</groupId>
+            <artifactId>swagger-bootstrap-ui</artifactId>
+            <version>1.9.3</version>
+        </dependency>
+
+        <!-- Mysql驱动包 -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+        <!-- SpringBoot Web容器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- SpringBoot 拦截器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</artifactId>
+        </dependency>
+
+
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <fork>true</fork> <!-- 如果没有该配置,devtools不会生效 -->
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+                <version>3.1.0</version>
+                <configuration>
+                    <failOnMissingWebXml>false</failOnMissingWebXml>
+                    <warName>${project.artifactId}</warName>
+                </configuration>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 25 - 0
fs-ai-api/src/main/java/com/fs/FsAiApiApplication.java

@@ -0,0 +1,25 @@
+package com.fs;
+
+import com.github.xiaoymin.swaggerbootstrapui.annotations.EnableSwaggerBootstrapUI;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * 兼容历史运行配置(类名大小写差异)。
+ * 推荐主入口:FsAiApiApplication
+ */
+@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
+@EnableTransactionManagement
+@EnableAsync
+@EnableScheduling
+public class FsAiApiApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(FsAiApiApplication.class, args);
+        System.out.println("fs-ai-app启动成功");
+    }
+}

+ 19 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/AiRagConfiguration.java

@@ -0,0 +1,19 @@
+package com.fs.ai.rag;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.BufferingClientHttpRequestFactory;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class AiRagConfiguration {
+
+    @Bean
+    public RestTemplate aiRagRestTemplate(AiRagProperties properties) {
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(properties.getConnectTimeoutMs());
+        factory.setReadTimeout(properties.getReadTimeoutMs());
+        return new RestTemplate(new BufferingClientHttpRequestFactory(factory));
+    }
+}

+ 81 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/AiRagProperties.java

@@ -0,0 +1,81 @@
+package com.fs.ai.rag;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "ai.rag")
+public class AiRagProperties {
+    private boolean enabled = true;
+    private String chromaBaseUrl = "https://saaschroma.ylrzcloud.com/api/v2";
+    private String chromaToken = "";
+    private String chromaTenantId = "";
+    private String chromaDatabase = "default_database";
+    private String embeddingUrl = "";
+    private String embeddingApiKey = "";
+    private String embeddingModel = "text-embedding-3-small";
+    private int connectTimeoutMs = 8000;
+    private int readTimeoutMs = 120000;
+    private int chunkSize = 500;
+    private int chunkOverlap = 80;
+
+    /**
+     * 多模型路由配置(可选),为空时走旧字段 embedding-url/api-key/model。
+     */
+    private Routing routing = new Routing();
+
+    /**
+     * 模型注册表:key=别名(如 openai-embed-small)
+     */
+    private Map<String, ModelConfig> models = new HashMap<>();
+
+    /**
+     * 供应商注册表:key=provider 名称(如 openai/qwen/local)
+     */
+    private Map<String, ProviderConfig> providers = new HashMap<>();
+
+    @Data
+    public static class Routing {
+        /**
+         * 默认 embedding 模型别名
+         */
+        private String embeddingDefault;
+        /**
+         * 按场景路由模型别名,key 为业务场景名
+         */
+        private Map<String, String> scenes = new HashMap<>();
+    }
+
+    @Data
+    public static class ModelConfig {
+        /**
+         * embedding/chat/rerank 等,当前主要用 embedding
+         */
+        private String type = "embedding";
+        /**
+         * 指向 providers 的 key
+         */
+        private String provider;
+        /**
+         * 模型名,如 text-embedding-3-small
+         */
+        private String model;
+    }
+
+    @Data
+    public static class ProviderConfig {
+        /**
+         * OpenAI 兼容接口的 baseUrl(如 https://api.openai.com/v1)
+         */
+        private String baseUrl;
+        /**
+         * 对应 provider 的 key
+         */
+        private String apiKey;
+    }
+}

+ 22 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/KnowledgeVectorService.java

@@ -0,0 +1,22 @@
+package com.fs.ai.rag;
+
+import com.fs.ai.rag.dto.DeleteReq;
+import com.fs.ai.rag.dto.CreateDatabaseReq;
+import com.fs.ai.rag.dto.CreateTenantReq;
+import com.fs.ai.rag.dto.IndexReq;
+import com.fs.ai.rag.dto.QueryReq;
+
+import java.util.List;
+import java.util.Map;
+
+public interface KnowledgeVectorService {
+    void index(IndexReq req);
+
+    List<String> search(QueryReq req);
+
+    void deleteByDocId(DeleteReq req);
+
+    Map<String, Object> createTenant(CreateTenantReq req);
+
+    Map<String, Object> createDatabase(CreateDatabaseReq req);
+}

+ 113 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/controller/AiRagController.java

@@ -0,0 +1,113 @@
+package com.fs.ai.rag.controller;
+
+import com.fs.ai.rag.KnowledgeVectorService;
+import com.fs.ai.rag.dto.*;
+import com.fs.common.core.domain.R;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Api(tags = "知识库向量(Chroma v2)")
+@RestController
+@RequestMapping("/kb")
+@RequiredArgsConstructor
+public class AiRagController {
+
+    private final KnowledgeVectorService knowledgeVectorService;
+
+    @ApiOperation("入库(分片 + Embedding + upsert)")
+    @PostMapping("/index")
+    public R index(@RequestBody Object req) {
+        for (IndexReq one : toIndexReqList(req)) {
+            knowledgeVectorService.index(one);
+        }
+        return R.ok();
+    }
+
+    @ApiOperation("检索")
+    @PostMapping("/query")
+    public R query(@RequestBody QueryReq req) {
+        return R.ok().put("data", knowledgeVectorService.search(req));
+    }
+
+    @ApiOperation("按 doc_id 删除")
+    @PostMapping("/delete")
+    public R delete(@RequestBody DeleteReq req) {
+        knowledgeVectorService.deleteByDocId(req);
+        return R.ok();
+    }
+
+    @ApiOperation("创建 Chroma Tenant")
+    @PostMapping("/tenant/create")
+    public R createTenant(@RequestBody CreateTenantReq req) {
+        return R.ok().put("data", knowledgeVectorService.createTenant(req));
+    }
+
+    @ApiOperation("创建 Chroma Database")
+    @PostMapping("/database/create")
+    public R createDatabase(@RequestBody CreateDatabaseReq req) {
+        return R.ok().put("data", knowledgeVectorService.createDatabase(req));
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<IndexReq> toIndexReqList(Object req) {
+        if (req instanceof Map) {
+            Object documents = ((Map<String, Object>) req).get("documents");
+            if (documents instanceof List) {
+                List<IndexReq> out = new ArrayList<>();
+                for (Object item : (List<?>) documents) {
+                    if (!(item instanceof Map)) {
+                        continue;
+                    }
+                    out.add(convertDocument((Map<String, Object>) item));
+                }
+                return out;
+            }
+        }
+        // 兼容旧格式:直接 IndexReq
+        if (req instanceof IndexReq) {
+            return java.util.Collections.singletonList((IndexReq) req);
+        }
+        // 兜底:Spring 默认把 JSON object 反序列化成 LinkedHashMap
+        return java.util.Collections.singletonList(convertDocument((Map<String, Object>) req));
+    }
+
+    @SuppressWarnings("unchecked")
+    private IndexReq convertDocument(Map<String, Object> doc) {
+        IndexReq req = new IndexReq();
+        req.setDocId(toStr(doc.get("doc_id")));
+        req.setTenantId(toStr(doc.get("tenant_id")));
+        req.setText(toStr(doc.get("content")));
+
+        String collectionName = toStr(doc.get("collection_name"));
+        if (collectionName == null) {
+            Map<String, Object> metadata = doc.get("metadata") instanceof Map ? (Map<String, Object>) doc.get("metadata") : null;
+            String companyId = metadata == null ? null : toStr(metadata.get("company_id"));
+            String bizType = metadata == null ? null : toStr(metadata.get("biz_type"));
+            if (companyId != null && bizType != null) {
+                collectionName = "company_" + companyId + "_" + bizType;
+            } else {
+                collectionName = "default_collection";
+            }
+        }
+        req.setCollectionName(collectionName);
+        return req;
+    }
+
+    private String toStr(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String s = Objects.toString(value, null);
+        return (s == null || s.trim().isEmpty()) ? null : s.trim();
+    }
+}

+ 14 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/CreateDatabaseReq.java

@@ -0,0 +1,14 @@
+package com.fs.ai.rag.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("创建 Chroma Database")
+public class CreateDatabaseReq {
+    @ApiModelProperty("Tenant UUID(必填)")
+    private String tenantId;
+    @ApiModelProperty("数据库名称,如 default_database / ylrz_db")
+    private String name;
+}

+ 12 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/CreateTenantReq.java

@@ -0,0 +1,12 @@
+package com.fs.ai.rag.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("创建 Chroma Tenant")
+public class CreateTenantReq {
+    @ApiModelProperty("租户名,如 ylrz_tenant")
+    private String name;
+}

+ 16 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/DeleteReq.java

@@ -0,0 +1,16 @@
+package com.fs.ai.rag.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("按业务主键删除")
+public class DeleteReq {
+    @ApiModelProperty("租户")
+    private String tenantId;
+    @ApiModelProperty("业务主键 doc_id")
+    private String docId;
+    @ApiModelProperty("集合名")
+    private String collectionName;
+}

+ 18 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/IndexReq.java

@@ -0,0 +1,18 @@
+package com.fs.ai.rag.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("知识入库")
+public class IndexReq {
+    @ApiModelProperty("公司/租户维度,用作 Chroma metadata.tenant_id")
+    private String tenantId;
+    @ApiModelProperty("业务主键,全文更新时先按此删旧向量")
+    private String docId;
+    @ApiModelProperty("集合名(公司+业务域),如 company_1_workflow_ai")
+    private String collectionName;
+    @ApiModelProperty("入库全文")
+    private String text;
+}

+ 18 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QueryReq.java

@@ -0,0 +1,18 @@
+package com.fs.ai.rag.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("向量检索")
+public class QueryReq {
+    @ApiModelProperty("租户过滤,与入库 metadata.tenant_id 一致")
+    private String tenantId;
+    @ApiModelProperty("集合名")
+    private String collectionName;
+    @ApiModelProperty("检索问题")
+    private String question;
+    @ApiModelProperty("条数")
+    private Integer topK = 5;
+}

+ 463 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/impl/KnowledgeVectorServiceImpl.java

@@ -0,0 +1,463 @@
+package com.fs.ai.rag.impl;
+
+import com.fs.ai.rag.AiRagProperties;
+import com.fs.ai.rag.KnowledgeVectorService;
+import com.fs.ai.rag.dto.CreateDatabaseReq;
+import com.fs.ai.rag.dto.CreateTenantReq;
+import com.fs.ai.rag.dto.DeleteReq;
+import com.fs.ai.rag.dto.IndexReq;
+import com.fs.ai.rag.dto.QueryReq;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.*;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class KnowledgeVectorServiceImpl implements KnowledgeVectorService {
+
+    private final AiRagProperties props;
+    private final RestTemplate restTemplate;
+
+    private volatile ChromaScope resolvedScope;
+    private static final class ChromaScope {
+        final String tenantId;
+        final String databaseName;
+
+        ChromaScope(String tenantId, String databaseName) {
+            this.tenantId = tenantId;
+            this.databaseName = databaseName;
+        }
+    }
+
+    private static final class EmbeddingProfile {
+        final String url;
+        final String apiKey;
+        final String model;
+
+        EmbeddingProfile(String url, String apiKey, String model) {
+            this.url = url;
+            this.apiKey = apiKey;
+            this.model = model;
+        }
+    }
+
+    @Override
+    public void index(IndexReq req) {
+        if (!props.isEnabled()) {
+            return;
+        }
+        validateIndex(req);
+        EmbeddingProfile embeddingProfile = resolveEmbeddingProfile(req.getCollectionName());
+        ChromaScope scope = resolveScope();
+        String collId = getOrCreateCollectionId(scope, req.getCollectionName());
+
+        deleteByDocIdInternal(scope, collId, req.getDocId());
+
+        List<String> chunks = chunk(req.getText());
+        if (chunks.isEmpty()) {
+            return;
+        }
+        List<List<Double>> embeddings = embedBatch(chunks, embeddingProfile);
+        if (embeddings.size() != chunks.size()) {
+            throw new IllegalStateException("Embedding 条数与分片不一致");
+        }
+
+        List<String> ids = new ArrayList<>();
+        List<Map<String, Object>> metadatas = new ArrayList<>();
+        int n = chunks.size();
+        for (int i = 0; i < n; i++) {
+            ids.add(req.getDocId() + "_" + i);
+            Map<String, Object> meta = new LinkedHashMap<>();
+            meta.put("doc_id", req.getDocId());
+            meta.put("tenant_id", req.getTenantId());
+            meta.put("chunk_index", i);
+            meta.put("total_chunks", n);
+            metadatas.add(meta);
+        }
+
+        String url = collectionsPath(scope, collId) + "/upsert";
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("ids", ids);
+        body.put("embeddings", embeddings);
+        body.put("documents", chunks);
+        body.put("metadatas", metadatas);
+        postJson(url, body);
+    }
+
+    @Override
+    public List<String> search(QueryReq req) {
+        if (!props.isEnabled()) {
+            return Collections.emptyList();
+        }
+        if (StringUtils.isAnyBlank(req.getTenantId(), req.getCollectionName(), req.getQuestion())) {
+            throw new IllegalArgumentException("tenantId/collectionName/question 不能为空");
+        }
+        EmbeddingProfile embeddingProfile = resolveEmbeddingProfile(req.getCollectionName());
+        ChromaScope scope = resolveScope();
+        String collId = findCollectionIdByName(scope, req.getCollectionName());
+        if (collId == null) {
+            return Collections.emptyList();
+        }
+
+        List<Double> vec = embedSingle(req.getQuestion(), embeddingProfile);
+        String url = collectionsPath(scope, collId) + "/query";
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("query_embeddings", Collections.singletonList(vec));
+        body.put("n_results", Optional.ofNullable(req.getTopK()).orElse(5));
+        Map<String, String> where = new LinkedHashMap<>();
+        where.put("tenant_id", req.getTenantId());
+        body.put("where", where);
+        body.put("include", Arrays.asList("documents", "metadatas", "distances"));
+
+        Map<String, Object> resp = postJsonReturnMap(url, body);
+        return extractDocuments(resp);
+    }
+
+    @Override
+    public void deleteByDocId(DeleteReq req) {
+        if (!props.isEnabled()) {
+            return;
+        }
+        if (StringUtils.isAnyBlank(req.getTenantId(), req.getDocId(), req.getCollectionName())) {
+            throw new IllegalArgumentException("tenantId/docId/collectionName 不能为空");
+        }
+        ChromaScope scope = resolveScope();
+        String collId = findCollectionIdByName(scope, req.getCollectionName());
+        if (collId == null) {
+            return;
+        }
+        deleteByDocIdInternal(scope, collId, req.getDocId());
+    }
+
+    @Override
+    public Map<String, Object> createTenant(CreateTenantReq req) {
+        if (req == null || StringUtils.isBlank(req.getName())) {
+            throw new IllegalArgumentException("name 不能为空");
+        }
+        String url = baseNormalized() + "/tenants";
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("name", req.getName().trim());
+        return postJsonReturnMap(url, body);
+    }
+
+    @Override
+    public Map<String, Object> createDatabase(CreateDatabaseReq req) {
+        if (req == null || StringUtils.isAnyBlank(req.getTenantId(), req.getName())) {
+            throw new IllegalArgumentException("tenantId/name 不能为空");
+        }
+        String url = baseNormalized() + "/tenants/" + req.getTenantId().trim() + "/databases";
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("name", req.getName().trim());
+        return postJsonReturnMap(url, body);
+    }
+
+    private void deleteByDocIdInternal(ChromaScope scope, String collectionId, String docId) {
+        String url = collectionsPath(scope, collectionId) + "/delete";
+        Map<String, Object> where = new LinkedHashMap<>();
+        where.put("doc_id", docId);
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("where", where);
+        postJson(url, body);
+    }
+
+    private String baseNormalized() {
+        String b = props.getChromaBaseUrl().trim();
+        while (b.endsWith("/")) {
+            b = b.substring(0, b.length() - 1);
+        }
+        return b;
+    }
+
+    private String tenantsDatabasesPath(ChromaScope s) {
+        return baseNormalized() + "/tenants/" + s.tenantId + "/databases/" + s.databaseName;
+    }
+
+    private String collectionsPath(ChromaScope s, String collectionId) {
+        return tenantsDatabasesPath(s) + "/collections/" + collectionId;
+    }
+
+    private ChromaScope resolveScope() {
+        ChromaScope c = resolvedScope;
+        if (c != null) {
+            return c;
+        }
+        synchronized (this) {
+            if (resolvedScope != null) {
+                return resolvedScope;
+            }
+            String tid = StringUtils.trimToEmpty(props.getChromaTenantId());
+            String db = StringUtils.trimToEmpty(props.getChromaDatabase());
+            if (StringUtils.isNotBlank(tid) && StringUtils.isNotBlank(db)) {
+                resolvedScope = new ChromaScope(tid, db);
+                return resolvedScope;
+            }
+            Map<String, Object> idMap = getJsonMap(baseNormalized() + "/auth/identity");
+            String tenant = Objects.toString(idMap.get("tenant"), "");
+            if (StringUtils.isBlank(tenant)) {
+                throw new IllegalStateException("Chroma /auth/identity 未返回 tenant,请配置 ai.rag.chroma-tenant-id 与 ai.rag.chroma-database");
+            }
+            if (StringUtils.isBlank(db)) {
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> databases = (List<Map<String, Object>>) idMap.get("databases");
+                if (databases != null && !databases.isEmpty()) {
+                    db = Objects.toString(databases.get(0).get("name"), "default_database");
+                } else {
+                    db = "default_database";
+                }
+            }
+            resolvedScope = new ChromaScope(tenant, db);
+            log.info("Chroma 作用域:tenant={}, database={}", tenant, db);
+            return resolvedScope;
+        }
+    }
+
+    private String getOrCreateCollectionId(ChromaScope scope, String name) {
+        String existing = findCollectionIdByName(scope, name);
+        if (existing != null) {
+            return existing;
+        }
+        String url = tenantsDatabasesPath(scope) + "/collections";
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("name", name);
+        body.put("get_or_create", Boolean.TRUE);
+        Map<String, Object> resp = postJsonReturnMap(url, body);
+        Object id = resp.get("id");
+        if (id != null) {
+            return id.toString();
+        }
+        return Objects.requireNonNull(findCollectionIdByName(scope, name), "创建集合失败: " + name);
+    }
+
+    @SuppressWarnings("unchecked")
+    private String findCollectionIdByName(ChromaScope scope, String name) {
+        String url = tenantsDatabasesPath(scope) + "/collections?limit=500&offset=0";
+        Object resp = getJsonObject(url);
+        List<?> collections;
+        // 兼容两类响应:
+        // 1) [](直接数组)
+        // 2) {"collections":[...]}(对象包裹数组)
+        if (resp instanceof List) {
+            collections = (List<?>) resp;
+        } else if (resp instanceof Map) {
+            Object cols = ((Map<String, Object>) resp).get("collections");
+            if (!(cols instanceof List)) {
+                return null;
+            }
+            collections = (List<?>) cols;
+        } else {
+            return null;
+        }
+        for (Object o : collections) {
+            if (!(o instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) o;
+            if (name.equals(Objects.toString(m.get("name"), null))) {
+                return Objects.toString(m.get("id"), null);
+            }
+        }
+        return null;
+    }
+
+    private void validateIndex(IndexReq req) {
+        if (StringUtils.isAnyBlank(req.getTenantId(), req.getDocId(), req.getCollectionName())) {
+            throw new IllegalArgumentException("tenantId/docId/collectionName 不能为空");
+        }
+        if (StringUtils.isBlank(req.getText())) {
+            throw new IllegalArgumentException("text 不能为空");
+        }
+    }
+
+    private List<String> chunk(String text) {
+        int size = Math.max(100, props.getChunkSize());
+        int overlap = Math.max(0, Math.min(props.getChunkOverlap(), size - 1));
+        int step = Math.max(1, size - overlap);
+        String t = text.trim();
+        if (t.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> out = new ArrayList<>();
+        for (int start = 0; start < t.length(); start += step) {
+            int end = Math.min(start + size, t.length());
+            out.add(t.substring(start, end));
+            if (end >= t.length()) {
+                break;
+            }
+        }
+        return out;
+    }
+
+    private List<Double> embedSingle(String input, EmbeddingProfile profile) {
+        List<List<Double>> batch = embedBatch(Collections.singletonList(input), profile);
+        return batch.isEmpty() ? Collections.emptyList() : batch.get(0);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<List<Double>> embedBatch(List<String> inputs, EmbeddingProfile profile) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setBearerAuth(profile.apiKey);
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("model", profile.model);
+        body.put("input", inputs);
+        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+        Map<String, Object> resp = restTemplate.postForObject(profile.url, entity, Map.class);
+        if (resp == null) {
+            throw new IllegalStateException("Embedding 接口无响应");
+        }
+        Object dataObj = resp.get("data");
+        if (!(dataObj instanceof List)) {
+            throw new IllegalStateException("Embedding 响应格式异常");
+        }
+        List<List<Double>> vecs = new ArrayList<>();
+        for (Object row : (List<?>) dataObj) {
+            if (!(row instanceof Map)) {
+                continue;
+            }
+            Object emb = ((Map<?, ?>) row).get("embedding");
+            if (!(emb instanceof List)) {
+                continue;
+            }
+            List<Double> v = new ArrayList<>();
+            for (Object x : (List<?>) emb) {
+                if (x instanceof Number) {
+                    v.add(((Number) x).doubleValue());
+                }
+            }
+            vecs.add(v);
+        }
+        return vecs;
+    }
+
+    private EmbeddingProfile resolveEmbeddingProfile(String scene) {
+        // 多模型配置优先:routing.scene -> routing.embeddingDefault -> 旧字段回退
+        String alias = null;
+        if (props.getRouting() != null && props.getRouting().getScenes() != null) {
+            alias = props.getRouting().getScenes().get(scene);
+        }
+        if (StringUtils.isBlank(alias) && props.getRouting() != null) {
+            alias = props.getRouting().getEmbeddingDefault();
+        }
+        if (StringUtils.isNotBlank(alias)) {
+            EmbeddingProfile p = buildProfileByAlias(alias);
+            if (p != null) {
+                return p;
+            }
+            log.warn("ai.rag.routing 指定模型别名不存在 alias={},将回退旧配置", alias);
+        }
+        return legacyEmbeddingProfile();
+    }
+
+    private EmbeddingProfile buildProfileByAlias(String alias) {
+        if (props.getModels() == null || props.getProviders() == null) {
+            return null;
+        }
+        AiRagProperties.ModelConfig modelCfg = props.getModels().get(alias);
+        if (modelCfg == null) {
+            return null;
+        }
+        AiRagProperties.ProviderConfig providerCfg = props.getProviders().get(modelCfg.getProvider());
+        if (providerCfg == null) {
+            throw new IllegalStateException("未找到 provider 配置: " + modelCfg.getProvider());
+        }
+        if (!"embedding".equalsIgnoreCase(StringUtils.defaultString(modelCfg.getType(), "embedding"))) {
+            throw new IllegalStateException("模型别名不是 embedding 类型: " + alias);
+        }
+        String baseUrl = StringUtils.trimToEmpty(providerCfg.getBaseUrl());
+        String url = normalizeEmbeddingUrl(baseUrl);
+        String apiKey = StringUtils.trimToEmpty(providerCfg.getApiKey());
+        String model = StringUtils.trimToEmpty(modelCfg.getModel());
+        if (StringUtils.isAnyBlank(url, apiKey, model)) {
+            throw new IllegalStateException("模型别名配置不完整 alias=" + alias + ",请检查 providers/models");
+        }
+        return new EmbeddingProfile(url, apiKey, model);
+    }
+
+    private EmbeddingProfile legacyEmbeddingProfile() {
+        String url = StringUtils.trimToEmpty(props.getEmbeddingUrl());
+        String apiKey = StringUtils.trimToEmpty(props.getEmbeddingApiKey());
+        String model = StringUtils.trimToEmpty(props.getEmbeddingModel());
+        if (StringUtils.isBlank(apiKey)) {
+            throw new IllegalStateException("未配置 ai.rag.embedding-api-key,或未配置可用的 ai.rag.routing/models/providers");
+        }
+        if (StringUtils.isAnyBlank(url, model)) {
+            throw new IllegalStateException("未配置 ai.rag.embedding-url / ai.rag.embedding-model");
+        }
+        return new EmbeddingProfile(normalizeEmbeddingUrl(url), apiKey, model);
+    }
+
+    private String normalizeEmbeddingUrl(String baseOrFull) {
+        String url = StringUtils.trimToEmpty(baseOrFull);
+        if (url.endsWith("/")) {
+            url = url.substring(0, url.length() - 1);
+        }
+        if (url.endsWith("/embeddings")) {
+            return url;
+        }
+        if (url.endsWith("/v1")) {
+            return url + "/embeddings";
+        }
+        return url + "/v1/embeddings";
+    }
+
+    private void postJson(String url, Map<String, Object> body) {
+        HttpHeaders h = chromaHeaders();
+        h.setContentType(MediaType.APPLICATION_JSON);
+        restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), String.class);
+    }
+
+    private Map<String, Object> postJsonReturnMap(String url, Map<String, Object> body) {
+        HttpHeaders h = chromaHeaders();
+        h.setContentType(MediaType.APPLICATION_JSON);
+        return restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h),
+                new ParameterizedTypeReference<Map<String, Object>>() {}).getBody();
+    }
+
+    private Map<String, Object> getJsonMap(String url) {
+        HttpHeaders h = chromaHeaders();
+        return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(h),
+                new ParameterizedTypeReference<Map<String, Object>>() {}).getBody();
+    }
+
+    private Object getJsonObject(String url) {
+        HttpHeaders h = chromaHeaders();
+        return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(h), Object.class).getBody();
+    }
+
+    private HttpHeaders chromaHeaders() {
+        HttpHeaders h = new HttpHeaders();
+        if (StringUtils.isNotBlank(props.getChromaToken())) {
+            h.add("x-chroma-token", props.getChromaToken());
+        }
+        return h;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> extractDocuments(Map<String, Object> queryResp) {
+        Object docs = queryResp.get("documents");
+        if (!(docs instanceof List) || ((List<?>) docs).isEmpty()) {
+            return Collections.emptyList();
+        }
+        Object first = ((List<?>) docs).get(0);
+        if (!(first instanceof List)) {
+            return Collections.emptyList();
+        }
+        List<String> out = new ArrayList<>();
+        for (Object x : (List<?>) first) {
+            if (x != null) {
+                out.add(x.toString());
+            }
+        }
+        return out;
+    }
+}

+ 182 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java

@@ -0,0 +1,182 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataScope;
+import com.fs.common.core.domain.BaseEntity;
+import com.fs.common.core.domain.entity.SysRole;
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+
+/**
+ * 数据过滤处理
+ *
+
+ */
+@Aspect
+@Component
+public class DataScopeAspect
+{
+    /**
+     * 全部数据权限
+     */
+    public static final String DATA_SCOPE_ALL = "1";
+
+    /**
+     * 自定数据权限
+     */
+    public static final String DATA_SCOPE_CUSTOM = "2";
+
+    /**
+     * 部门数据权限
+     */
+    public static final String DATA_SCOPE_DEPT = "3";
+
+    /**
+     * 部门及以下数据权限
+     */
+    public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";
+
+    /**
+     * 仅本人数据权限
+     */
+    public static final String DATA_SCOPE_SELF = "5";
+
+    /**
+     * 数据权限过滤关键字
+     */
+    public static final String DATA_SCOPE = "dataScope";
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.DataScope)")
+    public void dataScopePointCut()
+    {
+    }
+
+    @Before("dataScopePointCut()")
+    public void doBefore(JoinPoint point) throws Throwable
+    {
+        clearDataScope(point);
+        handleDataScope(point);
+    }
+
+    protected void handleDataScope(final JoinPoint joinPoint)
+    {
+        // 获得注解
+        DataScope controllerDataScope = getAnnotationLog(joinPoint);
+        if (controllerDataScope == null)
+        {
+            return;
+        }
+        // 获取当前的用户
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (StringUtils.isNotNull(loginUser))
+        {
+            SysUser currentUser = loginUser.getUser();
+            // 如果是超级管理员,则不过滤数据
+            if (StringUtils.isNotNull(currentUser) && !currentUser.isAdmin())
+            {
+                dataScopeFilter(joinPoint, currentUser, controllerDataScope.deptAlias(),
+                        controllerDataScope.userAlias());
+            }
+        }
+    }
+
+    /**
+     * 数据范围过滤
+     *
+     * @param joinPoint 切点
+     * @param user 用户
+     * @param userAlias 别名
+     */
+    public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String deptAlias, String userAlias)
+    {
+        StringBuilder sqlString = new StringBuilder();
+
+        for (SysRole role : user.getRoles())
+        {
+            String dataScope = role.getDataScope();
+            if (DATA_SCOPE_ALL.equals(dataScope))
+            {
+                sqlString = new StringBuilder();
+                break;
+            }
+            else if (DATA_SCOPE_CUSTOM.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(
+                        " OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
+                        role.getRoleId()));
+            }
+            else if (DATA_SCOPE_DEPT.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
+            }
+            else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(
+                        " OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",
+                        deptAlias, user.getDeptId(), user.getDeptId()));
+            }
+            else if (DATA_SCOPE_SELF.equals(dataScope))
+            {
+                if (StringUtils.isNotBlank(userAlias))
+                {
+                    sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
+                }
+                else
+                {
+                    // 数据权限为仅本人且没有userAlias别名不查询任何数据
+                    sqlString.append(" OR 1=0 ");
+                }
+            }
+        }
+
+        if (StringUtils.isNotBlank(sqlString.toString()))
+        {
+            Object params = joinPoint.getArgs()[0];
+            if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
+            {
+                BaseEntity baseEntity = (BaseEntity) params;
+                baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
+            }
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private DataScope getAnnotationLog(JoinPoint joinPoint)
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(DataScope.class);
+        }
+        return null;
+    }
+
+    /**
+     * 拼接权限sql前先清空params.dataScope参数防止注入
+     */
+    private void clearDataScope(final JoinPoint joinPoint)
+    {
+        Object params = joinPoint.getArgs()[0];
+        if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
+        {
+            BaseEntity baseEntity = (BaseEntity) params;
+            baseEntity.getParams().put(DATA_SCOPE, "");
+        }
+    }
+}

+ 73 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java

@@ -0,0 +1,73 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * 多数据源处理
+ * 
+
+ */
+@Aspect
+@Order(1)
+@Component
+public class DataSourceAspect
+{
+    protected Logger logger = LoggerFactory.getLogger(getClass());
+
+    @Pointcut("@annotation(com.fs.common.annotation.DataSource)"
+            + "|| @within(com.fs.common.annotation.DataSource)")
+    public void dsPointCut()
+    {
+
+    }
+
+    @Around("dsPointCut()")
+    public Object around(ProceedingJoinPoint point) throws Throwable
+    {
+        DataSource dataSource = getDataSource(point);
+
+        if (StringUtils.isNotNull(dataSource))
+        {
+            DynamicDataSourceContextHolder.setDataSourceType(dataSource.value().name());
+        }
+
+        try
+        {
+            return point.proceed();
+        }
+        finally
+        {
+            // 销毁数据源 在执行方法之后
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取需要切换的数据源
+     */
+    public DataSource getDataSource(ProceedingJoinPoint point)
+    {
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        DataSource dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
+        if (Objects.nonNull(dataSource))
+        {
+            return dataSource;
+        }
+
+        return AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
+    }
+}

+ 229 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -0,0 +1,229 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * 操作日志记录处理
+ * 
+
+ */
+@Aspect
+@Component
+public class LogAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.Log)")
+    public void logPointCut()
+    {
+    }
+
+    /**
+     * 处理完请求后执行
+     *
+     * @param joinPoint 切点
+     */
+    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
+    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult)
+    {
+        handleLog(joinPoint, null, jsonResult);
+    }
+
+    /**
+     * 拦截异常操作
+     * 
+     * @param joinPoint 切点
+     * @param e 异常
+     */
+    @AfterThrowing(value = "logPointCut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e)
+    {
+        handleLog(joinPoint, e, null);
+    }
+
+    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult)
+    {
+        try
+        {
+            // 获得注解
+            Log controllerLog = getAnnotationLog(joinPoint);
+            if (controllerLog == null)
+            {
+                return;
+            }
+
+            // 获取当前的用户
+            LoginUser loginUser = SecurityUtils.getLoginUser();
+
+            int status = BusinessStatus.SUCCESS.ordinal();
+            // 请求的地址
+            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+            String operUrl = ServletUtils.getRequest().getRequestURI();
+            String operName = loginUser == null ? "anonymous" : loginUser.getUsername();
+            String json = JSON.toJSONString(jsonResult);
+            if (loginUser != null)
+            {
+                operName = loginUser.getUsername();
+            }
+
+            if (e != null)
+            {
+                status = BusinessStatus.FAIL.ordinal();
+            }
+            // 设置方法名称
+            String className = joinPoint.getTarget().getClass().getName();
+            String methodName = joinPoint.getSignature().getName();
+            String method = className + "." + methodName + "()";
+            // 设置请求方式
+            String requestMethod = ServletUtils.getRequest().getMethod();
+            // 处理设置注解上的参数
+            String operParam = buildRequestValue(joinPoint, requestMethod, controllerLog.isSaveRequestData());
+            log.info("opLog title={}, type={}, operatorType={}, status={}, user={}, ip={}, url={}, method={}, requestMethod={}, params={}, result={}",
+                    controllerLog.title(),
+                    controllerLog.businessType().ordinal(),
+                    controllerLog.operatorType().ordinal(),
+                    status,
+                    operName,
+                    ip,
+                    operUrl,
+                    method,
+                    requestMethod,
+                    operParam,
+                    StringUtils.substring(json, 0, 2000));
+            if (e != null) {
+                log.error("操作异常: {}", StringUtils.substring(e.getMessage(), 0, 2000));
+            }
+        }
+        catch (Exception exp)
+        {
+            // 记录本地异常日志
+            log.error("==前置通知异常==");
+            log.error("异常信息:{}", exp.getMessage());
+            exp.printStackTrace();
+        }
+    }
+
+    /**
+     * 获取注解中对方法的描述信息 用于Controller层注解
+     * 
+     * @param log 日志
+     * @param operLog 操作日志
+     * @throws Exception
+     */
+    public String buildRequestValue(JoinPoint joinPoint, String requestMethod, boolean saveRequestData) throws Exception
+    {
+        if (!saveRequestData) {
+            return "";
+        }
+        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
+        {
+            String params = argsArrayToString(joinPoint.getArgs());
+            return StringUtils.substring(params, 0, 2000);
+        }
+        Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
+        if (paramsMap == null) {
+            return "";
+        }
+        return StringUtils.substring(paramsMap.toString(), 0, 2000);
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private Log getAnnotationLog(JoinPoint joinPoint) throws Exception
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(Log.class);
+        }
+        return null;
+    }
+
+    /**
+     * 参数拼装
+     */
+    private String argsArrayToString(Object[] paramsArray)
+    {
+        String params = "";
+        if (paramsArray != null && paramsArray.length > 0)
+        {
+            for (int i = 0; i < paramsArray.length; i++)
+            {
+                if (StringUtils.isNotNull(paramsArray[i]) && !isFilterObject(paramsArray[i]))
+                {
+                    Object jsonObj = JSON.toJSON(paramsArray[i]);
+                    params += jsonObj.toString() + " ";
+                }
+            }
+        }
+        return params.trim();
+    }
+
+    /**
+     * 判断是否需要过滤的对象。
+     * 
+     * @param o 对象信息。
+     * @return 如果是需要过滤的对象,则返回true;否则返回false。
+     */
+    @SuppressWarnings("rawtypes")
+    public boolean isFilterObject(final Object o)
+    {
+        Class<?> clazz = o.getClass();
+        if (clazz.isArray())
+        {
+            return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
+        }
+        else if (Collection.class.isAssignableFrom(clazz))
+        {
+            Collection collection = (Collection) o;
+            for (Iterator iter = collection.iterator(); iter.hasNext();)
+            {
+                return iter.next() instanceof MultipartFile;
+            }
+        }
+        else if (Map.class.isAssignableFrom(clazz))
+        {
+            Map map = (Map) o;
+            for (Iterator iter = map.entrySet().iterator(); iter.hasNext();)
+            {
+                Map.Entry entry = (Map.Entry) iter.next();
+                return entry.getValue() instanceof MultipartFile;
+            }
+        }
+        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
+                || o instanceof BindingResult;
+    }
+}

+ 117 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java

@@ -0,0 +1,117 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.RateLimiter;
+import com.fs.common.enums.LimitType;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 限流处理
+ *
+
+ */
+@Aspect
+@Component
+public class RateLimiterAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);
+
+    private RedisTemplate<Object, Object> redisTemplate;
+
+    private RedisScript<Long> limitScript;
+
+    @Autowired
+    public void setRedisTemplate1(RedisTemplate<Object, Object> redisTemplate)
+    {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Autowired
+    public void setLimitScript(RedisScript<Long> limitScript)
+    {
+        this.limitScript = limitScript;
+    }
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.RateLimiter)")
+    public void rateLimiterPointCut()
+    {
+    }
+
+    @Before("rateLimiterPointCut()")
+    public void doBefore(JoinPoint point) throws Throwable
+    {
+        RateLimiter rateLimiter = getAnnotationRateLimiter(point);
+        String key = rateLimiter.key();
+        int time = rateLimiter.time();
+        int count = rateLimiter.count();
+
+        String combineKey = getCombineKey(rateLimiter, point);
+        List<Object> keys = Collections.singletonList(combineKey);
+        try
+        {
+            Long number = redisTemplate.execute(limitScript, keys, count, time);
+            if (StringUtils.isNull(number) || number.intValue() > count)
+            {
+                throw new ServiceException("访问过于频繁,请稍后再试");
+            }
+            log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), key);
+        }
+        catch (ServiceException e)
+        {
+            throw e;
+        }
+        catch (Exception e)
+        {
+            throw new RuntimeException("服务器限流异常,请稍后再试");
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private RateLimiter getAnnotationRateLimiter(JoinPoint joinPoint)
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(RateLimiter.class);
+        }
+        return null;
+    }
+
+    public String getCombineKey(RateLimiter rateLimiter, JoinPoint point)
+    {
+        StringBuffer stringBuffer = new StringBuffer(rateLimiter.key());
+        if (rateLimiter.limitType() == LimitType.IP)
+        {
+            stringBuffer.append(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        }
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        Method method = signature.getMethod();
+        Class<?> targetClass = method.getDeclaringClass();
+        stringBuffer.append("-").append(targetClass.getName()).append("- ").append(method.getName());
+        return stringBuffer.toString();
+    }
+}

+ 58 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java

@@ -0,0 +1,58 @@
+package com.fs.framework.aspectj.lock;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 分布式锁注解
+ *
+ * @author Hollis
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface DistributeLock {
+
+    /**
+     * 锁的场景
+     *
+     * @return
+     */
+    public String scene();
+
+    /**
+     * 加锁的key,优先取key(),如果没有,则取keyExpression()
+     *
+     * @return
+     */
+    public String key() default DistributeLockConstant.NONE_KEY;
+
+    /**
+     * SPEL表达式:
+     * <pre>
+     *     #id
+     *     #insertResult.id
+     * </pre>
+     *
+     * @return
+     */
+    public String keyExpression() default DistributeLockConstant.NONE_KEY;
+
+    /**
+     * 超时时间,毫秒
+     * 默认情况下不设置超时时间,会自动续期
+     *
+     * @return
+     */
+    public int expireTime() default DistributeLockConstant.DEFAULT_EXPIRE_TIME;
+
+    public String errorMsg() default DistributeLockConstant.ERROR_MSG;
+
+    /**
+     * 加锁等待时长,毫秒
+     * 默认情况下不设置等待时长,会一直等待直到获取到锁
+     * @return
+     */
+    public int waitTime() default DistributeLockConstant.DEFAULT_WAIT_TIME;
+}

+ 113 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java

@@ -0,0 +1,113 @@
+package com.fs.framework.aspectj.lock;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.StandardReflectionParameterNameDiscoverer;
+import org.springframework.core.annotation.Order;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.concurrent.TimeUnit;
+
+@Aspect
+@Component
+@Order(Integer.MIN_VALUE + 1)
+public class DistributeLockAspect {
+
+    private RedissonClient redissonClient;
+
+    public DistributeLockAspect(RedissonClient redissonClient) {
+        this.redissonClient = redissonClient;
+    }
+
+    private static final Logger LOG = LoggerFactory.getLogger(DistributeLockAspect.class);
+
+    @Around("@annotation(com.fs.framework.aspectj.lock.DistributeLock)")
+    public Object process(ProceedingJoinPoint pjp) throws Throwable {
+        Object response = null;
+        Method method = ((MethodSignature) pjp.getSignature()).getMethod();
+        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);
+
+        String key = distributeLock.key();
+        if (DistributeLockConstant.NONE_KEY.equals(key)) {
+            if (DistributeLockConstant.NONE_KEY.equals(distributeLock.keyExpression())) {
+                throw new DistributeLockException("no lock key found...");
+            }
+            SpelExpressionParser parser = new SpelExpressionParser();
+            Expression expression = parser.parseExpression(distributeLock.keyExpression());
+
+            EvaluationContext context = new StandardEvaluationContext();
+            // 获取参数值
+            Object[] args = pjp.getArgs();
+
+            // 获取运行时参数的名称
+            StandardReflectionParameterNameDiscoverer discoverer
+                    = new StandardReflectionParameterNameDiscoverer();
+            String[] parameterNames = discoverer.getParameterNames(method);
+
+            // 将参数绑定到context中
+            if (parameterNames != null) {
+                for (int i = 0; i < parameterNames.length; i++) {
+                    context.setVariable(parameterNames[i], args[i]);
+                }
+            }
+
+            // 解析表达式,获取结果
+            key = String.valueOf(expression.getValue(context));
+        }
+
+        String scene = distributeLock.scene();
+
+        String lockKey = scene + "#" + key;
+
+        int expireTime = distributeLock.expireTime();
+        int waitTime = distributeLock.waitTime();
+        RLock rLock= redissonClient.getLock(lockKey);
+        try {
+            boolean lockResult = false;
+            if (waitTime == DistributeLockConstant.DEFAULT_WAIT_TIME) {
+                if (expireTime == DistributeLockConstant.DEFAULT_EXPIRE_TIME) {
+//                    LOG.info(String.format("lock for key : %s", lockKey));
+                    rLock.lock();
+                } else {
+//                    LOG.info(String.format("lock for key : %s , expire : %s", lockKey, expireTime));
+                    rLock.lock(expireTime, TimeUnit.MILLISECONDS);
+                }
+                lockResult = true;
+            } else {
+                if (expireTime == DistributeLockConstant.DEFAULT_EXPIRE_TIME) {
+//                    LOG.info(String.format("try lock for key : %s , wait : %s", lockKey, waitTime));
+                    lockResult = rLock.tryLock(waitTime, TimeUnit.MILLISECONDS);
+                } else {
+//                    LOG.info(String.format("try lock for key : %s , expire : %s , wait : %s", lockKey, expireTime, waitTime));
+                    lockResult = rLock.tryLock(waitTime, expireTime, TimeUnit.MILLISECONDS);
+                }
+            }
+
+            if (!lockResult) {
+//                LOG.warn(String.format("lock failed for key : %s , expire : %s", lockKey, expireTime));
+                throw new DistributeLockException(distributeLock.errorMsg());
+            }
+
+
+//            LOG.info(String.format("lock success for key : %s , expire : %s", lockKey, expireTime));
+            response = pjp.proceed();
+        }  finally {
+            if (rLock.isHeldByCurrentThread()) {
+                rLock.unlock();
+//                LOG.info(String.format("unlock for key : %s , expire : %s", lockKey, expireTime));
+            }
+        }
+        return response;
+    }
+}

+ 13 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java

@@ -0,0 +1,13 @@
+package com.fs.framework.aspectj.lock;
+
+public class DistributeLockConstant {
+
+    public static final String NONE_KEY = "NONE";
+
+    public static final String DEFAULT_OWNER = "DEFAULT";
+
+    public static final int DEFAULT_EXPIRE_TIME = -1;
+
+    public static final int DEFAULT_WAIT_TIME = Integer.MAX_VALUE;
+    public static final String ERROR_MSG  = "请勿重复操作";
+}

+ 24 - 0
fs-ai-api/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java

@@ -0,0 +1,24 @@
+package com.fs.framework.aspectj.lock;
+
+
+public class DistributeLockException extends RuntimeException {
+
+    public DistributeLockException() {
+    }
+
+    public DistributeLockException(String message) {
+        super(message);
+    }
+
+    public DistributeLockException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public DistributeLockException(Throwable cause) {
+        super(cause);
+    }
+
+    public DistributeLockException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
+        super(message, cause, enableSuppression, writableStackTrace);
+    }
+}

+ 31 - 0
fs-ai-api/src/main/java/com/fs/framework/config/ApplicationConfig.java

@@ -0,0 +1,31 @@
+package com.fs.framework.config;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+
+import java.util.TimeZone;
+
+/**
+ * 程序注解配置
+ *
+
+ */
+@Configuration
+// 表示通过aop框架暴露该代理对象,AopContext能够访问
+@EnableAspectJAutoProxy(exposeProxy = true)
+// 指定要扫描的Mapper类的包的路径
+@MapperScan("com.fs.**.mapper")
+public class ApplicationConfig
+{
+    /**
+     * 时区配置
+     */
+    @Bean
+    public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization()
+    {
+        return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault());
+    }
+}

+ 96 - 0
fs-ai-api/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -0,0 +1,96 @@
+package com.fs.framework.config;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+import com.alibaba.druid.util.Utils;
+import com.fs.common.enums.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+import javax.servlet.*;
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class DataSourceConfig {
+
+    
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.master")
+    public DataSource masterDataSource() {
+        return new DruidDataSource();
+    }
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.slave")
+    public DataSource slaveDataSource() {
+        return new DruidDataSource();
+    }
+
+
+
+    @Bean
+    @Primary
+    public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("slaveDataSource") DataSource slaveDataSource) {
+        Map<Object, Object> targetDataSources = new HashMap<>();
+        // 使用字符串 key(MASTER/SLAVE),与 DynamicDataSourceContextHolder 中 setDataSourceType 保持一致
+        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
+        targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
+        
+        return new DynamicDataSource(masterDataSource, targetDataSources);
+    }
+
+    /**
+     * 去除监控页面底部的广告
+     */
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    @ConditionalOnProperty(name = "spring.datasource.mysql.druid.statViewServlet.enabled", havingValue = "true")
+    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties)
+    {
+        // 获取web监控页面的参数
+        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
+        // 提取common.js的配置路径
+        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
+        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
+        final String filePath = "support/http/resources/js/common.js";
+        // 创建filter进行过滤
+        Filter filter = new Filter()
+        {
+            @Override
+            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException
+            {
+            }
+            @Override
+            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+                    throws IOException, ServletException
+            {
+                chain.doFilter(request, response);
+                // 重置缓冲区,响应头不会被重置
+                response.resetBuffer();
+                // 获取common.js
+                String text = Utils.readFromResource(filePath);
+                // 正则替换banner, 除去底部的广告信息
+                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
+                text = text.replaceAll("powered.*?shrek.wang</a>", "");
+                response.getWriter().write(text);
+            }
+            @Override
+            public void destroy()
+            {
+            }
+        };
+        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
+        registrationBean.setFilter(filter);
+        registrationBean.addUrlPatterns(commonJsPattern);
+        return registrationBean;
+    }
+}

+ 123 - 0
fs-ai-api/src/main/java/com/fs/framework/config/DruidConfig.java

@@ -0,0 +1,123 @@
+package com.fs.framework.config;//package com.fs.framework.config;
+//
+//import java.io.IOException;
+//import java.util.HashMap;
+//import java.util.Map;
+//import javax.servlet.Filter;
+//import javax.servlet.FilterChain;
+//import javax.servlet.ServletException;
+//import javax.servlet.ServletRequest;
+//import javax.servlet.ServletResponse;
+//import javax.sql.DataSource;
+//
+//import org.springframework.beans.factory.annotation.Qualifier;
+//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+//import org.springframework.boot.context.properties.ConfigurationProperties;
+//import org.springframework.boot.web.servlet.FilterRegistrationBean;
+//import org.springframework.context.annotation.Bean;
+//import org.springframework.context.annotation.Configuration;
+//import org.springframework.context.annotation.Primary;
+//import com.alibaba.druid.pool.DruidDataSource;
+//import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
+//import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+//import com.alibaba.druid.util.Utils;
+//import com.fs.common.enums.DataSourceType;
+//import com.fs.common.utils.spring.SpringUtils;
+//import com.fs.framework.config.properties.DruidProperties;
+//import com.fs.framework.datasource.DynamicDataSource;
+//
+///**
+// * druid 配置多数据源
+// *
+//
+// */
+//@Configuration
+//public class DruidConfig
+//{
+//    @Bean
+//    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.master")
+//    public DataSource sopDataSource() {
+//        return new DruidDataSource();
+//    }
+//
+//    @Bean
+//    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.master")
+//    public DataSource masterDataSource() {
+//        return new DruidDataSource();
+//    }
+//
+//
+//
+//    @Bean
+//    @Primary
+//    public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource) {
+//        Map<Object, Object> targetDataSources = new HashMap<>();
+//        
+//        return new DynamicDataSource(masterDataSource, targetDataSources);
+//    }
+//
+//    /**
+//     * 设置数据源
+//     *
+//     * @param targetDataSources 备选数据源集合
+//     * @param sourceName 数据源名称
+//     * @param beanName bean名称
+//     */
+//    public void setDataSource(Map<Object, Object> targetDataSources, String sourceName, String beanName)
+//    {
+//        try
+//        {
+//            DataSource dataSource = SpringUtils.getBean(beanName);
+//            targetDataSources.put(sourceName, dataSource);
+//        }
+//        catch (Exception e)
+//        {
+//        }
+//    }
+//
+//    /**
+//     * 去除监控页面底部的广告
+//     */
+//    @SuppressWarnings({ "rawtypes", "unchecked" })
+//    @Bean
+//    @ConditionalOnProperty(name = "spring.datasource.mysql.druid.statViewServlet.enabled", havingValue = "true")
+//    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties)
+//    {
+//        // 获取web监控页面的参数
+//        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
+//        // 提取common.js的配置路径
+//        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
+//        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
+//        final String filePath = "support/http/resources/js/common.js";
+//        // 创建filter进行过滤
+//        Filter filter = new Filter()
+//        {
+//            @Override
+//            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException
+//            {
+//            }
+//            @Override
+//            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+//                    throws IOException, ServletException
+//            {
+//                chain.doFilter(request, response);
+//                // 重置缓冲区,响应头不会被重置
+//                response.resetBuffer();
+//                // 获取common.js
+//                String text = Utils.readFromResource(filePath);
+//                // 正则替换banner, 除去底部的广告信息
+//                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
+//                text = text.replaceAll("powered.*?shrek.wang</a>", "");
+//                response.getWriter().write(text);
+//            }
+//            @Override
+//            public void destroy()
+//            {
+//            }
+//        };
+//        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
+//        registrationBean.setFilter(filter);
+//        registrationBean.addUrlPatterns(commonJsPattern);
+//        return registrationBean;
+//    }
+//}

+ 72 - 0
fs-ai-api/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -0,0 +1,72 @@
+package com.fs.framework.config;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.ParserConfig;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.data.redis.serializer.SerializationException;
+import org.springframework.util.Assert;
+
+import java.nio.charset.Charset;
+
+/**
+ * Redis使用FastJson序列化
+ * 
+
+ */
+public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
+{
+    @SuppressWarnings("unused")
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
+
+    private Class<T> clazz;
+
+    static
+    {
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+    }
+
+    public FastJson2JsonRedisSerializer(Class<T> clazz)
+    {
+        super();
+        this.clazz = clazz;
+    }
+
+    @Override
+    public byte[] serialize(T t) throws SerializationException
+    {
+        if (t == null)
+        {
+            return new byte[0];
+        }
+        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
+    }
+
+    @Override
+    public T deserialize(byte[] bytes) throws SerializationException
+    {
+        if (bytes == null || bytes.length <= 0)
+        {
+            return null;
+        }
+        String str = new String(bytes, DEFAULT_CHARSET);
+
+        return JSON.parseObject(str, clazz);
+    }
+
+    public void setObjectMapper(ObjectMapper objectMapper)
+    {
+        Assert.notNull(objectMapper, "'objectMapper' must not be null");
+        this.objectMapper = objectMapper;
+    }
+
+    protected JavaType getJavaType(Class<?> clazz)
+    {
+        return TypeFactory.defaultInstance().constructType(clazz);
+    }
+}

+ 59 - 0
fs-ai-api/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -0,0 +1,59 @@
+package com.fs.framework.config;
+
+import com.fs.common.filter.RepeatableFilter;
+import com.fs.common.filter.XssFilter;
+import com.fs.common.utils.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.servlet.DispatcherType;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Filter配置
+ *
+
+ */
+@Configuration
+@ConditionalOnProperty(value = "xss.enabled", havingValue = "true")
+public class FilterConfig
+{
+    @Value("${xss.excludes}")
+    private String excludes;
+
+    @Value("${xss.urlPatterns}")
+    private String urlPatterns;
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    public FilterRegistrationBean xssFilterRegistration()
+    {
+        FilterRegistrationBean registration = new FilterRegistrationBean();
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setFilter(new XssFilter());
+        registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
+        registration.setName("xssFilter");
+        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        Map<String, String> initParameters = new HashMap<String, String>();
+        initParameters.put("excludes", excludes);
+        registration.setInitParameters(initParameters);
+        return registration;
+    }
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    public FilterRegistrationBean someFilterRegistration()
+    {
+        FilterRegistrationBean registration = new FilterRegistrationBean();
+        registration.setFilter(new RepeatableFilter());
+        registration.addUrlPatterns("/*");
+        registration.setName("repeatableFilter");
+        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        return registration;
+    }
+
+}

+ 149 - 0
fs-ai-api/src/main/java/com/fs/framework/config/MyBatisConfig.java

@@ -0,0 +1,149 @@
+package com.fs.framework.config;
+
+import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
+import org.apache.ibatis.io.VFS;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.boot.autoconfigure.SpringBootVFS;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReader;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.util.ClassUtils;
+
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Mybatis支持*匹配扫描包
+ *
+
+ */
+@Configuration
+public class MyBatisConfig
+{
+    @Autowired
+    private Environment env;
+
+    static final String DEFAULT_RESOURCE_PATTERN = "**/*.class";
+
+    public static String setTypeAliasesPackage(String typeAliasesPackage)
+    {
+        ResourcePatternResolver resolver = (ResourcePatternResolver) new PathMatchingResourcePatternResolver();
+        MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+        List<String> allResult = new ArrayList<String>();
+        try
+        {
+            for (String aliasesPackage : typeAliasesPackage.split(","))
+            {
+                List<String> result = new ArrayList<String>();
+                aliasesPackage = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX
+                        + ClassUtils.convertClassNameToResourcePath(aliasesPackage.trim()) + "/" + DEFAULT_RESOURCE_PATTERN;
+                Resource[] resources = resolver.getResources(aliasesPackage);
+                if (resources != null && resources.length > 0)
+                {
+                    MetadataReader metadataReader = null;
+                    for (Resource resource : resources)
+                    {
+                        if (resource.isReadable())
+                        {
+                            metadataReader = metadataReaderFactory.getMetadataReader(resource);
+                            try
+                            {
+                                result.add(Class.forName(metadataReader.getClassMetadata().getClassName()).getPackage().getName());
+                            }
+                            catch (ClassNotFoundException e)
+                            {
+                                e.printStackTrace();
+                            }
+                        }
+                    }
+                }
+                if (result.size() > 0)
+                {
+                    HashSet<String> hashResult = new HashSet<String>(result);
+                    allResult.addAll(hashResult);
+                }
+            }
+            if (allResult.size() > 0)
+            {
+                typeAliasesPackage = String.join(",", (String[]) allResult.toArray(new String[0]));
+            }
+            else
+            {
+                throw new RuntimeException("mybatis typeAliasesPackage 路径扫描错误,参数typeAliasesPackage:" + typeAliasesPackage + "未找到任何包");
+            }
+        }
+        catch (IOException e)
+        {
+            e.printStackTrace();
+        }
+        return typeAliasesPackage;
+    }
+
+    public Resource[] resolveMapperLocations(String[] mapperLocations)
+    {
+        ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
+        List<Resource> resources = new ArrayList<Resource>();
+        if (mapperLocations != null)
+        {
+            for (String mapperLocation : mapperLocations)
+            {
+                try
+                {
+                    Resource[] mappers = resourceResolver.getResources(mapperLocation);
+                    resources.addAll(Arrays.asList(mappers));
+                }
+                catch (IOException e)
+                {
+                    // ignore
+                }
+            }
+        }
+        return resources.toArray(new Resource[resources.size()]);
+    }
+
+//    @Bean
+//    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception
+//    {
+//        String typeAliasesPackage = env.getProperty("mybatis.typeAliasesPackage");
+//        String mapperLocations = env.getProperty("mybatis.mapperLocations");
+//        String configLocation = env.getProperty("mybatis.configLocation");
+//        typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage);
+//        VFS.addImplClass(SpringBootVFS.class);
+//
+//        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
+//        sessionFactory.setDataSource(dataSource);
+//        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
+//        sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ",")));
+//        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
+//        return sessionFactory.getObject();
+//    }
+
+    @Bean
+    public SqlSessionFactory sqlSessionFactorys(DataSource dataSource) throws Exception
+    {
+        String typeAliasesPackage = env.getProperty("mybatis-plus.typeAliasesPackage");
+        String mapperLocations = env.getProperty("mybatis-plus.mapperLocations");
+        String configLocation = env.getProperty("mybatis-plus.configLocation");
+        typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage);
+        VFS.addImplClass(SpringBootVFS.class);
+
+        final MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
+        sessionFactory.setDataSource(dataSource);
+        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
+        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
+        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
+        return sessionFactory.getObject();
+    }
+}

+ 65 - 0
fs-ai-api/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -0,0 +1,65 @@
+package com.fs.framework.config;
+
+import com.fs.common.config.FSConfig;
+import com.fs.common.constant.Constants;
+import com.fs.framework.interceptor.RepeatSubmitInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 通用配置
+ * 
+
+ */
+@Configuration
+public class ResourcesConfig implements WebMvcConfigurer
+{
+    @Autowired
+    private RepeatSubmitInterceptor repeatSubmitInterceptor;
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry)
+    {
+        /** 本地文件上传路径 */
+        registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**").addResourceLocations("file:" + FSConfig.getProfile() + "/");
+
+        /** swagger配置 */
+        registry.addResourceHandler("/swagger-ui/**").addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/");
+    }
+
+    /**
+     * 自定义拦截规则
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry)
+    {
+        registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
+    }
+
+    /**
+     * 跨域配置
+     */
+    @Bean
+    public CorsFilter corsFilter()
+    {
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        CorsConfiguration config = new CorsConfiguration();
+        config.setAllowCredentials(true);
+        // 设置访问源地址
+        config.addAllowedOrigin("*");
+        // 设置访问源请求头
+        config.addAllowedHeader("*");
+        // 设置访问源请求方法
+        config.addAllowedMethod("*");
+        // 对接口配置跨域设置
+        source.registerCorsConfiguration("/**", config);
+        return new CorsFilter(source);
+    }
+}

+ 59 - 0
fs-ai-api/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,59 @@
+package com.fs.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.BeanIds;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.web.firewall.StrictHttpFirewall;
+
+/**
+ * spring security配置
+ *
+
+ */
+@Configuration
+@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
+public class SecurityConfig extends WebSecurityConfigurerAdapter
+{
+
+    /**
+     * anyRequest          |   匹配所有请求路径
+     * access              |   SpringEl表达式结果为true时可以访问
+     * anonymous           |   匿名可以访问
+     * denyAll             |   用户不能访问
+     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
+     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
+     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
+     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
+     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
+     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
+     * permitAll           |   用户可以任意访问
+     * rememberMe          |   允许通过remember-me登录的用户访问
+     * authenticated       |   用户登录后可访问
+     */
+    @Override
+    protected void configure(HttpSecurity http) throws Exception
+    {
+        http.authorizeRequests()
+                .antMatchers("/**").permitAll()
+                .anyRequest().authenticated()
+                .and().csrf().disable();
+    }
+
+    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception {
+        return super.authenticationManagerBean();
+    }
+    @Bean
+    public StrictHttpFirewall httpFirewall() {
+        StrictHttpFirewall firewall = new StrictHttpFirewall();
+        // 允许URL编码的双斜杠
+        firewall.setAllowUrlEncodedDoubleSlash(true);
+        return firewall;
+    }
+
+}

+ 33 - 0
fs-ai-api/src/main/java/com/fs/framework/config/ServerConfig.java

@@ -0,0 +1,33 @@
+package com.fs.framework.config;
+
+import com.fs.common.utils.ServletUtils;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 服务相关配置
+ * 
+
+ */
+@Component
+public class ServerConfig
+{
+    /**
+     * 获取完整的请求路径,包括:域名,端口,上下文访问路径
+     * 
+     * @return 服务地址
+     */
+    public String getUrl()
+    {
+        HttpServletRequest request = ServletUtils.getRequest();
+        return getDomain(request);
+    }
+
+    public static String getDomain(HttpServletRequest request)
+    {
+        StringBuffer url = request.getRequestURL();
+        String contextPath = request.getServletContext().getContextPath();
+        return url.delete(url.length() - request.getRequestURI().length(), url.length()).append(contextPath).toString();
+    }
+}

+ 124 - 0
fs-ai-api/src/main/java/com/fs/framework/config/SwaggerConfig.java

@@ -0,0 +1,124 @@
+package com.fs.framework.config;
+
+import com.fs.common.config.FSConfig;
+import com.github.xiaoymin.swaggerbootstrapui.annotations.EnableSwaggerBootstrapUI;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import springfox.documentation.builders.ApiInfoBuilder;
+import springfox.documentation.builders.PathSelectors;
+import springfox.documentation.builders.RequestHandlerSelectors;
+import springfox.documentation.service.*;
+import springfox.documentation.spi.DocumentationType;
+import springfox.documentation.spi.service.contexts.SecurityContext;
+import springfox.documentation.spring.web.plugins.Docket;
+import springfox.documentation.swagger2.annotations.EnableSwagger2;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Swagger2的接口配置
+ *
+
+ */
+@Configuration
+@EnableSwagger2
+@EnableSwaggerBootstrapUI
+public class SwaggerConfig
+{
+    /** 系统基础配置 */
+    @Autowired
+    private FSConfig fsConfig;
+
+    /** 是否开启swagger */
+    @Value("${swagger.enabled}")
+    private boolean enabled;
+
+    /** 设置请求的统一前缀 */
+    @Value("${swagger.pathMapping}")
+    private String pathMapping;
+
+    /**
+     * 创建API
+     */
+    @Bean
+    public Docket createRestApi()
+    {
+        return new Docket(DocumentationType.SWAGGER_2)
+                // 是否启用Swagger
+                .enable(enabled)
+                // 用来创建该API的基本信息,展示在文档的页面中(自定义展示的信息)
+                .apiInfo(apiInfo())
+                // 设置哪些接口暴露给Swagger展示
+                .select()
+                // 扫描所有有注解的api,用这种方式更灵活
+                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
+                // 扫描指定包中的swagger注解
+                // .apis(RequestHandlerSelectors.basePackage("com.fs.project.tool.swagger"))
+                // 扫描所有 .apis(RequestHandlerSelectors.any())
+                .paths(PathSelectors.any())
+                .build()
+                /* 设置安全模式,swagger可以设置访问token */
+                .securitySchemes(securitySchemes())
+                .securityContexts(securityContexts())
+                .pathMapping(pathMapping);
+    }
+
+    /**
+     * 安全模式,这里指定token通过Authorization头请求头传递
+     */
+    private List<ApiKey> securitySchemes()
+    {
+        List<ApiKey> apiKeyList = new ArrayList<ApiKey>();
+        apiKeyList.add(new ApiKey("Authorization", "AppToken", "header"));
+        return apiKeyList;
+    }
+
+    /**
+     * 安全上下文
+     */
+    private List<SecurityContext> securityContexts()
+    {
+        List<SecurityContext> securityContexts = new ArrayList<>();
+        securityContexts.add(
+                SecurityContext.builder()
+                        .securityReferences(defaultAuth())
+                        .forPaths(PathSelectors.regex("^(?!auth).*$"))
+                        .build());
+        return securityContexts;
+    }
+
+    /**
+     * 默认的安全上引用
+     */
+    private List<SecurityReference> defaultAuth()
+    {
+        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
+        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
+        authorizationScopes[0] = authorizationScope;
+        List<SecurityReference> securityReferences = new ArrayList<>();
+        securityReferences.add(new SecurityReference("Authorization", authorizationScopes));
+        return securityReferences;
+    }
+
+    /**
+     * 添加摘要信息
+     */
+    private ApiInfo apiInfo()
+    {
+        // 用ApiInfoBuilder进行定制
+        return new ApiInfoBuilder()
+                // 设置标题
+                .title("标题:FS管理系统_接口文档")
+                // 描述
+                .description("描述:用于管理集团旗下公司的人员信息,具体包括XXX,XXX模块...")
+                // 作者信息
+                .contact(new Contact(fsConfig.getName(), null, null))
+                // 版本
+                .version("版本号:" + fsConfig.getVersion())
+                .build();
+    }
+}

+ 63 - 0
fs-ai-api/src/main/java/com/fs/framework/config/ThreadPoolConfig.java

@@ -0,0 +1,63 @@
+package com.fs.framework.config;
+
+import com.fs.common.utils.Threads;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 线程池配置
+ *
+
+ **/
+@Configuration
+public class ThreadPoolConfig
+{
+    // 核心线程池大小
+    private int corePoolSize = 50;
+
+    // 最大可创建的线程数
+    private int maxPoolSize = 200;
+
+    // 队列最大长度
+    private int queueCapacity = 1000;
+
+    // 线程池维护线程所允许的空闲时间
+    private int keepAliveSeconds = 300;
+
+    @Bean(name = "threadPoolTaskExecutor")
+    public ThreadPoolTaskExecutor threadPoolTaskExecutor()
+    {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setCorePoolSize(corePoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(keepAliveSeconds);
+        // 线程池对拒绝任务(无线程可用)的处理策略
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        return executor;
+    }
+
+    /**
+     * 执行周期性或定时任务
+     */
+    @Bean(name = "scheduledExecutorService")
+    protected ScheduledExecutorService scheduledExecutorService()
+    {
+        return new ScheduledThreadPoolExecutor(corePoolSize,
+                new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build())
+        {
+            @Override
+            protected void afterExecute(Runnable r, Throwable t)
+            {
+                super.afterExecute(r, t);
+                Threads.printException(r, t);
+            }
+        };
+    }
+}

+ 77 - 0
fs-ai-api/src/main/java/com/fs/framework/config/properties/DruidProperties.java

@@ -0,0 +1,77 @@
+package com.fs.framework.config.properties;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * druid 配置属性
+ *
+
+ */
+@Configuration
+public class DruidProperties
+{
+    @Value("${spring.datasource.mysql.druid.initialSize}")
+    private int initialSize;
+
+    @Value("${spring.datasource.mysql.druid.minIdle}")
+    private int minIdle;
+
+    @Value("${spring.datasource.mysql.druid.maxActive}")
+    private int maxActive;
+
+    @Value("${spring.datasource.mysql.druid.maxWait}")
+    private int maxWait;
+
+    @Value("${spring.datasource.mysql.druid.timeBetweenEvictionRunsMillis}")
+    private int timeBetweenEvictionRunsMillis;
+
+    @Value("${spring.datasource.mysql.druid.minEvictableIdleTimeMillis}")
+    private int minEvictableIdleTimeMillis;
+
+    @Value("${spring.datasource.mysql.druid.maxEvictableIdleTimeMillis}")
+    private int maxEvictableIdleTimeMillis;
+
+    @Value("${spring.datasource.mysql.druid.validationQuery}")
+    private String validationQuery;
+
+    @Value("${spring.datasource.mysql.druid.testWhileIdle}")
+    private boolean testWhileIdle;
+
+    @Value("${spring.datasource.mysql.druid.testOnBorrow}")
+    private boolean testOnBorrow;
+
+    @Value("${spring.datasource.mysql.druid.testOnReturn}")
+    private boolean testOnReturn;
+
+    public DruidDataSource dataSource(DruidDataSource datasource)
+    {
+        /** 配置初始化大小、最小、最大 */
+        datasource.setInitialSize(initialSize);
+        datasource.setMaxActive(maxActive);
+        datasource.setMinIdle(minIdle);
+
+        /** 配置获取连接等待超时的时间 */
+        datasource.setMaxWait(maxWait);
+
+        /** 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 */
+        datasource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
+
+        /** 配置一个连接在池中最小、最大生存的时间,单位是毫秒 */
+        datasource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
+        datasource.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);
+
+        /**
+         * 用来检测连接是否有效的sql,要求是一个查询语句,常用select 'x'。如果validationQuery为null,testOnBorrow、testOnReturn、testWhileIdle都不会起作用。
+         */
+        datasource.setValidationQuery(validationQuery);
+        /** 建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。 */
+        datasource.setTestWhileIdle(testWhileIdle);
+        /** 申请连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。 */
+        datasource.setTestOnBorrow(testOnBorrow);
+        /** 归还连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。 */
+        datasource.setTestOnReturn(testOnReturn);
+        return datasource;
+    }
+}

+ 27 - 0
fs-ai-api/src/main/java/com/fs/framework/datasource/DynamicDataSource.java

@@ -0,0 +1,27 @@
+package com.fs.framework.datasource;
+
+import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
+
+import javax.sql.DataSource;
+import java.util.Map;
+
+/**
+ * 动态数据源
+ *
+
+ */
+public class DynamicDataSource extends AbstractRoutingDataSource
+{
+    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources)
+    {
+        super.setDefaultTargetDataSource(defaultTargetDataSource);
+        super.setTargetDataSources(targetDataSources);
+        super.afterPropertiesSet();
+    }
+
+    @Override
+    protected Object determineCurrentLookupKey()
+    {
+        return DynamicDataSourceContextHolder.getDataSourceType();
+    }
+}

+ 44 - 0
fs-ai-api/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java

@@ -0,0 +1,44 @@
+package com.fs.framework.datasource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 数据源切换处理
+ *
+
+ */
+public class DynamicDataSourceContextHolder {
+    public static final Logger log = LoggerFactory.getLogger(DynamicDataSourceContextHolder.class);
+
+    /**
+     * 使用ThreadLocal维护变量,ThreadLocal为每个使用该变量的线程提供独立的变量副本,
+     *  所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
+     */
+    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
+
+    /**
+     * 设置数据源的变量
+     */
+    public static void setDataSourceType(String dsType)
+    {
+//        log.info("切换到{}数据源", dsType);
+        CONTEXT_HOLDER.set(dsType);
+    }
+
+    /**
+     * 获得数据源的变量
+     */
+    public static String getDataSourceType()
+    {
+        return CONTEXT_HOLDER.get();
+    }
+
+    /**
+     * 清空数据源变量
+     */
+    public static void clearDataSourceType()
+    {
+        CONTEXT_HOLDER.remove();
+    }
+}

+ 27 - 0
fs-ai-api/src/main/java/com/fs/framework/filter/AppTenantSwitchFilter.java

@@ -0,0 +1,27 @@
+package com.fs.framework.filter;
+
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * fs-ai-api 不承载多租户业务切库逻辑,过滤器保持透传。
+ */
+@Component
+@Order(5)
+public class AppTenantSwitchFilter extends OncePerRequestFilter {
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request,
+                                    HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        filterChain.doFilter(request, response);
+    }
+}
+

+ 56 - 0
fs-ai-api/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java

@@ -0,0 +1,56 @@
+package com.fs.framework.interceptor;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+/**
+ * 防止重复提交拦截器
+ *
+
+ */
+@Component
+public abstract class RepeatSubmitInterceptor extends HandlerInterceptorAdapter
+{
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
+    {
+        if (handler instanceof HandlerMethod)
+        {
+            HandlerMethod handlerMethod = (HandlerMethod) handler;
+            Method method = handlerMethod.getMethod();
+            RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
+            if (annotation != null)
+            {
+                if (this.isRepeatSubmit(request))
+                {
+                    AjaxResult ajaxResult = AjaxResult.error("不允许重复提交,请稍后再试");
+                    ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
+                    return false;
+                }
+            }
+            return true;
+        }
+        else
+        {
+            return super.preHandle(request, response, handler);
+        }
+    }
+
+    /**
+     * 验证是否重复提交由子类实现具体的防重复提交的规则
+     *
+     * @param request
+     * @return
+     * @throws Exception
+     */
+    public abstract boolean isRepeatSubmit(HttpServletRequest request);
+}

+ 126 - 0
fs-ai-api/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java

@@ -0,0 +1,126 @@
+package com.fs.framework.interceptor.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.filter.RepeatedlyRequestWrapper;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.http.HttpHelper;
+import com.fs.framework.interceptor.RepeatSubmitInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 判断请求url和数据是否和上一次相同,
+ * 如果和上次相同,则是重复提交表单。 有效时间为10秒内。
+ *
+
+ */
+@Component
+public class SameUrlDataInterceptor extends RepeatSubmitInterceptor
+{
+    public final String REPEAT_PARAMS = "repeatParams";
+
+    public final String REPEAT_TIME = "repeatTime";
+
+    // 令牌自定义标识
+    @Value("${token.header}")
+    private String header;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 间隔时间,单位:秒 默认10秒
+     *
+     * 两次相同参数的请求,如果间隔时间大于该参数,系统不会认定为重复提交的数据
+     */
+    private int intervalTime = 10;
+
+    public void setIntervalTime(int intervalTime)
+    {
+        this.intervalTime = intervalTime;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public boolean isRepeatSubmit(HttpServletRequest request)
+    {
+        String nowParams = "";
+        if (request instanceof RepeatedlyRequestWrapper)
+        {
+            RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
+            nowParams = HttpHelper.getBodyString(repeatedlyRequest);
+        }
+
+        // body参数为空,获取Parameter的数据
+        if (StringUtils.isEmpty(nowParams))
+        {
+            nowParams = JSONObject.toJSONString(request.getParameterMap());
+        }
+        Map<String, Object> nowDataMap = new HashMap<String, Object>();
+        nowDataMap.put(REPEAT_PARAMS, nowParams);
+        nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
+
+        // 请求地址(作为存放cache的key值)
+        String url = request.getRequestURI();
+
+        // 唯一值(没有消息头则使用请求地址)
+        String submitKey = request.getHeader(header);
+        if (StringUtils.isEmpty(submitKey))
+        {
+            submitKey = url;
+        }
+
+        // 唯一标识(指定key + 消息头)
+        String cacheRepeatKey = Constants.REPEAT_SUBMIT_KEY + submitKey;
+
+        Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
+        if (sessionObj != null)
+        {
+            Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
+            if (sessionMap.containsKey(url))
+            {
+                Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
+                if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap))
+                {
+                    return true;
+                }
+            }
+        }
+        Map<String, Object> cacheMap = new HashMap<String, Object>();
+        cacheMap.put(url, nowDataMap);
+        redisCache.setCacheObject(cacheRepeatKey, cacheMap, intervalTime, TimeUnit.SECONDS);
+        return false;
+    }
+
+    /**
+     * 判断参数是否相同
+     */
+    private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap)
+    {
+        String nowParams = (String) nowMap.get(REPEAT_PARAMS);
+        String preParams = (String) preMap.get(REPEAT_PARAMS);
+        return nowParams.equals(preParams);
+    }
+
+    /**
+     * 判断两次间隔时间
+     */
+    private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap)
+    {
+        long time1 = (Long) nowMap.get(REPEAT_TIME);
+        long time2 = (Long) preMap.get(REPEAT_TIME);
+        if ((time1 - time2) < (this.intervalTime * 1000))
+        {
+            return true;
+        }
+        return false;
+    }
+}

+ 56 - 0
fs-ai-api/src/main/java/com/fs/framework/manager/AsyncManager.java

@@ -0,0 +1,56 @@
+package com.fs.framework.manager;
+
+import com.fs.common.utils.Threads;
+import com.fs.common.utils.spring.SpringUtils;
+
+import java.util.TimerTask;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 异步任务管理器
+ * 
+
+ */
+public class AsyncManager
+{
+    /**
+     * 操作延迟10毫秒
+     */
+    private final int OPERATE_DELAY_TIME = 10;
+
+    /**
+     * 异步操作任务调度线程池
+     */
+    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
+
+    /**
+     * 单例模式
+     */
+    private AsyncManager(){}
+
+    private static AsyncManager me = new AsyncManager();
+
+    public static AsyncManager me()
+    {
+        return me;
+    }
+
+    /**
+     * 执行任务
+     * 
+     * @param task 任务
+     */
+    public void execute(TimerTask task)
+    {
+        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
+    }
+
+    /**
+     * 停止任务线程池
+     */
+    public void shutdown()
+    {
+        Threads.shutdownAndAwaitTermination(executor);
+    }
+}

+ 40 - 0
fs-ai-api/src/main/java/com/fs/framework/manager/ShutdownManager.java

@@ -0,0 +1,40 @@
+package com.fs.framework.manager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PreDestroy;
+
+/**
+ * 确保应用退出时能关闭后台线程
+ *
+
+ */
+@Component
+public class ShutdownManager
+{
+    private static final Logger logger = LoggerFactory.getLogger("sys-user");
+
+    @PreDestroy
+    public void destroy()
+    {
+        shutdownAsyncManager();
+    }
+
+    /**
+     * 停止异步执行任务
+     */
+    private void shutdownAsyncManager()
+    {
+        try
+        {
+            logger.info("====关闭后台任务任务线程池====");
+            AsyncManager.me().shutdown();
+        }
+        catch (Exception e)
+        {
+            logger.error(e.getMessage(), e);
+        }
+    }
+}

+ 50 - 0
fs-ai-api/src/main/java/com/fs/framework/security/SecurityUtils.java

@@ -0,0 +1,50 @@
+package com.fs.framework.security;
+
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+
+/**
+ * 安全服务工具类
+ * 
+
+ */
+public class SecurityUtils
+{
+
+    /**
+     * 生成BCryptPasswordEncoder密码
+     *
+     * @param password 密码
+     * @return 加密字符串
+     */
+    public static String encryptPassword(String password)
+    {
+        
+        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+        return passwordEncoder.encode(password);
+    }
+
+    /**
+     * 判断密码是否相同
+     *
+     * @param rawPassword 真实密码
+     * @param encodedPassword 加密后字符
+     * @return 结果
+     */
+    public static boolean matchesPassword(String rawPassword, String encodedPassword)
+    {
+        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+        return passwordEncoder.matches(rawPassword, encodedPassword);
+    }
+
+    /**
+     * 是否为管理员
+     *
+     * @param userId 用户ID
+     * @return 结果
+     */
+    public static boolean isAdmin(Long userId)
+    {
+        return userId != null && 1L == userId;
+    }
+}

+ 17 - 0
fs-ai-api/src/main/java/com/fs/framework/security/TenantPrincipal.java

@@ -0,0 +1,17 @@
+package com.fs.framework.security;
+
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 用于 SecurityContext 的租户身份,仅携带 tenantId。
+ * SecurityUtils.getTenantId() 会通过反射调用 getTenantId(),
+ * 使 TenantKeyRedisSerializer 能自动为 Redis Key 加上租户前缀。
+ */
+@Getter
+@AllArgsConstructor
+public class TenantPrincipal {
+
+    private final Long tenantId;
+}

+ 1 - 0
fs-ai-api/src/main/resources/META-INF/spring-devtools.properties

@@ -0,0 +1 @@
+restart.include.json=/com.alibaba.fastjson.*.jar

+ 52 - 0
fs-ai-api/src/main/resources/application.yml

@@ -0,0 +1,52 @@
+server:
+  # 服务器的HTTP端口,默认为8080
+  port: 9009
+# Spring配置
+spring:
+  profiles:
+    #    active: dev-test
+    #    active: druid-hdt
+    #    active: druid-yzt
+    #    active: druid-sxjz-test
+    #    active: druid-sft
+    #    active: druid-fby
+    active: dev
+
+swagger:
+  enabled: true
+
+# Chroma v2 根路径(含 /api/v2),与心跳一致:https://saaschroma.ylrzcloud.com/api/v2/heartbeat
+ai:
+  rag:
+    enabled: true
+    chroma-base-url: https://saaschroma.ylrzcloud.com/api/v2
+    # 若 Chroma 开启鉴权,填写 x-chroma-token(OpenAPI 名称 ApiKeyAuth)
+    chroma-token: ""
+    # 可选:手动指定租户 UUID、库名;留空则尝试 GET /auth/identity 自动解析
+    chroma-tenant-id: "saasai"
+    chroma-database: ylrz_saas_ai
+    embedding-url: https://api.openai.com/v1/embeddings
+    embedding-api-key: ""
+    embedding-model: text-embedding-3-small
+    # 多模型路由(可选)。配置后优先走 routing/models/providers,旧 embedding-* 作为回退。
+    routing:
+      embedding-default: openai-embed-small
+      scenes:
+        company_1_workflow_lobster: openai-embed-small
+    models:
+      openai-embed-small:
+        type: embedding
+        provider: openai
+        model: text-embedding-3-small
+      openai-embed-large:
+        type: embedding
+        provider: openai
+        model: text-embedding-3-large
+    providers:
+      openai:
+        base-url: http://129.28.170.206:3000/api
+        api-key: "fastgpt-lDP6kVelHf2p8j80vfz2Kl7g9PjacwJoTmCplEBGWBaGMCRtv7SueW5mZ4iXe"
+    connect-timeout-ms: 8000
+    read-timeout-ms: 120000
+    chunk-size: 500
+    chunk-overlap: 80

+ 2 - 0
fs-ai-api/src/main/resources/banner.txt

@@ -0,0 +1,2 @@
+Application Version: ${fs.version}
+Spring Boot Version: ${spring-boot.version}

+ 37 - 0
fs-ai-api/src/main/resources/i18n/messages.properties

@@ -0,0 +1,37 @@
+#错误消息
+not.null=* 必须填写
+user.jcaptcha.error=验证码错误
+user.jcaptcha.expire=验证码已失效
+user.not.exists=用户不存在/密码错误
+user.password.not.match=用户不存在/密码错误
+user.password.retry.limit.count=密码输入错误{0}次
+user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定10分钟
+user.password.delete=对不起,您的账号已被删除
+user.blocked=用户已封禁,请联系管理员
+role.blocked=角色已封禁,请联系管理员
+user.logout.success=退出成功
+
+length.not.valid=长度必须在{min}到{max}个字符之间
+
+user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
+user.password.not.valid=* 5-50个字符
+ 
+user.email.not.valid=邮箱格式错误
+user.mobile.phone.number.not.valid=手机号格式错误
+user.login.success=登录成功
+user.register.success=注册成功
+user.notfound=请重新登录
+user.forcelogout=管理员强制退出,请重新登录
+user.unknown.error=未知错误,请重新登录
+
+##文件上传消息
+upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
+upload.filename.exceed.length=上传的文件名最长{0}个字符
+
+##权限
+no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
+no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
+no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
+no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
+no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
+no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]

+ 93 - 0
fs-ai-api/src/main/resources/logback.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <!-- 日志存放路径 -->
+	<property name="log.path" value="/home/fs-ai-app/logs" />
+    <!-- 日志输出格式 -->
+	<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
+
+	<!-- 控制台输出 -->
+	<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+	</appender>
+
+	<!-- 系统日志输出 -->
+	<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/sys-info.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+		<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 60天 -->
+			<maxHistory>60</maxHistory>
+		</rollingPolicy>
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+		<filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+	</appender>
+
+	<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/sys-error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 60天 -->
+			<maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>ERROR</level>
+			<!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+			<!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+	<!-- 用户访问日志输出  -->
+    <appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
+		<file>${log.path}/sys-user.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 按天回滚 daily -->
+            <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+    </appender>
+
+	<!-- 系统模块日志级别控制  -->
+	<logger name="com.fs" level="info" />
+	<!-- Spring日志级别控制  -->
+	<logger name="org.springframework" level="warn" />
+
+	<root level="info">
+		<appender-ref ref="console" />
+	</root>
+
+	<!--系统操作日志-->
+    <root level="info">
+        <appender-ref ref="file_info" />
+        <appender-ref ref="file_error" />
+    </root>
+
+	<!--系统用户操作日志-->
+    <logger name="sys-user" level="info">
+        <appender-ref ref="sys-user"/>
+    </logger>
+</configuration>

+ 15 - 0
fs-ai-api/src/main/resources/mybatis/mybatis-config.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE configuration
+PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-config.dtd">
+<configuration>
+
+	<settings>
+		<setting name="cacheEnabled"             value="true" />  <!-- 全局映射器启用缓存 -->
+		<setting name="useGeneratedKeys"         value="true" />  <!-- 允许 JDBC 支持自动生成主键 -->
+		<setting name="defaultExecutorType"      value="REUSE" /> <!-- 配置默认的执行器 -->
+		<setting name="logImpl"                  value="SLF4J" /> <!-- 指定 MyBatis 所用日志的具体实现 -->
+		 <setting name="mapUnderscoreToCamelCase" value="true"/>
+	</settings>
+
+</configuration>

+ 2 - 0
fs-service/src/main/java/com/fs/tenant/service/TenantInfoService.java

@@ -117,4 +117,6 @@ public interface TenantInfoService extends IService<TenantInfo> {
     Map<String, Object> getYesterAiReplyToken(DateTime yesterDayBegin, DateTime yesterdayEnd, FeePlanItem item, TenantInfo tenant);
     Map<String, Object> getYesterAiReplyToken(DateTime yesterDayBegin, DateTime yesterdayEnd, FeePlanItem item, TenantInfo tenant);
 
 
     Map<String, Object> getYesterSopToken(DateTime yesterDayBegin, DateTime yesterdayEnd, FeePlanItem item,TenantInfo tenant);
     Map<String, Object> getYesterSopToken(DateTime yesterDayBegin, DateTime yesterdayEnd, FeePlanItem item,TenantInfo tenant);
+
+    TenantInfo selectTenantInfoByCode(String tenantCode);
 }
 }

+ 99 - 5
fs-service/src/main/java/com/fs/tenant/service/impl/TenantInfoServiceImpl.java

@@ -12,6 +12,7 @@ import com.fs.billing.mapper.BillingDetailMapper;
 import com.fs.billing.mapper.UsageEventMapper;
 import com.fs.billing.mapper.UsageEventMapper;
 import com.fs.common.constant.UserConstants;
 import com.fs.common.constant.UserConstants;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.domain.TreeSelect;
 import com.fs.common.core.domain.TreeSelect;
 import com.fs.common.core.domain.entity.SysMenu;
 import com.fs.common.core.domain.entity.SysMenu;
 import com.fs.common.core.domain.entity.TenantCompanyMenu;
 import com.fs.common.core.domain.entity.TenantCompanyMenu;
@@ -37,10 +38,8 @@ import org.springframework.util.CollectionUtils;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.sql.Connection;
 import java.sql.Connection;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
 /**
 /**
@@ -51,6 +50,10 @@ import java.util.stream.Collectors;
 @Slf4j
 @Slf4j
 @Service
 @Service
 public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantInfo> implements TenantInfoService {
 public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantInfo> implements TenantInfoService {
+    private static final String REDIS_SYSTEM_KEY_PREFIX = "tenantid:system:";
+    private static final String TENANT_INFO_CODE_CACHE_KEY_PREFIX = "tenant:info:code:";
+    private static final int TENANT_INFO_CODE_CACHE_HOURS = 24;
+
     @Autowired
     @Autowired
     private TenantAsyncService tenantAsyncService;
     private TenantAsyncService tenantAsyncService;
     @Autowired
     @Autowired
@@ -59,6 +62,8 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
     private UsageEventMapper usageEventMapper;
     private UsageEventMapper usageEventMapper;
     @Autowired
     @Autowired
     private BillingDetailMapper billingDetailMapper;
     private BillingDetailMapper billingDetailMapper;
+    @Autowired
+    private RedisCache redisCache;
 
 
     /**
     /**
      * 查询租户基础信息
      * 查询租户基础信息
@@ -110,6 +115,7 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
             tenantInfo.setCreateTime(DateUtils.getNowDate());
             tenantInfo.setCreateTime(DateUtils.getNowDate());
             tenantInfo.setStatus(2);
             tenantInfo.setStatus(2);
             int result = baseMapper.insertTenantInfo(tenantInfo);
             int result = baseMapper.insertTenantInfo(tenantInfo);
+            refreshTenantInfoCodeCache(tenantInfo);
 
 
             // 从主库查询租户后台菜单表和销售菜单表
             // 从主库查询租户后台菜单表和销售菜单表
             List<SysMenu> sysMenus = baseMapper.selectMenuList(new SysMenu());
             List<SysMenu> sysMenus = baseMapper.selectMenuList(new SysMenu());
@@ -161,8 +167,22 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
     @Override
     @Override
     public int updateTenantInfo(TenantInfo tenantInfo)
     public int updateTenantInfo(TenantInfo tenantInfo)
     {
     {
+        String oldTenantCode = null;
+        if (StringUtils.isNotEmpty(Collections.singleton(tenantInfo.getId()))) {
+            TenantInfo oldTenantInfo = baseMapper.selectTenantInfoById(tenantInfo.getId().toString());
+            if (oldTenantInfo != null) {
+                oldTenantCode = oldTenantInfo.getTenantCode();
+            }
+        }
         tenantInfo.setUpdateTime(DateUtils.getNowDate());
         tenantInfo.setUpdateTime(DateUtils.getNowDate());
-        return baseMapper.updateTenantInfo(tenantInfo);
+        int rows = baseMapper.updateTenantInfo(tenantInfo);
+        if (rows > 0) {
+            refreshTenantInfoCodeCache(tenantInfo);
+            if (StringUtils.isNotEmpty(oldTenantCode) && !Objects.equals(oldTenantCode, tenantInfo.getTenantCode())) {
+                redisCache.deleteObject(buildTenantInfoCodeCacheKey(oldTenantCode));
+            }
+        }
+        return rows;
     }
     }
 
 
     /**
     /**
@@ -532,6 +552,80 @@ public class TenantInfoServiceImpl extends ServiceImpl<TenantInfoMapper, TenantI
         map.put("price", totalFee);
         map.put("price", totalFee);
         return map;
         return map;
     }
     }
+
+    @Override
+    public TenantInfo selectTenantInfoByCode(String tenantCode) {
+        if (StringUtils.isBlank(tenantCode)) {
+            return null;
+        }
+        String cacheKey = buildTenantInfoCodeCacheKey(tenantCode);
+        TenantInfo tenantInfo = redisCache.getCacheObject(cacheKey);
+        if (tenantInfo != null) {
+            return tenantInfo;
+        }
+        String oldDataSource = getCurrentDataSourceType();
+        try {
+            setCurrentDataSourceType(DataSourceType.MASTER.name());
+            tenantInfo = baseMapper.getTenByCode(tenantCode);
+        } finally {
+            if (StringUtils.isBlank(oldDataSource)) {
+                clearCurrentDataSourceType();
+            } else {
+                setCurrentDataSourceType(oldDataSource);
+            }
+        }
+        if (tenantInfo != null) {
+            redisCache.setCacheObject(cacheKey, tenantInfo, TENANT_INFO_CODE_CACHE_HOURS, TimeUnit.HOURS);
+        }
+        return tenantInfo;
+    }
+
+    private void refreshTenantInfoCodeCache(TenantInfo tenantInfo) {
+        if (tenantInfo == null || StringUtils.isBlank(tenantInfo.getTenantCode())) {
+            return;
+        }
+        TenantInfo latest = baseMapper.getTenByCode(tenantInfo.getTenantCode());
+        if (latest != null) {
+            redisCache.setCacheObject(buildTenantInfoCodeCacheKey(latest.getTenantCode()),
+                    latest, TENANT_INFO_CODE_CACHE_HOURS, TimeUnit.HOURS);
+        }
+    }
+
+    private String buildTenantInfoCodeCacheKey(String tenantCode) {
+        // 直接传完整 tenantid:system: 前缀,强制命中主库 Redis 空间。
+        return REDIS_SYSTEM_KEY_PREFIX + TENANT_INFO_CODE_CACHE_KEY_PREFIX + tenantCode;
+    }
+
+    private String getCurrentDataSourceType() {
+        try {
+            Class<?> holderClass = Class.forName("com.fs.framework.datasource.DynamicDataSourceContextHolder");
+            Object value = holderClass.getMethod("getDataSourceType").invoke(null);
+            return value == null ? null : String.valueOf(value);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private void setCurrentDataSourceType(String dataSourceType) {
+        if (StringUtils.isBlank(dataSourceType)) {
+            return;
+        }
+        try {
+            Class<?> holderClass = Class.forName("com.fs.framework.datasource.DynamicDataSourceContextHolder");
+            holderClass.getMethod("setDataSourceType", String.class).invoke(null, dataSourceType);
+        } catch (Exception e) {
+            log.debug("setCurrentDataSourceType skipped: {}", dataSourceType);
+        }
+    }
+
+    private void clearCurrentDataSourceType() {
+        try {
+            Class<?> holderClass = Class.forName("com.fs.framework.datasource.DynamicDataSourceContextHolder");
+            holderClass.getMethod("clearDataSourceType").invoke(null);
+        } catch (Exception e) {
+            // ignore
+        }
+    }
 }
 }
 
 
 
 

+ 1 - 0
pom.xml

@@ -270,6 +270,7 @@
         <module>fs-company</module>
         <module>fs-company</module>
         <module>fs-doctor-app</module>
         <module>fs-doctor-app</module>
         <module>fs-ai-chat</module>
         <module>fs-ai-chat</module>
+        <module>fs-ai-api</module>
         <module>fs-wx-api</module>
         <module>fs-wx-api</module>
         <module>fs-user-app-ai-chat</module>
         <module>fs-user-app-ai-chat</module>
         <module>fs-qwhook</module>
         <module>fs-qwhook</module>