ソースを参照

merge: 解决UserDetailsServiceImpl冲突,保留租户库sys_user查询逻辑

云联一号 1 週間 前
コミット
c367f76dc1
51 ファイル変更3286 行追加288 行削除
  1. 6 56
      fs-admin-saas/src/main/java/com/fs/web/controller/system/SysKeywordController.java
  2. 11 0
      fs-admin-saas/src/main/resources/db/migration/tenant/V20260526_01__tenant_dict_columns.sql
  3. 5 0
      fs-admin/pom.xml
  4. 2 0
      fs-admin/src/main/java/com/fs/FSApplication.java
  5. 32 0
      fs-admin/src/main/java/com/fs/admin/config/AdminWebMvcConfig.java
  6. 7 8
      fs-admin/src/main/java/com/fs/admin/controller/AdminVideoResourceController.java
  7. 10 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java
  8. 241 0
      fs-admin/src/main/java/com/fs/admin/controller/TenantDictController.java
  9. 1 3
      fs-admin/src/main/java/com/fs/admin/controller/audit/AdminCompanyBridgeController.java
  10. 427 0
      fs-admin/src/main/java/com/fs/admin/controller/tenant/TenantInfoController.java
  11. 91 0
      fs-admin/src/main/java/com/fs/admin/interceptor/AdminTenantDataSourceInterceptor.java
  12. 13 10
      fs-admin/src/main/java/com/fs/web/controller/system/SysConfigController.java
  13. 106 157
      fs-admin/src/main/java/com/fs/web/controller/system/SysKeywordController.java
  14. 9 4
      fs-admin/src/main/resources/logback.xml
  15. 30 0
      fs-common/src/main/java/com/fs/common/config/RedisConfig.java
  16. 39 0
      fs-common/src/main/java/com/fs/common/core/domain/entity/SysDictData.java
  17. 39 0
      fs-common/src/main/java/com/fs/common/core/domain/entity/SysDictType.java
  18. 15 15
      fs-service/src/main/java/com/fs/admin/helper/AdminCrossTenantHelper.java
  19. 9 3
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchCommentServiceImpl.java
  20. 10 0
      fs-service/src/main/java/com/fs/crm/param/CrmLineCustomerListQueryParam.java
  21. 23 0
      fs-service/src/main/java/com/fs/system/constant/SysKeywordConstants.java
  22. 13 0
      fs-service/src/main/java/com/fs/system/domain/SysConfig.java
  23. 14 3
      fs-service/src/main/java/com/fs/system/domain/SysKeyword.java
  24. 102 22
      fs-service/src/main/java/com/fs/system/service/impl/SysKeywordServiceImpl.java
  25. 31 0
      fs-service/src/main/java/com/fs/tenant/config/SysConfigCacheDelegate.java
  26. 19 0
      fs-service/src/main/java/com/fs/tenant/config/service/TenantSysConfigService.java
  27. 94 0
      fs-service/src/main/java/com/fs/tenant/config/service/impl/TenantSysConfigServiceImpl.java
  28. 45 0
      fs-service/src/main/java/com/fs/tenant/dict/constant/TenantDictConstants.java
  29. 103 0
      fs-service/src/main/java/com/fs/tenant/dict/domain/TenantDictTemplateData.java
  30. 68 0
      fs-service/src/main/java/com/fs/tenant/dict/domain/TenantDictTemplateType.java
  31. 36 0
      fs-service/src/main/java/com/fs/tenant/dict/dto/TenantDictSyncRunReq.java
  32. 24 0
      fs-service/src/main/java/com/fs/tenant/dict/dto/TenantDictTemplateImportReq.java
  33. 95 0
      fs-service/src/main/java/com/fs/tenant/dict/helper/TenantDictContextHelper.java
  34. 42 0
      fs-service/src/main/java/com/fs/tenant/dict/mapper/TenantDictTemplateMapper.java
  35. 36 0
      fs-service/src/main/java/com/fs/tenant/dict/service/TenantDictManageService.java
  36. 20 0
      fs-service/src/main/java/com/fs/tenant/dict/service/TenantDictSyncService.java
  37. 43 0
      fs-service/src/main/java/com/fs/tenant/dict/service/TenantDictTemplateService.java
  38. 214 0
      fs-service/src/main/java/com/fs/tenant/dict/service/impl/TenantDictManageServiceImpl.java
  39. 552 0
      fs-service/src/main/java/com/fs/tenant/dict/service/impl/TenantDictSyncServiceImpl.java
  40. 325 0
      fs-service/src/main/java/com/fs/tenant/dict/service/impl/TenantDictTemplateServiceImpl.java
  41. 21 0
      fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictSyncResultVo.java
  42. 20 0
      fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictSyncTaskDetailVo.java
  43. 22 0
      fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictSyncTaskVo.java
  44. 18 0
      fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictTemplateImportResultVo.java
  45. 3 0
      fs-service/src/main/resources/db/20250530-初始化表结构.sql
  46. 1 1
      fs-service/src/main/resources/db/tenant-initData.sql
  47. 9 0
      fs-service/src/main/resources/db/tenant-initTable.sql
  48. 9 1
      fs-service/src/main/resources/mapper/system/SysDictDataMapper.xml
  49. 9 1
      fs-service/src/main/resources/mapper/system/SysDictTypeMapper.xml
  50. 26 4
      fs-service/src/main/resources/mapper/system/SysKeywordMapper.xml
  51. 146 0
      fs-service/src/main/resources/mapper/tenant/TenantDictTemplateMapper.xml

+ 6 - 56
fs-admin-saas/src/main/java/com/fs/web/controller/system/SysKeywordController.java

@@ -4,7 +4,6 @@ import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
-import com.fs.common.core.redis.RedisCache;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.system.domain.SysKeyword;
@@ -12,16 +11,13 @@ import com.fs.system.service.ISysKeywordService;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.util.CollectionUtils;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.context.annotation.Profile;
 
-import javax.annotation.PostConstruct;
-import java.time.LocalDateTime;
-import java.util.*;
-import java.util.stream.Collectors;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 /**
  * 系统关键字Controller
@@ -37,14 +33,6 @@ public class SysKeywordController extends BaseController
     @Autowired
     private ISysKeywordService sysKeywordService;
 
-    @Autowired
-    RedisCache redisCache;
-
-    @Autowired
-    public RedisTemplate<String,String> redisTemplate;
-
-    private static final String REDIS_KEY = "sys:keywords";
-
     /**
      * 查询系统关键字列表
      */
@@ -52,7 +40,6 @@ public class SysKeywordController extends BaseController
     @GetMapping("/list")
     public R list(SysKeyword sysKeyword)
     {
-//        startPage();
         PageHelper.startPage(sysKeyword.getPageNum(), sysKeyword.getPageSize());
         List<SysKeyword> list = sysKeywordService.selectSysKeywordList(sysKeyword);
 
@@ -94,10 +81,7 @@ public class SysKeywordController extends BaseController
     @PostMapping
     public AjaxResult add(@RequestBody SysKeyword sysKeyword)
     {
-        int i = sysKeywordService.insertSysKeyword(sysKeyword);
-        // 缓存
-        redisTemplate.opsForSet().add(REDIS_KEY, sysKeyword.getKeyword());
-        return toAjax(i);
+        return toAjax(sysKeywordService.insertSysKeyword(sysKeyword));
     }
 
     /**
@@ -108,14 +92,7 @@ public class SysKeywordController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestBody SysKeyword sysKeyword)
     {
-        //获取之前的数据
-        SysKeyword sysKeywordOld = sysKeywordService.selectSysKeywordById(sysKeyword.getKeywordId());
-        String keywordOld = sysKeywordOld.getKeyword();
-        int i = sysKeywordService.updateSysKeyword(sysKeyword);
-        // 更新缓存
-        redisTemplate.opsForSet().remove(REDIS_KEY, keywordOld);
-        redisTemplate.opsForSet().add(REDIS_KEY, sysKeyword.getKeyword());
-        return toAjax(i);
+        return toAjax(sysKeywordService.updateSysKeyword(sysKeyword));
     }
 
     /**
@@ -126,33 +103,6 @@ public class SysKeywordController extends BaseController
 	@DeleteMapping("/{keywordIds}")
     public AjaxResult remove(@PathVariable Long[] keywordIds)
     {
-        List<SysKeyword> sysKeywords = sysKeywordService.selectSysKeywordByIds(keywordIds);
-        int i = sysKeywordService.deleteSysKeywordByIds(keywordIds);
-        if (!CollectionUtils.isEmpty(sysKeywords)) {
-            redisTemplate.opsForSet().remove(REDIS_KEY, sysKeywords.stream().map(SysKeyword::getKeyword).toArray(String[]::new));
-        }
-        return toAjax(i);
-    }
-
-    /**
-     * 启动加载全部关键字到缓存
-     */
-    @PostConstruct
-    public void initKeywords() {
-        try {
-            SysKeyword sysKeywordParam = new SysKeyword();
-            List<SysKeyword> sysKeywords = sysKeywordService.selectSysKeywordList(sysKeywordParam);
-            List<String> keywords = sysKeywords.stream()
-                    .map(SysKeyword::getKeyword)
-                    .filter(k -> k != null && !k.isEmpty())
-                    .collect(Collectors.toList());
-
-            if (!keywords.isEmpty()) {
-                redisTemplate.opsForSet().add(REDIS_KEY, keywords.toArray(new String[0]));
-            }
-            System.out.println("加载全部关键字到缓存");
-        } catch (Exception e) {
-            System.err.println("警告: 加载关键字到Redis缓存失败,应用继续启动: " + e.getMessage());
-        }
+        return toAjax(sysKeywordService.deleteSysKeywordByIds(keywordIds));
     }
 }

+ 11 - 0
fs-admin-saas/src/main/resources/db/migration/tenant/V20260526_01__tenant_dict_columns.sql

@@ -0,0 +1,11 @@
+-- 租户库 sys_dict 扩展字段(同步溯源)
+ALTER TABLE `sys_dict_type`
+    ADD COLUMN `dict_source` varchar(20) NULL DEFAULT 'tenant' COMMENT '来源 platform/tenant' AFTER `status`,
+    ADD COLUMN `is_platform_managed` tinyint NULL DEFAULT 0 COMMENT '是否平台管控 1是 0否' AFTER `dict_source`;
+
+ALTER TABLE `sys_dict_data`
+    ADD COLUMN `dict_source` varchar(20) NULL DEFAULT 'tenant' COMMENT '来源 platform/tenant' AFTER `status`,
+    ADD COLUMN `is_platform_managed` tinyint NULL DEFAULT 0 COMMENT '是否平台管控 1是 0否' AFTER `dict_source`;
+
+UPDATE `sys_dict_type` SET `dict_source` = 'tenant', `is_platform_managed` = 0 WHERE `dict_source` IS NULL;
+UPDATE `sys_dict_data` SET `dict_source` = 'tenant', `is_platform_managed` = 0 WHERE `dict_source` IS NULL;

+ 5 - 0
fs-admin/pom.xml

@@ -72,6 +72,11 @@
             <artifactId>mysql-connector-java</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-cache</artifactId>
+        </dependency>
+
         <!-- 核心模块-->
         <dependency>
             <groupId>com.fs</groupId>

+ 2 - 0
fs-admin/src/main/java/com/fs/FSApplication.java

@@ -6,6 +6,7 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.redisson.spring.starter.RedissonAutoConfiguration;
 import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.FilterType;
+import org.springframework.cache.annotation.EnableCaching;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.transaction.annotation.Transactional;
@@ -21,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
     }
 )
 @Transactional
+@EnableCaching
 @EnableAsync
 @EnableScheduling
 public class FSApplication {

+ 32 - 0
fs-admin/src/main/java/com/fs/admin/config/AdminWebMvcConfig.java

@@ -0,0 +1,32 @@
+package com.fs.admin.config;
+
+import com.fs.admin.interceptor.AdminTenantDataSourceInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class AdminWebMvcConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private AdminTenantDataSourceInterceptor adminTenantDataSourceInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(adminTenantDataSourceInterceptor)
+                .addPathPatterns(
+                        "/admin/callRecord/**",
+                        "/admin/voice-robotic/**",
+                        "/admin/sms-admin/**",
+                        "/admin/sms-order/**",
+                        "/admin/sms-package/**",
+                        "/admin/videoResource/**",
+                        "/admin/live/**",
+                        "/admin/liveVideo/**",
+                        "/admin/product/**",
+                        "/admin/storeOrder/**",
+                        "/admin/article/**"
+                );
+    }
+}

+ 7 - 8
fs-admin/src/main/java/com/fs/admin/controller/AdminVideoResourceController.java

@@ -2,31 +2,30 @@ package com.fs.admin.controller;
 
 import java.util.*;
 
-import com.fs.admin.helper.AdminCrossTenantHelper;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.page.TableDataInfo;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 /**
  * 总后台视频资源审计控制器
- * 遍历所有租户库查询 fs_user_course_video 数据(视频资源部分)
+ * 通过拦截器切库后直接查询当前租户 fs_user_course_video 数据
  */
 @RestController
 @RequestMapping("/admin/videoResource")
 public class AdminVideoResourceController extends BaseController {
 
     @Autowired
-    private AdminCrossTenantHelper crossTenantHelper;
+    private JdbcTemplate jdbcTemplate;
 
     /**
-     * 查询所有租户的视频资源列表
+     * 查询指定租户的视频资源列表(由 AdminTenantDataSourceInterceptor 切库)
      */
     @PreAuthorize("@ss.hasPermi('admin:videoResource:list')")
     @GetMapping("/list")
-    public TableDataInfo list(@RequestParam(required = false) Long companyId,
-                              @RequestParam(required = false) String companyName,
+    public TableDataInfo list(@RequestParam(required = false) Long tenantId,
                               @RequestParam(required = false) String videoName) {
         String sql = "SELECT id, title as videoName, video_type as videoType, " +
                 "play_url as playUrl, duration, status, create_time as createTime " +
@@ -37,7 +36,7 @@ public class AdminVideoResourceController extends BaseController {
         }
         sb.append(" ORDER BY create_time DESC");
 
-        List<Map<String, Object>> allList = crossTenantHelper.queryAcrossTenants(companyId, companyName, sb.toString());
-        return getDataTable(allList);
+        List<Map<String, Object>> list = jdbcTemplate.queryForList(sb.toString());
+        return getDataTable(list);
     }
 }

+ 10 - 0
fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java

@@ -171,6 +171,16 @@ public class CompanyAdminController extends BaseController {
         return AjaxResult.success(list);
     }
 
+    /**
+     * 查询租户 id、名称、编码(轻量列表,供下拉/登录页等)
+     * 支持 pageNum、pageSize 分页
+     */
+    @GetMapping("/tenantList")
+    public TableDataInfo tenantList(TenantInfo tenantInfo) {
+        startPage();
+        return getDataTable(tenantInfoService.tenantList(tenantInfo));
+    }
+
     /**
      * 租户统计信息
      */

+ 241 - 0
fs-admin/src/main/java/com/fs/admin/controller/TenantDictController.java

@@ -0,0 +1,241 @@
+package com.fs.admin.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.entity.SysDictData;
+import com.fs.common.core.domain.entity.SysDictType;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.tenant.dict.domain.TenantDictTemplateData;
+import com.fs.tenant.dict.domain.TenantDictTemplateType;
+import com.fs.tenant.dict.dto.TenantDictSyncRunReq;
+import com.fs.tenant.dict.dto.TenantDictTemplateImportReq;
+import com.fs.tenant.dict.service.TenantDictManageService;
+import com.fs.tenant.dict.service.TenantDictSyncService;
+import com.fs.tenant.dict.service.TenantDictTemplateService;
+import com.fs.tenant.dict.vo.TenantDictSyncTaskVo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 总后台 - 租户字典管理(方案1+2)
+ */
+@RestController
+@RequestMapping("/tenant/dict")
+public class TenantDictController extends BaseController {
+
+    @Autowired
+    private TenantDictManageService tenantDictManageService;
+
+    @Autowired
+    private TenantDictTemplateService templateService;
+
+    @Autowired
+    private TenantDictSyncService syncService;
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:list')")
+    @GetMapping("/type/list")
+    public TableDataInfo tenantTypeList(@RequestParam Long tenantId, SysDictType query) {
+        List<SysDictType> list = tenantDictManageService.selectDictTypeList(tenantId, query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:query')")
+    @GetMapping("/type/{dictId}")
+    public AjaxResult tenantTypeInfo(@PathVariable Long dictId, @RequestParam Long tenantId) {
+        return AjaxResult.success(tenantDictManageService.selectDictTypeById(tenantId, dictId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:add')")
+    @Log(title = "租户字典类型", businessType = BusinessType.INSERT)
+    @PostMapping("/type")
+    public AjaxResult tenantTypeAdd(@RequestParam Long tenantId, @Validated @RequestBody SysDictType dict) {
+        dict.setCreateBy(SecurityUtils.getUsername());
+        return toAjax(tenantDictManageService.insertDictType(tenantId, dict));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:edit')")
+    @Log(title = "租户字典类型", businessType = BusinessType.UPDATE)
+    @PutMapping("/type")
+    public AjaxResult tenantTypeEdit(@RequestParam Long tenantId, @Validated @RequestBody SysDictType dict) {
+        dict.setUpdateBy(SecurityUtils.getUsername());
+        return toAjax(tenantDictManageService.updateDictType(tenantId, dict));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:remove')")
+    @Log(title = "租户字典类型", businessType = BusinessType.DELETE)
+    @DeleteMapping("/type/{dictIds}")
+    public AjaxResult tenantTypeRemove(@PathVariable Long[] dictIds, @RequestParam Long tenantId) {
+        tenantDictManageService.deleteDictTypeByIds(tenantId, dictIds);
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:list')")
+    @GetMapping("/data/list")
+    public TableDataInfo tenantDataList(@RequestParam Long tenantId, SysDictData query) {
+        List<SysDictData> list = tenantDictManageService.selectDictDataList(tenantId, query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:query')")
+    @GetMapping("/data/{dictCode}")
+    public AjaxResult tenantDataInfo(@PathVariable Long dictCode, @RequestParam Long tenantId) {
+        return AjaxResult.success(tenantDictManageService.selectDictDataById(tenantId, dictCode));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:add')")
+    @Log(title = "租户字典数据", businessType = BusinessType.INSERT)
+    @PostMapping("/data")
+    public AjaxResult tenantDataAdd(@RequestParam Long tenantId, @Validated @RequestBody SysDictData dict) {
+        dict.setCreateBy(SecurityUtils.getUsername());
+        return toAjax(tenantDictManageService.insertDictData(tenantId, dict));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:edit')")
+    @Log(title = "租户字典数据", businessType = BusinessType.UPDATE)
+    @PutMapping("/data")
+    public AjaxResult tenantDataEdit(@RequestParam Long tenantId, @Validated @RequestBody SysDictData dict) {
+        dict.setUpdateBy(SecurityUtils.getUsername());
+        return toAjax(tenantDictManageService.updateDictData(tenantId, dict));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:remove')")
+    @Log(title = "租户字典数据", businessType = BusinessType.DELETE)
+    @DeleteMapping("/data/{dictCodes}")
+    public AjaxResult tenantDataRemove(@PathVariable Long[] dictCodes, @RequestParam Long tenantId) {
+        tenantDictManageService.deleteDictDataByIds(tenantId, dictCodes);
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:edit')")
+    @DeleteMapping("/cache/refresh")
+    public AjaxResult refreshTenantCache(@RequestParam Long tenantId) {
+        tenantDictManageService.refreshDictCache(tenantId);
+        return AjaxResult.success("租户字典缓存已刷新");
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:list')")
+    @GetMapping("/template/type/list")
+    public TableDataInfo templateTypeList(TenantDictTemplateType query) {
+        startPage();
+        List<TenantDictTemplateType> list = templateService.selectTemplateTypeList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:query')")
+    @GetMapping("/template/type/{dictId}")
+    public AjaxResult templateTypeInfo(@PathVariable Long dictId) {
+        return AjaxResult.success(templateService.selectTemplateTypeById(dictId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:add')")
+    @Log(title = "平台字典模板类型", businessType = BusinessType.INSERT)
+    @PostMapping("/template/type")
+    public AjaxResult templateTypeAdd(@RequestBody TenantDictTemplateType row) {
+        row.setCreateBy(SecurityUtils.getUsername());
+        return toAjax(templateService.insertTemplateType(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:edit')")
+    @Log(title = "平台字典模板类型", businessType = BusinessType.UPDATE)
+    @PutMapping("/template/type")
+    public AjaxResult templateTypeEdit(@RequestBody TenantDictTemplateType row) {
+        row.setUpdateBy(SecurityUtils.getUsername());
+        return toAjax(templateService.updateTemplateType(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:remove')")
+    @Log(title = "平台字典模板类型", businessType = BusinessType.DELETE)
+    @DeleteMapping("/template/type/{dictIds}")
+    public AjaxResult templateTypeRemove(@PathVariable Long[] dictIds) {
+        templateService.deleteTemplateTypeByIds(dictIds);
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:list')")
+    @GetMapping("/template/data/list")
+    public TableDataInfo templateDataList(TenantDictTemplateData query) {
+        startPage();
+        List<TenantDictTemplateData> list = templateService.selectTemplateDataList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:query')")
+    @GetMapping("/template/data/{dictCode}")
+    public AjaxResult templateDataInfo(@PathVariable Long dictCode) {
+        return AjaxResult.success(templateService.selectTemplateDataById(dictCode));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:add')")
+    @Log(title = "平台字典模板数据", businessType = BusinessType.INSERT)
+    @PostMapping("/template/data")
+    public AjaxResult templateDataAdd(@RequestBody TenantDictTemplateData row) {
+        row.setCreateBy(SecurityUtils.getUsername());
+        return toAjax(templateService.insertTemplateData(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:edit')")
+    @Log(title = "平台字典模板数据", businessType = BusinessType.UPDATE)
+    @PutMapping("/template/data")
+    public AjaxResult templateDataEdit(@RequestBody TenantDictTemplateData row) {
+        row.setUpdateBy(SecurityUtils.getUsername());
+        return toAjax(templateService.updateTemplateData(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:remove')")
+    @Log(title = "平台字典模板数据", businessType = BusinessType.DELETE)
+    @DeleteMapping("/template/data/{dictCodes}")
+    public AjaxResult templateDataRemove(@PathVariable Long[] dictCodes) {
+        templateService.deleteTemplateDataByIds(dictCodes);
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:list')")
+    @GetMapping("/template/type/optionselect")
+    public AjaxResult templateTypeOptions() {
+        return AjaxResult.success(templateService.selectAllTemplateTypes());
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:add')")
+    @Log(title = "平台字典模板导入", businessType = BusinessType.INSERT)
+    @PostMapping("/template/import/platform")
+    public AjaxResult importFromPlatform(@RequestBody TenantDictTemplateImportReq req) {
+        return AjaxResult.success(templateService.importFromPlatform(req, SecurityUtils.getUsername()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:template:add')")
+    @Log(title = "平台字典模板导入", businessType = BusinessType.INSERT)
+    @PostMapping("/template/import/tenant")
+    public AjaxResult importFromTenant(@RequestBody TenantDictTemplateImportReq req) {
+        return AjaxResult.success(templateService.importFromTenant(req, SecurityUtils.getUsername()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:sync')")
+    @Log(title = "租户字典同步", businessType = BusinessType.OTHER)
+    @PostMapping("/sync/run")
+    public AjaxResult syncRun(@RequestBody TenantDictSyncRunReq req) {
+        Map<String, Object> data = syncService.runSync(req, SecurityUtils.getUsername());
+        return AjaxResult.success(data);
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:sync')")
+    @GetMapping("/sync/task/{taskNo}")
+    public AjaxResult syncTask(@PathVariable String taskNo) {
+        TenantDictSyncTaskVo vo = syncService.getTask(taskNo);
+        return AjaxResult.success(vo);
+    }
+
+    @PreAuthorize("@ss.hasPermi('tenant:dict:sync')")
+    @GetMapping("/sync/preview")
+    public AjaxResult syncPreview(@RequestParam Long tenantId,
+                                  @RequestParam(required = false) List<String> dictTypes) {
+        return AjaxResult.success(syncService.previewDiff(tenantId, dictTypes));
+    }
+}

+ 1 - 3
fs-admin/src/main/java/com/fs/admin/controller/AdminCompanyBridgeController.java → fs-admin/src/main/java/com/fs/admin/controller/audit/AdminCompanyBridgeController.java

@@ -1,4 +1,4 @@
-package com.fs.admin.controller;
+package com.fs.admin.controller.audit;
 
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
@@ -6,9 +6,7 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.company.domain.*;
-import com.fs.company.param.*;
 import com.fs.company.service.*;
-import com.fs.company.vo.*;
 import com.fs.proxy.domain.TenantTrafficPricing;
 import com.fs.proxy.service.ITenantTrafficPricingService;
 import com.fs.qw.service.IQwIpadServerService;

+ 427 - 0
fs-admin/src/main/java/com/fs/admin/controller/tenant/TenantInfoController.java

@@ -0,0 +1,427 @@
+package com.fs.admin.controller.tenant;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.annotation.Log;
+import com.fs.common.constant.UserConstants;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.entity.SysMenu;
+import com.fs.common.core.domain.entity.TenantCompanyMenu;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.dto.MenuDto;
+import com.fs.tenant.dto.SysConfigDto;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import com.fs.tenant.service.TenantInfoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.CollectionUtils;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 租户基础信息Controller(SaaS 下租户表仅在主库,强制走主库)
+ *
+ * @author fs
+ * @date 2026-01-23
+ */
+@DataSource(DataSourceType.MASTER)
+@RestController
+@RequestMapping("/tenant/tenant")
+public class TenantInfoController extends BaseController
+{
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+    /**
+     * 查询租户基础信息列表
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(TenantInfo tenantInfo)
+    {
+        startPage();
+        List<TenantInfo> list = tenantInfoService.selectTenantInfoList(tenantInfo);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询所有租户id以及租户名称 租户编码
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:list')")
+    @GetMapping("/tenantList")
+    public R tenantList(TenantInfo tenantInfo)
+    {
+        return R.ok().put("rows",tenantInfoService.tenantList(tenantInfo));
+    }
+
+    /**
+     * 导出租户基础信息列表
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:export')")
+    @Log(title = "租户基础信息", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(TenantInfo tenantInfo)
+    {
+        List<TenantInfo> list = tenantInfoService.selectTenantInfoList(tenantInfo);
+        ExcelUtil<TenantInfo> util = new ExcelUtil<TenantInfo>(TenantInfo.class);
+        return util.exportExcel(list, "租户基础信息数据");
+    }
+
+    /**
+     * 获取租户基础信息详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") String id)
+    {
+        return AjaxResult.success(tenantInfoService.selectTenantInfoById(id));
+    }
+
+    /**
+     * 新增租户基础信息
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:add')")
+    @Log(title = "租户基础信息", businessType = BusinessType.INSERT)
+    @PostMapping
+    public R add(@RequestBody TenantInfo tenantInfo)
+    {
+        int i = tenantInfoService.insertTenantInfo(tenantInfo);
+        return i > 0 ? R.ok("租户数据库初始化中,请稍后") : R.error("租户创建失败");
+    }
+
+    /**
+     * 修改租户基础信息
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:edit')")
+    @Log(title = "修改租户基础信息", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody TenantInfo tenantInfo)
+    {
+        return toAjax(tenantInfoService.updateTenantInfo(tenantInfo));
+    }
+
+    /**
+     * 删除租户基础信息
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:remove')")
+    @Log(title = "删除租户基础信息", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String[] ids)
+    {
+        return toAjax(tenantInfoService.deleteTenantInfoByIds(ids));
+    }
+
+    /**
+     * 租户菜单修改(获取租户菜单)
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:edit')")
+    @PostMapping("/menu")
+    public R menuChange(@RequestBody Map<String,Object> map)
+    {
+        String id = map.get("id").toString();
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(id);
+        if (tenantInfo.getStatus() == 2) {
+            throw new CustomException("租户初始化中");
+        }
+
+        // 先查一下标准菜单
+        String flag = map.get("flag").toString();
+        if ("sys".equals(flag)){
+            List<SysMenu> sysMenus = tenantInfoMapper.selectMenuList(new SysMenu());
+            tenantDataSourceManager.switchTenant(tenantInfo);
+            return tenantInfoService.menuChange(flag, sysMenus,null);
+        }
+
+        List<TenantCompanyMenu> companyMenus = tenantInfoMapper.selectCompanyMenuList(new TenantCompanyMenu());
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        return tenantInfoService.menuChange(flag,null,companyMenus);
+
+    }
+
+    /**
+     * 租户菜单修改
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:tenant:edit')")
+    @PostMapping("/menu/edit")
+    public R menuEdit(@RequestBody MenuDto menuDto)
+    {
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(menuDto.getId());
+        if (tenantInfo.getStatus() == 2) {
+            throw new CustomException("租户初始化中");
+        }
+
+        List<Long> selected = menuDto.getSelected();
+        List<Long> unSelected = menuDto.getUnSelected();
+        if ("sys".equals(menuDto.getFlag())) {
+            List<SysMenu> addSysMenu = getAddSysMenu(tenantInfo, menuDto.getSelected());
+            tenantDataSourceManager.switchTenant(tenantInfo);
+            return tenantInfoService.menuEdit(selected, unSelected, menuDto.getFlag(),addSysMenu,null);
+        }
+
+        List<TenantCompanyMenu> addCompanyMenu = getAddCompanyMenu(tenantInfo, menuDto.getSelected());
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        return tenantInfoService.menuEdit(selected, unSelected, menuDto.getFlag(),null,addCompanyMenu);
+    }
+
+
+    /**
+     * 租户配置修改
+     */
+    @PreAuthorize("@ss.hasPermi('tenant:config:edit')")
+    @PostMapping("/config/edit")
+    public R configEdit(@Validated @RequestBody SysConfigDto config)
+    {
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(config.getId());
+        if (tenantInfo.getStatus() == 2) {
+            throw new CustomException("租户初始化中");
+        }
+
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        //修复只能更新的BUG
+        if (null != config.getConfigId()) {
+            return R.ok().put("data", configService.updateConfig(config));
+        } else {
+           return R.ok().put("data", configService.insertConfig(config));
+        }
+    }
+
+    /**
+     * 根据租户id获取指定配置文件
+     *
+     * @param configKey
+     * @param id
+     * @return
+     */
+    @GetMapping(value = "/getConfigByKey/{configKey}")
+    public AjaxResult getConfigByKey(@PathVariable String configKey,String id) {
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(id);
+        if (tenantInfo.getStatus() == 2) {
+            throw new CustomException("租户初始化中");
+        }
+
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        SysConfig config = configService.selectConfigByConfigKey(configKey);
+        return AjaxResult.success(config);
+    }
+
+    /**
+     * 获取需要更新的后台菜单
+     */
+    private List<SysMenu> getAddSysMenu(TenantInfo tenantInfo,List<Long> selected){
+        // 切换到租户库
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        // 查询租户库里已经存在的menuId
+        List<Long> existIds = tenantInfoMapper.selectExistMenuIds();
+        // 不存在的menuId(就是要新增的)
+        List<Long> needAddIds = selected.stream()
+                .filter(id -> !existIds.contains(id))
+                .collect(Collectors.toList());
+        // 去总库查询详细的菜单详情
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (!CollectionUtils.isEmpty(needAddIds)){
+            List<SysMenu> addMenuList = tenantInfoMapper.getTenSysMenuByIds(needAddIds);
+            return addMenuList;
+        }
+
+        return new ArrayList<>();
+    }
+
+    /**
+     * 获取需要更新的销售菜单
+     */
+    private List<TenantCompanyMenu> getAddCompanyMenu(TenantInfo tenantInfo, List<Long> selected){
+        // 切换到租户库
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        // 查询租户库里已经存在的menuId
+        List<Long> existIds = tenantInfoMapper.selectExistComMenuIds();
+        // 不存在的menuId(就是要新增的)
+        List<Long> needAddIds = selected.stream()
+                .filter(id -> !existIds.contains(id))
+                .collect(Collectors.toList());
+        // 去总库查询详细的菜单详情
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (!CollectionUtils.isEmpty(needAddIds)) {
+            List<TenantCompanyMenu> addMenuList = tenantInfoMapper.getTenComMenuByIds(needAddIds);
+            return addMenuList;
+        }
+
+        return new ArrayList<>();
+    }
+
+    /**
+     * 获取租户总后台菜单列表
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:list')")
+    @GetMapping("/tenantMenu/list")
+    public AjaxResult list(SysMenu menu)
+    {
+        List<SysMenu> menus = tenantInfoService.selectMenuList(menu, getUserId());
+        return AjaxResult.success(menus);
+    }
+
+    /**
+     * 获取租户销售菜单列表
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:list')")
+    @GetMapping("/tenantComMenu/list")
+    public AjaxResult list(TenantCompanyMenu menu)
+    {
+        List<TenantCompanyMenu> menus = tenantInfoService.selectCompanyMenuList(menu, getUserId());
+        return AjaxResult.success(menus);
+    }
+
+
+    /**
+     * 根据菜单编号获取详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:query')")
+    @GetMapping(value = "/tenantMenu/{menuId}")
+    public AjaxResult getInfo(@PathVariable Long menuId)
+    {
+        return AjaxResult.success(tenantInfoService.selectMenuById(menuId));
+    }
+
+    /**
+     * 根据菜单编号获取详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:query')")
+    @GetMapping(value = "/getTenantComMenu/{menuId}")
+    public AjaxResult getTenantComMenu(@PathVariable Long menuId)
+    {
+        return AjaxResult.success(tenantInfoService.getTenantComMenu(menuId));
+    }
+
+
+    /**
+     * 新增菜单
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:add')")
+    @Log(title = "菜单管理", businessType = BusinessType.INSERT)
+    @PostMapping("/addTenantMenu")
+    public AjaxResult add(@Validated @RequestBody SysMenu menu)
+    {
+        if (UserConstants.NOT_UNIQUE.equals(tenantInfoService.checkMenuNameUnique(menu)))
+        {
+            return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
+        }
+        else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
+        {
+            return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
+        }
+        menu.setCreateBy(getUsername());
+        return toAjax(tenantInfoService.insertMenu(menu));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:menu:add')")
+    @Log(title = "菜单管理", businessType = BusinessType.INSERT)
+    @PostMapping("/addTenantComMenu")
+    public AjaxResult addTenantComMenu(@Validated @RequestBody TenantCompanyMenu menu)
+    {
+        if (UserConstants.NOT_UNIQUE.equals(tenantInfoService.checkComMenuNameUnique(menu)))
+        {
+            return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
+        }
+        else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
+        {
+            return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
+        }
+        menu.setCreateBy(getUsername());
+        return toAjax(tenantInfoService.insertComMenu(menu));
+    }
+
+    /**
+     * 修改菜单
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:edit')")
+    @Log(title = "菜单管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/updateTenantMenu")
+    public AjaxResult edit(@Validated @RequestBody SysMenu menu)
+    {
+        if (UserConstants.NOT_UNIQUE.equals(tenantInfoService.checkMenuNameUnique(menu)))
+        {
+            return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
+        }
+        else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
+        {
+            return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
+        }
+        else if (menu.getMenuId().equals(menu.getParentId()))
+        {
+            return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
+        }
+        menu.setUpdateBy(getUsername());
+        return toAjax(tenantInfoService.updateMenu(menu));
+    }
+
+    /**
+     * 修改菜单
+     */
+    @PreAuthorize("@ss.hasPermi('system:menu:edit')")
+    @Log(title = "菜单管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/updateTenantComMenu")
+    public AjaxResult updateTenantComMenu(@Validated @RequestBody TenantCompanyMenu menu)
+    {
+        if (UserConstants.NOT_UNIQUE.equals(tenantInfoService.checkComMenuNameUnique(menu)))
+        {
+            return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
+        }
+        else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
+        {
+            return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
+        }
+        else if (menu.getMenuId().equals(menu.getParentId()))
+        {
+            return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
+        }
+        menu.setUpdateBy(getUsername());
+        return toAjax(tenantInfoService.updateComMenu(menu));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:menu:remove')")
+    @Log(title = "菜单管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/delTenantMenu/{menuId}")
+    public AjaxResult remove(@PathVariable("menuId") Long menuId)
+    {
+        if (tenantInfoService.hasChildByMenuId(menuId))
+        {
+            return AjaxResult.error("存在子菜单,不允许删除");
+        }
+        return toAjax(tenantInfoService.deleteMenuById(menuId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:menu:remove')")
+    @Log(title = "菜单管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/delTenantComMenu/{menuId}")
+    public AjaxResult delTenantComMenu(@PathVariable("menuId") Long menuId)
+    {
+        if (tenantInfoService.hasChildByComMenuId(menuId))
+        {
+            return AjaxResult.error("存在子菜单,不允许删除");
+        }
+        return toAjax(tenantInfoService.deleteComMenuById(menuId));
+    }
+}

+ 91 - 0
fs-admin/src/main/java/com/fs/admin/interceptor/AdminTenantDataSourceInterceptor.java

@@ -0,0 +1,91 @@
+package com.fs.admin.interceptor;
+
+import com.fs.common.enums.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 内容审计模块数据源切换拦截器
+ * <p>
+ * 通过请求头 datasource-type 控制数据源切换:
+ * <ul>
+ *   <li><b>saas</b>  - 切换到指定租户的数据库(需配合请求参数 tenantId 指定租户ID)</li>
+ *   <li><b>admin</b> - 使用总库 MASTER 数据源(默认行为,不切库)</li>
+ * </ul>
+ * <p>
+ * 请求结束后自动清除租户数据源上下文,恢复到 MASTER。
+ */
+@Component
+public class AdminTenantDataSourceInterceptor extends HandlerInterceptorAdapter {
+
+    private static final Logger log = LoggerFactory.getLogger(AdminTenantDataSourceInterceptor.class);
+
+    /** 请求头: 数据源类型, saas=租户库, admin=总库 */
+    private static final String HEADER_DS_TYPE = "datasource-type";
+    /** 数据源类型: 租户库 */
+    private static final String DS_TYPE_SAAS = "saas";
+    /** 请求头: 租户ID */
+    private static final String HEADER_TENANT_ID = "tenant-id";
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        // 读取请求头 datasource-type, 仅 saas 模式才切库
+        String dsType = request.getHeader(HEADER_DS_TYPE);
+        if (!DS_TYPE_SAAS.equals(dsType)) {
+            // admin 模式或不传该头, 保持总库 MASTER 数据源
+            return true;
+        }
+
+        // saas 模式: 优先从请求参数获取租户ID, 兜底从请求头获取
+        String tenantIdStr = request.getParameter("tenantId");
+        if (!StringUtils.hasText(tenantIdStr)) {
+            tenantIdStr = request.getHeader(HEADER_TENANT_ID);
+        }
+        if (!StringUtils.hasText(tenantIdStr)) {
+            log.warn("datasource-type=saas 但未传 tenantId 参数, 无法切库");
+            return true;
+        }
+
+        try {
+            Long tenantId = Long.valueOf(tenantIdStr);
+            TenantInfo tenant = tenantInfoService.getById(tenantId);
+            if (tenant != null) {
+                tenantDataSourceManager.switchTenant(tenant);
+                log.debug("切库成功: tenantId={}, tenantCode={}", tenantId, tenant.getTenantCode());
+            } else {
+                log.warn("租户不存在: tenantId={}", tenantId);
+            }
+        } catch (Exception e) {
+            log.error("切库失败: tenantId={}, error={}", tenantIdStr, e.getMessage());
+        }
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
+                                Object handler, Exception ex) {
+        // 仅 saas 模式需要清理租户数据源上下文
+        String dsType = request.getHeader(HEADER_DS_TYPE);
+        if (DS_TYPE_SAAS.equals(dsType)) {
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+}

+ 13 - 10
fs-admin/src/main/java/com/fs/web/controller/system/SysConfigController.java

@@ -14,6 +14,7 @@ import com.fs.common.utils.SecurityUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.config.service.TenantSysConfigService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
@@ -30,6 +31,8 @@ public class SysConfigController extends BaseController {
     @Autowired
     private ISysConfigService configService;
     @Autowired
+    private TenantSysConfigService tenantSysConfigService;
+    @Autowired
     public RedisCache redisCache;
 
     /**
@@ -120,24 +123,24 @@ public class SysConfigController extends BaseController {
         return AjaxResult.success();
     }
 
+    /**
+     * 按 configKey 查询参数。tenantId 有则查租户库,无则查主库;先 Redis 后库。
+     */
     @GetMapping(value = "/getConfigByKey/{configKey:.+}")
-    public AjaxResult getConfigByKey(@PathVariable String configKey) {
-        SysConfig config = configService.selectConfigByConfigKey(configKey);
-        return AjaxResult.success(config);
+    public AjaxResult getConfigByKey(@PathVariable String configKey,
+                                     @RequestParam(value = "tenantId", required = false) Long tenantId) {
+        return AjaxResult.success(tenantSysConfigService.getConfigByKey(tenantId, configKey));
     }
 
+    /**
+     * 按 configKey 新增或更新。body.tenantId 有则写租户库,无则写主库;写后同步 Redis。
+     */
     @PostMapping(value = "/updateConfigByKey")
     @Log(title = "更改参数", businessType = BusinessType.UPDATE)
     @RepeatSubmit
     public AjaxResult updateConfigByKey(@Validated @RequestBody SysConfig config) {
         config.setCreateBy(SecurityUtils.getUsername());
-        //修复只能更新的BUG
-        if (null != config.getConfigId()) {
-            return toAjax(configService.updateConfig(config));
-        } else {
-            return toAjax(configService.insertConfig(config));
-        }
-
+        return toAjax(tenantSysConfigService.updateConfigByKey(config));
     }
 
 

+ 106 - 157
fs-admin/src/main/java/com/fs/web/controller/system/SysKeywordController.java

@@ -1,157 +1,106 @@
-package com.fs.web.controller.system;
-
-import com.fs.common.annotation.Log;
-import com.fs.common.core.controller.BaseController;
-import com.fs.common.core.domain.AjaxResult;
-import com.fs.common.core.domain.R;
-import com.fs.common.core.redis.RedisCache;
-import com.fs.common.enums.BusinessType;
-import com.fs.common.utils.poi.ExcelUtil;
-import com.fs.system.domain.SysKeyword;
-import com.fs.system.service.ISysKeywordService;
-import com.github.pagehelper.PageHelper;
-import com.github.pagehelper.PageInfo;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.util.CollectionUtils;
-import org.springframework.web.bind.annotation.*;
-
-import javax.annotation.PostConstruct;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * 系统关键字Controller
- *
- * @author fs
- * @date 2025-05-14
- */
-@RestController
-@RequestMapping("/system/keyword")
-public class SysKeywordController extends BaseController
-{
-    @Autowired
-    private ISysKeywordService sysKeywordService;
-
-    @Autowired
-    RedisCache redisCache;
-
-    @Autowired
-    public RedisTemplate<String,String> redisTemplate;
-
-    private static final String REDIS_KEY = "sys:keywords";
-
-    /**
-     * 查询系统关键字列表
-     */
-    @PreAuthorize("@ss.hasPermi('system:keyword:list')")
-    @GetMapping("/list")
-    public R list(SysKeyword sysKeyword)
-    {
-//        startPage();
-        PageHelper.startPage(sysKeyword.getPageNum(), sysKeyword.getPageSize());
-        List<SysKeyword> list = sysKeywordService.selectSysKeywordList(sysKeyword);
-
-        PageInfo<SysKeyword> pageInfo = new PageInfo<>(list);
-        Map<String, Object> result = new HashMap<>();
-        result.put("rows", pageInfo.getList());
-        result.put("total", pageInfo.getTotal());
-        return R.ok(result);
-    }
-
-    /**
-     * 导出系统关键字列表
-     */
-    @PreAuthorize("@ss.hasPermi('system:keyword:export')")
-    @Log(title = "系统关键字", businessType = BusinessType.EXPORT)
-    @GetMapping("/export")
-    public AjaxResult export(SysKeyword sysKeyword)
-    {
-        List<SysKeyword> list = sysKeywordService.selectSysKeywordList(sysKeyword);
-        ExcelUtil<SysKeyword> util = new ExcelUtil<SysKeyword>(SysKeyword.class);
-        return util.exportExcel(list, "系统关键字数据");
-    }
-
-    /**
-     * 获取系统关键字详细信息
-     */
-    @PreAuthorize("@ss.hasPermi('system:keyword:query')")
-    @GetMapping(value = "/{keywordId}")
-    public AjaxResult getInfo(@PathVariable("keywordId") Long keywordId)
-    {
-        return AjaxResult.success(sysKeywordService.selectSysKeywordById(keywordId));
-    }
-
-    /**
-     * 新增系统关键字
-     */
-    @PreAuthorize("@ss.hasPermi('system:keyword:add')")
-    @Log(title = "系统关键字", businessType = BusinessType.INSERT)
-    @PostMapping
-    public AjaxResult add(@RequestBody SysKeyword sysKeyword)
-    {
-        int i = sysKeywordService.insertSysKeyword(sysKeyword);
-        // 缓存
-        redisTemplate.opsForSet().add(REDIS_KEY, sysKeyword.getKeyword());
-        return toAjax(i);
-    }
-
-    /**
-     * 修改系统关键字
-     */
-    @PreAuthorize("@ss.hasPermi('system:keyword:edit')")
-    @Log(title = "系统关键字", businessType = BusinessType.UPDATE)
-    @PutMapping
-    public AjaxResult edit(@RequestBody SysKeyword sysKeyword)
-    {
-        //获取之前的数据
-        SysKeyword sysKeywordOld = sysKeywordService.selectSysKeywordById(sysKeyword.getKeywordId());
-        String keywordOld = sysKeywordOld.getKeyword();
-        int i = sysKeywordService.updateSysKeyword(sysKeyword);
-        // 更新缓存
-        redisTemplate.opsForSet().remove(REDIS_KEY, keywordOld);
-        redisTemplate.opsForSet().add(REDIS_KEY, sysKeyword.getKeyword());
-        return toAjax(i);
-    }
-
-    /**
-     * 删除系统关键字
-     */
-    @PreAuthorize("@ss.hasPermi('system:keyword:remove')")
-    @Log(title = "系统关键字", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{keywordIds}")
-    public AjaxResult remove(@PathVariable Long[] keywordIds)
-    {
-        List<SysKeyword> sysKeywords = sysKeywordService.selectSysKeywordByIds(keywordIds);
-        int i = sysKeywordService.deleteSysKeywordByIds(keywordIds);
-        if (!CollectionUtils.isEmpty(sysKeywords)) {
-            redisTemplate.opsForSet().remove(REDIS_KEY, sysKeywords.stream().map(SysKeyword::getKeyword).toArray(String[]::new));
-        }
-        return toAjax(i);
-    }
-
-    /**
-     * 启动加载全部关键字到缓存
-     */
-    @PostConstruct
-    public void initKeywords() {
-        try {
-            SysKeyword sysKeywordParam = new SysKeyword();
-            List<SysKeyword> sysKeywords = sysKeywordService.selectSysKeywordList(sysKeywordParam);
-            List<String> keywords = sysKeywords.stream()
-                    .map(SysKeyword::getKeyword)
-                    .filter(k -> k != null && !k.isEmpty())
-                    .collect(Collectors.toList());
-
-            if (!keywords.isEmpty()) {
-                redisTemplate.opsForSet().add(REDIS_KEY, keywords.toArray(new String[0]));
-            }
-            System.out.println("加载全部关键字到缓存");
-        } catch (Exception e) {
-            System.err.println("警告: 加载关键字到Redis缓存失败,应用继续启动: " + e.getMessage());
-        }
-    }
-}
+package com.fs.web.controller.system;

+

+import com.fs.common.annotation.Log;

+import com.fs.common.core.controller.BaseController;

+import com.fs.common.core.domain.AjaxResult;

+import com.fs.common.core.domain.R;

+import com.fs.common.enums.BusinessType;

+import com.fs.common.utils.poi.ExcelUtil;

+import com.fs.system.domain.SysKeyword;

+import com.fs.system.service.ISysKeywordService;

+import com.github.pagehelper.PageHelper;

+import com.github.pagehelper.PageInfo;

+import org.springframework.beans.factory.annotation.Autowired;

+import org.springframework.security.access.prepost.PreAuthorize;

+import org.springframework.web.bind.annotation.*;

+

+import java.util.HashMap;

+import java.util.List;

+import java.util.Map;

+

+/**

+ * 系统关键字Controller

+ *

+ * @author fs

+ * @date 2025-05-14

+ */

+@RestController

+@RequestMapping("/system/keyword")

+public class SysKeywordController extends BaseController

+{

+    @Autowired

+    private ISysKeywordService sysKeywordService;

+

+    /**

+     * 查询系统关键字列表

+     */

+    @PreAuthorize("@ss.hasPermi('system:keyword:list')")

+    @GetMapping("/list")

+    public R list(SysKeyword sysKeyword)

+    {

+        PageHelper.startPage(sysKeyword.getPageNum(), sysKeyword.getPageSize());

+        List<SysKeyword> list = sysKeywordService.selectSysKeywordList(sysKeyword);

+

+        PageInfo<SysKeyword> pageInfo = new PageInfo<>(list);

+        Map<String, Object> result = new HashMap<>();

+        result.put("rows", pageInfo.getList());

+        result.put("total", pageInfo.getTotal());

+        return R.ok(result);

+    }

+

+    /**

+     * 导出系统关键字列表

+     */

+    @PreAuthorize("@ss.hasPermi('system:keyword:export')")

+    @Log(title = "系统关键字", businessType = BusinessType.EXPORT)

+    @GetMapping("/export")

+    public AjaxResult export(SysKeyword sysKeyword)

+    {

+        List<SysKeyword> list = sysKeywordService.selectSysKeywordList(sysKeyword);

+        ExcelUtil<SysKeyword> util = new ExcelUtil<SysKeyword>(SysKeyword.class);

+        return util.exportExcel(list, "系统关键字数据");

+    }

+

+    /**

+     * 获取系统关键字详细信息

+     */

+    @PreAuthorize("@ss.hasPermi('system:keyword:query')")

+    @GetMapping(value = "/{keywordId}")

+    public AjaxResult getInfo(@PathVariable("keywordId") Long keywordId)

+    {

+        return AjaxResult.success(sysKeywordService.selectSysKeywordById(keywordId));

+    }

+

+    /**

+     * 新增系统关键字

+     */

+    @PreAuthorize("@ss.hasPermi('system:keyword:add')")

+    @Log(title = "系统关键字", businessType = BusinessType.INSERT)

+    @PostMapping

+    public AjaxResult add(@RequestBody SysKeyword sysKeyword)

+    {

+        return toAjax(sysKeywordService.insertSysKeyword(sysKeyword));

+    }

+

+    /**

+     * 修改系统关键字

+     */

+    @PreAuthorize("@ss.hasPermi('system:keyword:edit')")

+    @Log(title = "系统关键字", businessType = BusinessType.UPDATE)

+    @PutMapping

+    public AjaxResult edit(@RequestBody SysKeyword sysKeyword)

+    {

+        return toAjax(sysKeywordService.updateSysKeyword(sysKeyword));

+    }

+

+    /**

+     * 删除系统关键字

+     */

+    @PreAuthorize("@ss.hasPermi('system:keyword:remove')")

+    @Log(title = "系统关键字", businessType = BusinessType.DELETE)

+	@DeleteMapping("/{keywordIds}")

+    public AjaxResult remove(@PathVariable Long[] keywordIds)

+    {

+        return toAjax(sysKeywordService.deleteSysKeywordByIds(keywordIds));

+    }

+}


+ 9 - 4
fs-admin/src/main/resources/logback.xml

@@ -68,10 +68,15 @@
 	<!-- Spring日志级别控制  -->
 	<logger name="org.springframework" level="warn" />
 
-    <!-- log4j2.xml -->
-    <Logger name="com.fs.his.mapper" level="debug"/>
-    <Logger name="com.fs.company.mapper" level="debug"/>
-    <Logger name="org.apache.ibatis" level="debug"/>
+	<!-- MyBatis SQL调试日志 -->
+	<logger name="com.fs.his.mapper" level="debug" />
+	<logger name="com.fs.company.mapper" level="debug" />
+	<logger name="com.fs.admin.mapper" level="debug" />
+	<logger name="com.fs.tenant.mapper" level="debug" />
+	<logger name="com.fs.fee.mapper" level="debug" />
+	<logger name="org.apache.ibatis" level="debug" />
+	<logger name="com.baomidou.mybatisplus" level="debug" />
+	<logger name="com.fs.framework.datasource" level="debug" />
 
 
     <root level="info">

+ 30 - 0
fs-common/src/main/java/com/fs/common/config/RedisConfig.java

@@ -3,17 +3,22 @@ package com.fs.common.config;
 import com.fasterxml.jackson.annotation.JsonAutoDetect;
 import com.fasterxml.jackson.annotation.PropertyAccessor;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.cache.CacheManager;
 import org.springframework.cache.annotation.CachingConfigurerSupport;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Primary;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.core.script.DefaultRedisScript;
 import org.springframework.data.redis.serializer.GenericToStringSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
 import org.springframework.data.redis.serializer.RedisSerializer;
 
 import java.math.BigDecimal;
+import java.time.Duration;
 
 /**
  * Redis 配置类
@@ -38,6 +43,31 @@ public class RedisConfig extends CachingConfigurerSupport {
         return new TenantKeyRedisSerializer();
     }
 
+    /**
+     * Spring Cache(@Cacheable / @CacheEvict)使用与 RedisTemplate 相同的租户 Key 前缀
+     */
+    @Bean
+    public CacheManager cacheManager(
+            RedisConnectionFactory connectionFactory,
+            RedisSerializer<String> tenantKeySerializer) {
+        FastJson2JsonRedisSerializer<Object> valueSerializer =
+                new FastJson2JsonRedisSerializer<>(Object.class);
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
+        valueSerializer.setObjectMapper(mapper);
+
+        RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
+                .entryTtl(Duration.ofDays(7))
+                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(tenantKeySerializer))
+                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer))
+                .disableCachingNullValues();
+
+        return RedisCacheManager.builder(connectionFactory)
+                .cacheDefaults(cacheConfiguration)
+                .build();
+    }
+
     /**
      * 通用 RedisTemplate(Object -> Object)
      * 用于大多数业务缓存场景

+ 39 - 0
fs-common/src/main/java/com/fs/common/core/domain/entity/SysDictData.java

@@ -52,6 +52,15 @@ public class SysDictData extends BaseEntity
     @Excel(name = "状态", readConverterExp = "0=正常,1=停用")
     private String status;
 
+    /** 来源 platform/tenant */
+    private String dictSource;
+
+    /** 是否平台管控 1是 0否 */
+    private Integer isPlatformManaged;
+
+    /** 租户ID(总后台查询用,非表字段) */
+    private Long tenantId;
+
     public Long getDictCode()
     {
         return dictCode;
@@ -153,6 +162,36 @@ public class SysDictData extends BaseEntity
     {
         this.status = status;
     }
+
+    public String getDictSource()
+    {
+        return dictSource;
+    }
+
+    public void setDictSource(String dictSource)
+    {
+        this.dictSource = dictSource;
+    }
+
+    public Integer getIsPlatformManaged()
+    {
+        return isPlatformManaged;
+    }
+
+    public void setIsPlatformManaged(Integer isPlatformManaged)
+    {
+        this.isPlatformManaged = isPlatformManaged;
+    }
+
+    public Long getTenantId()
+    {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId)
+    {
+        this.tenantId = tenantId;
+    }
     
     @Override
     public String toString() {

+ 39 - 0
fs-common/src/main/java/com/fs/common/core/domain/entity/SysDictType.java

@@ -33,6 +33,15 @@ public class SysDictType extends BaseEntity
     @Excel(name = "状态", readConverterExp = "0=正常,1=停用")
     private String status;
 
+    /** 来源 platform/tenant */
+    private String dictSource;
+
+    /** 是否平台管控 1是 0否 */
+    private Integer isPlatformManaged;
+
+    /** 租户ID(总后台查询用,非表字段) */
+    private Long tenantId;
+
     public Long getDictId()
     {
         return dictId;
@@ -76,6 +85,36 @@ public class SysDictType extends BaseEntity
     {
         this.status = status;
     }
+
+    public String getDictSource()
+    {
+        return dictSource;
+    }
+
+    public void setDictSource(String dictSource)
+    {
+        this.dictSource = dictSource;
+    }
+
+    public Integer getIsPlatformManaged()
+    {
+        return isPlatformManaged;
+    }
+
+    public void setIsPlatformManaged(Integer isPlatformManaged)
+    {
+        this.isPlatformManaged = isPlatformManaged;
+    }
+
+    public Long getTenantId()
+    {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId)
+    {
+        this.tenantId = tenantId;
+    }
     
     @Override
     public String toString() {

+ 15 - 15
fs-service/src/main/java/com/fs/admin/helper/AdminCrossTenantHelper.java

@@ -41,20 +41,20 @@ public class AdminCrossTenantHelper {
     /**
      * 获取过滤后的启用租户列表
      *
-     * @param companyId   指定租户ID(来自InlineTenantSelector),为null则不过滤
-     * @param companyName 租户名称模糊搜索,为null/空则不过滤
+     * @param tenantId   指定租户ID(来自InlineTenantSelector),为null则不过滤
+     * @param tenantName 租户名称模糊搜索,为null/空则不过滤
      * @return 过滤后的租户列表
      */
-    public List<TenantInfo> getFilteredTenants(Long companyId, String companyName) {
+    public List<TenantInfo> getFilteredTenants(Long tenantId, String tenantName) {
         DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
         try {
             List<TenantInfo> tenants = tenantInfoService.selectTenantInfoList(new TenantInfo());
             tenants.removeIf(t -> t.getStatus() == null || t.getStatus() != 1);
-            if (companyId != null) {
-                tenants.removeIf(t -> !t.getId().equals(companyId));
+            if (tenantId != null) {
+                tenants.removeIf(t -> !t.getId().equals(tenantId));
             }
-            if (companyName != null && !companyName.isEmpty()) {
-                String search = companyName.toLowerCase();
+            if (tenantName != null && !tenantName.isEmpty()) {
+                String search = tenantName.toLowerCase();
                 tenants.removeIf(t -> t.getTenantName() == null ||
                         !t.getTenantName().toLowerCase().contains(search));
             }
@@ -67,15 +67,15 @@ public class AdminCrossTenantHelper {
     /**
      * 跨租户查询并聚合结果(使用回调函数构建每租户SQL)
      *
-     * @param companyId   指定租户ID(可选)
-     * @param companyName 租户名称模糊搜索(可选)
+     * @param tenantId   指定租户ID(可选)
+     * @param tenantName 租户名称模糊搜索(可选)
      * @param queryFunc   查询回调:(TenantInfo, JdbcTemplate) → 该租户的数据行
      * @return 聚合结果,每行自动附加 companyId / companyName / tenantCode
      */
-    public List<Map<String, Object>> queryAcrossTenants(Long companyId, String companyName,
+    public List<Map<String, Object>> queryAcrossTenants(Long tenantId, String tenantName,
                                                         BiFunction<TenantInfo, JdbcTemplate, List<Map<String, Object>>> queryFunc) {
         List<Map<String, Object>> allList = new ArrayList<>();
-        List<TenantInfo> tenants = getFilteredTenants(companyId, companyName);
+        List<TenantInfo> tenants = getFilteredTenants(tenantId, tenantName);
 
         for (TenantInfo tenant : tenants) {
             try {
@@ -105,15 +105,15 @@ public class AdminCrossTenantHelper {
      * <p>
      * 便捷方法:直接传入SQL和参数,自动遍历每个租户执行。
      *
-     * @param companyId   指定租户ID(可选)
-     * @param companyName 租户名称模糊搜索(可选)
+     * @param tenantId   指定租户ID(可选)
+     * @param tenantName 租户名称模糊搜索(可选)
      * @param sql         查询SQL(SELECT ... FROM ... WHERE ...)
      * @param args        SQL参数
      * @return 聚合结果,每行自动附加 companyId / companyName / tenantCode
      */
-    public List<Map<String, Object>> queryAcrossTenants(Long companyId, String companyName,
+    public List<Map<String, Object>> queryAcrossTenants(Long tenantId, String tenantName,
                                                         String sql, Object... args) {
-        return queryAcrossTenants(companyId, companyName, (tenant, jdbc) -> {
+        return queryAcrossTenants(tenantId, tenantName, (tenant, jdbc) -> {
             if (args == null || args.length == 0) {
                 return jdbc.queryForList(sql);
             }

+ 9 - 3
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchCommentServiceImpl.java

@@ -7,6 +7,7 @@ import java.util.stream.Collectors;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.course.param.FsCourseWatchCommentListParam;
 import com.fs.course.param.FsCourseWatchCommentPageParam;
@@ -14,6 +15,7 @@ import com.fs.course.param.FsCourseWatchCommentSaveParam;
 import com.fs.course.vo.FsCourseWatchCommentListVO;
 import com.fs.course.vo.FsCourseWatchCommentVO;
 import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.system.constant.SysKeywordConstants;
 import com.fs.system.domain.SysKeyword;
 import com.fs.system.mapper.SysKeywordMapper;
 import org.springframework.beans.BeanUtils;
@@ -33,7 +35,7 @@ import com.fs.course.service.IFsCourseWatchCommentService;
 @Service
 public class FsCourseWatchCommentServiceImpl extends ServiceImpl<FsCourseWatchCommentMapper, FsCourseWatchComment> implements IFsCourseWatchCommentService {
 
-    private static final String REDIS_KEY = "sys:keywords";
+    private static final String REDIS_KEY = SysKeywordConstants.redisKey(SysKeywordConstants.TYPE_COURSE_COMMENT);
 
     @Autowired
     RedisCache redisCache;
@@ -127,12 +129,16 @@ public class FsCourseWatchCommentServiceImpl extends ServiceImpl<FsCourseWatchCo
         Set<String>  keywords = redisTemplate.opsForSet().members(REDIS_KEY);
         if(keywords == null || keywords.isEmpty()){
             SysKeyword sysKeywordParam = new SysKeyword();
+            sysKeywordParam.setKeywordType(SysKeywordConstants.TYPE_COURSE_COMMENT);
             List<SysKeyword> sysKeywords = mapper.selectSysKeywordList(sysKeywordParam);
-            keywords = sysKeywords.stream().map(SysKeyword::getKeyword).collect(Collectors.toSet());
+            keywords = sysKeywords.stream()
+                    .map(SysKeyword::getKeyword)
+                    .filter(StringUtils::isNotEmpty)
+                    .collect(Collectors.toSet());
         }
         if(!keywords.isEmpty()){
             for (String keyword : keywords) {
-                if (param.getContent().contains(keyword)) {
+                if (StringUtils.isNotEmpty(keyword) && param.getContent().contains(keyword)) {
                     //标记用户为黑名单,并且不保存数据
                     qwExternalContactMapper.updateQwExternalContactByFsUserId(1, param.getUserId());
                     return R.ok().put("status", false);

+ 10 - 0
fs-service/src/main/java/com/fs/crm/param/CrmLineCustomerListQueryParam.java

@@ -85,4 +85,14 @@ public class CrmLineCustomerListQueryParam extends BaseQueryParam
     @Excel(name = "标签" )
     private String tags;
 
+    /**
+     * 等级
+     */
+    private String attritionLevel;
+
+    /**
+     * 意向度
+     */
+    @Excel(name = "意向度")
+    private String intentionDegree;
 }

+ 23 - 0
fs-service/src/main/java/com/fs/system/constant/SysKeywordConstants.java

@@ -0,0 +1,23 @@
+package com.fs.system.constant;
+
+/**
+ * 系统关键字常量
+ */
+public final class SysKeywordConstants {
+
+    private SysKeywordConstants() {
+    }
+
+    /** 字典类型 */
+    public static final String DICT_TYPE = "keyword_type";
+
+    /** 看课弹幕 */
+    public static final int TYPE_COURSE_COMMENT = 1;
+
+    public static final String REDIS_KEY_PREFIX = "sys:keywords:";
+
+    public static String redisKey(Integer keywordType) {
+        int type = keywordType != null ? keywordType : TYPE_COURSE_COMMENT;
+        return REDIS_KEY_PREFIX + type;
+    }
+}

+ 13 - 0
fs-service/src/main/java/com/fs/system/domain/SysConfig.java

@@ -37,6 +37,19 @@ public class SysConfig extends BaseEntity
     @Excel(name = "系统内置", readConverterExp = "Y=是,N=否")
     private String configType;
 
+    /** 租户ID(非表字段,多租户读写 sys_config 时使用) */
+    private Long tenantId;
+
+    public Long getTenantId()
+    {
+        return tenantId;
+    }
+
+    public void setTenantId(Long tenantId)
+    {
+        this.tenantId = tenantId;
+    }
+
     public Long getConfigId()
     {
         return configId;

+ 14 - 3
fs-service/src/main/java/com/fs/system/domain/SysKeyword.java

@@ -29,14 +29,25 @@ public class SysKeyword extends BaseEntity{
     @Excel(name = "关键字")
     private String keyword;
 
-//    /** 类型:1-看课弹幕; */
-//    @Excel(name = "类型:1-看课弹幕;")
-//    private Integer type;
+    /**
+     * 关键字内容(前端字段 keywordContent,可与 keyword 二选一)
+     */
+    private String keywordContent;
+
+    /**
+     * 关键字类型,字典 keyword_type(1-看课弹幕等)
+     */
+    @Excel(name = "关键字类型", dictType = "keyword_type")
+    private Integer keywordType;
 
     /** 所属公司 */
     @Excel(name = "所属公司")
     private Long companyId;
 
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+
     @TableField(exist = false)
     @Excel(name = "类型名称")
     private String typeName;

+ 102 - 22
fs-service/src/main/java/com/fs/system/service/impl/SysKeywordServiceImpl.java

@@ -1,12 +1,22 @@
 package com.fs.system.service.impl;
 
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.system.constant.SysKeywordConstants;
 import com.fs.system.domain.SysKeyword;
 import com.fs.system.mapper.SysKeywordMapper;
 import com.fs.system.service.ISysKeywordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import javax.annotation.PostConstruct;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 系统关键字Service业务层处理
@@ -17,11 +27,11 @@ import java.util.List;
 @Service
 public class SysKeywordServiceImpl extends ServiceImpl<SysKeywordMapper, SysKeyword> implements ISysKeywordService {
 
+    @Autowired
+    private RedisTemplate<String, String> redisTemplate;
+
     /**
      * 查询系统关键字
-     *
-     * @param keywordId 系统关键字主键
-     * @return 系统关键字
      */
     @Override
     public SysKeyword selectSysKeywordById(Long keywordId)
@@ -31,9 +41,6 @@ public class SysKeywordServiceImpl extends ServiceImpl<SysKeywordMapper, SysKeyw
 
     /**
      * 查询系统关键字列表
-     *
-     * @param sysKeyword 系统关键字
-     * @return 系统关键字
      */
     @Override
     public List<SysKeyword> selectSysKeywordList(SysKeyword sysKeyword)
@@ -43,56 +50,129 @@ public class SysKeywordServiceImpl extends ServiceImpl<SysKeywordMapper, SysKeyw
 
     /**
      * 新增系统关键字
-     *
-     * @param sysKeyword 系统关键字
-     * @return 结果
      */
     @Override
     public int insertSysKeyword(SysKeyword sysKeyword)
     {
+        normalize(sysKeyword);
+        validate(sysKeyword);
         sysKeyword.setCreateTime(DateUtils.getNowDate());
-        return baseMapper.insertSysKeyword(sysKeyword);
+        int rows = baseMapper.insertSysKeyword(sysKeyword);
+        if (rows > 0) {
+            addKeywordToCache(sysKeyword.getKeyword(), sysKeyword.getKeywordType());
+        }
+        return rows;
     }
 
     /**
      * 修改系统关键字
-     *
-     * @param sysKeyword 系统关键字
-     * @return 结果
      */
     @Override
     public int updateSysKeyword(SysKeyword sysKeyword)
     {
+        normalize(sysKeyword);
+        validate(sysKeyword);
+        SysKeyword old = baseMapper.selectSysKeywordById(sysKeyword.getKeywordId());
         sysKeyword.setUpdateTime(DateUtils.getNowDate());
-        return baseMapper.updateSysKeyword(sysKeyword);
+        int rows = baseMapper.updateSysKeyword(sysKeyword);
+        if (rows > 0 && old != null) {
+            removeKeywordFromCache(old.getKeyword(), old.getKeywordType());
+            addKeywordToCache(sysKeyword.getKeyword(), sysKeyword.getKeywordType());
+        }
+        return rows;
     }
 
     /**
      * 批量删除系统关键字
-     *
-     * @param keywordIds 需要删除的系统关键字主键
-     * @return 结果
      */
     @Override
     public int deleteSysKeywordByIds(Long[] keywordIds)
     {
-        return baseMapper.deleteSysKeywordByIds(keywordIds);
+        List<SysKeyword> sysKeywords = baseMapper.selectSysKeywordByIds(keywordIds);
+        int rows = baseMapper.deleteSysKeywordByIds(keywordIds);
+        if (rows > 0 && !CollectionUtils.isEmpty(sysKeywords)) {
+            sysKeywords.forEach(k -> removeKeywordFromCache(k.getKeyword(), k.getKeywordType()));
+        }
+        return rows;
     }
 
     /**
      * 删除系统关键字信息
-     *
-     * @param keywordId 系统关键字主键
-     * @return 结果
      */
     @Override
     public int deleteSysKeywordById(Long keywordId)
     {
-        return baseMapper.deleteSysKeywordById(keywordId);
+        SysKeyword old = baseMapper.selectSysKeywordById(keywordId);
+        int rows = baseMapper.deleteSysKeywordById(keywordId);
+        if (rows > 0 && old != null) {
+            removeKeywordFromCache(old.getKeyword(), old.getKeywordType());
+        }
+        return rows;
     }
 
     @Override
     public List<SysKeyword> selectSysKeywordByIds(Long[] keywordIds) {
         return baseMapper.selectSysKeywordByIds(keywordIds);
     }
+
+    /**
+     * 启动时按类型加载关键字到 Redis
+     */
+    @PostConstruct
+    public void initKeywordsCache() {
+        try {
+            List<SysKeyword> sysKeywords = baseMapper.selectSysKeywordList(new SysKeyword());
+            Map<Integer, List<String>> grouped = sysKeywords.stream()
+                    .filter(k -> StringUtils.isNotEmpty(k.getKeyword()))
+                    .collect(Collectors.groupingBy(
+                            k -> k.getKeywordType() != null ? k.getKeywordType() : SysKeywordConstants.TYPE_COURSE_COMMENT,
+                            Collectors.mapping(SysKeyword::getKeyword, Collectors.toList())
+                    ));
+            grouped.forEach((type, keywords) -> {
+                String redisKey = SysKeywordConstants.redisKey(type);
+                redisTemplate.delete(redisKey);
+                if (!keywords.isEmpty()) {
+                    redisTemplate.opsForSet().add(redisKey, keywords.toArray(new String[0]));
+                }
+            });
+        } catch (Exception e) {
+            System.err.println("警告: 加载关键字到Redis缓存失败,应用继续启动: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 归一化前端字段:keywordContent 可回填 keyword,默认类型为看课弹幕
+     */
+    private void normalize(SysKeyword sysKeyword) {
+        if (StringUtils.isEmpty(sysKeyword.getKeyword()) && StringUtils.isNotEmpty(sysKeyword.getKeywordContent())) {
+            sysKeyword.setKeyword(sysKeyword.getKeywordContent().trim());
+        }
+        if (sysKeyword.getKeywordType() == null) {
+            sysKeyword.setKeywordType(SysKeywordConstants.TYPE_COURSE_COMMENT);
+        }
+        if (StringUtils.isNotEmpty(sysKeyword.getKeyword())) {
+            sysKeyword.setKeyword(sysKeyword.getKeyword().trim());
+        }
+    }
+
+    private void validate(SysKeyword sysKeyword) {
+        if (StringUtils.isEmpty(sysKeyword.getKeyword())) {
+            throw new ServiceException("关键字不能为空");
+        }
+        if (sysKeyword.getKeywordType() == null) {
+            throw new ServiceException("关键字类型不能为空");
+        }
+    }
+
+    private void addKeywordToCache(String keyword, Integer keywordType) {
+        if (StringUtils.isNotEmpty(keyword)) {
+            redisTemplate.opsForSet().add(SysKeywordConstants.redisKey(keywordType), keyword);
+        }
+    }
+
+    private void removeKeywordFromCache(String keyword, Integer keywordType) {
+        if (StringUtils.isNotEmpty(keyword)) {
+            redisTemplate.opsForSet().remove(SysKeywordConstants.redisKey(keywordType), keyword);
+        }
+    }
 }

+ 31 - 0
fs-service/src/main/java/com/fs/tenant/config/SysConfigCacheDelegate.java

@@ -0,0 +1,31 @@
+package com.fs.tenant.config;
+
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+/**
+ * 参数配置 Redis 缓存代理(须独立 Bean,@Cacheable 才生效)。
+ * 租户前缀由 {@link com.fs.common.config.RedisTenantContext} + {@link com.fs.common.config.TenantKeyRedisSerializer} 处理。
+ */
+@Service
+public class SysConfigCacheDelegate {
+
+    public static final String CACHE_NAME = "sysConfigByKey";
+
+    @Autowired
+    private SysConfigMapper configMapper;
+
+    @Cacheable(value = CACHE_NAME, key = "#configKey", unless = "#result == null")
+    public SysConfig loadFromDb(String configKey) {
+        return configMapper.selectConfigByConfigKey(configKey);
+    }
+
+    @CacheEvict(value = CACHE_NAME, key = "#configKey")
+    public void evict(String configKey) {
+        // AOP 清理缓存 todo
+    }
+}

+ 19 - 0
fs-service/src/main/java/com/fs/tenant/config/service/TenantSysConfigService.java

@@ -0,0 +1,19 @@
+package com.fs.tenant.config.service;
+
+import com.fs.system.domain.SysConfig;
+
+/**
+ * 多租户参数配置(总后台按 tenantId 读写租户库/主库,带 Redis 缓存)
+ */
+public interface TenantSysConfigService {
+
+    /**
+     * 按 configKey 查询配置。tenantId 为空查主库,否则查对应租户库。
+     */
+    SysConfig getConfigByKey(Long tenantId, String configKey);
+
+    /**
+     * 按 configKey 新增或更新。config.tenantId 为空则操作主库,否则操作对应租户库。
+     */
+    int updateConfigByKey(SysConfig config);
+}

+ 94 - 0
fs-service/src/main/java/com/fs/tenant/config/service/impl/TenantSysConfigServiceImpl.java

@@ -0,0 +1,94 @@
+package com.fs.tenant.config.service.impl;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.constant.UserConstants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import com.fs.tenant.config.SysConfigCacheDelegate;
+import com.fs.tenant.config.service.TenantSysConfigService;
+import com.fs.tenant.dict.helper.TenantDictContextHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 多租户 sys_config 读写:统一切库 / Redis 租户上下文,查询走 @Cacheable,更新后驱逐并回写值缓存。
+ */
+@Service
+public class TenantSysConfigServiceImpl implements TenantSysConfigService {
+
+    @Autowired
+    private TenantDictContextHelper contextHelper;
+
+    @Autowired
+    private SysConfigCacheDelegate configCacheDelegate;
+
+    @Autowired
+    private SysConfigMapper configMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public SysConfig getConfigByKey(Long tenantId, String configKey) {
+        if (StringUtils.isEmpty(configKey)) {
+            throw new ServiceException("参数键名不能为空");
+        }
+        if (tenantId == null) {
+            return contextHelper.executeInMaster(() -> configCacheDelegate.loadFromDb(configKey));
+        }
+        return contextHelper.executeInTenant(tenantId, () -> configCacheDelegate.loadFromDb(configKey));
+    }
+
+    @Override
+    public int updateConfigByKey(SysConfig config) {
+        if (config == null || StringUtils.isEmpty(config.getConfigKey())) {
+            throw new ServiceException("参数键名不能为空");
+        }
+        Long tenantId = config.getTenantId();
+        if (tenantId == null) {
+            return contextHelper.executeInMaster(() -> saveOrUpdateInContext(config));
+        }
+        return contextHelper.executeInTenant(tenantId, () -> saveOrUpdateInContext(config));
+    }
+
+    private int saveOrUpdateInContext(SysConfig config) {
+        if (UserConstants.NOT_UNIQUE.equals(checkConfigKeyUnique(config))) {
+            throw new ServiceException("参数键名'" + config.getConfigKey() + "'已存在");
+        }
+        int row;
+        if (config.getConfigId() != null) {
+            row = configMapper.updateConfig(config);
+        } else {
+            row = configMapper.insertConfig(config);
+        }
+        if (row > 0) {
+            syncRedisAfterWrite(config.getConfigKey(), config.getConfigValue());
+        }
+        return row;
+    }
+
+    private void syncRedisAfterWrite(String configKey, String configValue) {
+        configCacheDelegate.evict(configKey);
+        if (StringUtils.isNotEmpty(configValue)) {
+            redisCache.setCacheObject(valueCacheKey(configKey), configValue);
+        } else {
+            redisCache.deleteObject(valueCacheKey(configKey));
+        }
+    }
+
+    private String checkConfigKeyUnique(SysConfig config) {
+        Long configId = StringUtils.isNull(config.getConfigId()) ? -1L : config.getConfigId();
+        SysConfig info = configMapper.checkConfigKeyUnique(config.getConfigKey());
+        if (StringUtils.isNotNull(info) && info.getConfigId().longValue() != configId.longValue()) {
+            return UserConstants.NOT_UNIQUE;
+        }
+        return UserConstants.UNIQUE;
+    }
+
+    private static String valueCacheKey(String configKey) {
+        return Constants.SYS_CONFIG_KEY + configKey;
+    }
+}

+ 45 - 0
fs-service/src/main/java/com/fs/tenant/dict/constant/TenantDictConstants.java

@@ -0,0 +1,45 @@
+package com.fs.tenant.dict.constant;
+
+/**
+ * 租户字典模块常量
+ */
+public final class TenantDictConstants {
+
+    private TenantDictConstants() {
+    }
+
+    /** 字典来源:平台模板同步 */
+    public static final String SOURCE_PLATFORM = "platform";
+
+    /** 字典来源:租户/总后台手工维护 */
+    public static final String SOURCE_TENANT = "tenant";
+
+    /** 同步模式:仅追加缺失项 */
+    public static final String SYNC_APPEND = "APPEND";
+
+    /** 同步模式:合并更新平台管控项,保留租户扩展项 */
+    public static final String SYNC_MERGE = "MERGE";
+
+    /** 同步模式:按字典类型覆盖平台管控项(保留租户自有项) */
+    public static final String SYNC_OVERWRITE = "OVERWRITE";
+
+    /** 租户范围:全部启用租户 */
+    public static final String SCOPE_ALL = "ALL";
+
+    /** 租户范围:仅包含指定租户 */
+    public static final String SCOPE_INCLUDE = "INCLUDE";
+
+    /** 租户范围:排除指定租户 */
+    public static final String SCOPE_EXCLUDE = "EXCLUDE";
+
+    /** 任务状态 */
+    public static final String TASK_RUNNING = "RUNNING";
+    public static final String TASK_SUCCESS = "SUCCESS";
+    public static final String TASK_PARTIAL = "PARTIAL";
+    public static final String TASK_FAILED = "FAILED";
+
+    /** 明细状态 */
+    public static final String DETAIL_SUCCESS = "SUCCESS";
+    public static final String DETAIL_FAILED = "FAILED";
+    public static final String DETAIL_SKIPPED = "SKIPPED";
+}

+ 103 - 0
fs-service/src/main/java/com/fs/tenant/dict/domain/TenantDictTemplateData.java

@@ -0,0 +1,103 @@
+package com.fs.tenant.dict.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+
+/**
+ * 平台字典数据模板 tenant_dict_template_data(主库)
+ */
+public class TenantDictTemplateData extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long dictCode;
+    private Long dictSort;
+    private String dictLabel;
+    private String dictValue;
+    private String dictType;
+    private String cssClass;
+    private String listClass;
+    private String isDefault;
+    private String status;
+    /** 是否平台强制管控 1是 0否 */
+    private Integer isManaged;
+
+    public Long getDictCode() {
+        return dictCode;
+    }
+
+    public void setDictCode(Long dictCode) {
+        this.dictCode = dictCode;
+    }
+
+    public Long getDictSort() {
+        return dictSort;
+    }
+
+    public void setDictSort(Long dictSort) {
+        this.dictSort = dictSort;
+    }
+
+    public String getDictLabel() {
+        return dictLabel;
+    }
+
+    public void setDictLabel(String dictLabel) {
+        this.dictLabel = dictLabel;
+    }
+
+    public String getDictValue() {
+        return dictValue;
+    }
+
+    public void setDictValue(String dictValue) {
+        this.dictValue = dictValue;
+    }
+
+    public String getDictType() {
+        return dictType;
+    }
+
+    public void setDictType(String dictType) {
+        this.dictType = dictType;
+    }
+
+    public String getCssClass() {
+        return cssClass;
+    }
+
+    public void setCssClass(String cssClass) {
+        this.cssClass = cssClass;
+    }
+
+    public String getListClass() {
+        return listClass;
+    }
+
+    public void setListClass(String listClass) {
+        this.listClass = listClass;
+    }
+
+    public String getIsDefault() {
+        return isDefault;
+    }
+
+    public void setIsDefault(String isDefault) {
+        this.isDefault = isDefault;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+
+    public Integer getIsManaged() {
+        return isManaged;
+    }
+
+    public void setIsManaged(Integer isManaged) {
+        this.isManaged = isManaged;
+    }
+}

+ 68 - 0
fs-service/src/main/java/com/fs/tenant/dict/domain/TenantDictTemplateType.java

@@ -0,0 +1,68 @@
+package com.fs.tenant.dict.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+
+/**
+ * 平台字典类型模板 tenant_dict_template_type(主库)
+ */
+public class TenantDictTemplateType extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long dictId;
+    private String dictName;
+    private String dictType;
+    private String status;
+    /** 是否平台强制管控 1是 0否 */
+    private Integer isManaged;
+    /** 默认同步模式 APPEND/MERGE/OVERWRITE */
+    private String syncMode;
+
+    public Long getDictId() {
+        return dictId;
+    }
+
+    public void setDictId(Long dictId) {
+        this.dictId = dictId;
+    }
+
+    public String getDictName() {
+        return dictName;
+    }
+
+    public void setDictName(String dictName) {
+        this.dictName = dictName;
+    }
+
+    public String getDictType() {
+        return dictType;
+    }
+
+    public void setDictType(String dictType) {
+        this.dictType = dictType;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+
+    public Integer getIsManaged() {
+        return isManaged;
+    }
+
+    public void setIsManaged(Integer isManaged) {
+        this.isManaged = isManaged;
+    }
+
+    public String getSyncMode() {
+        return syncMode;
+    }
+
+    public void setSyncMode(String syncMode) {
+        this.syncMode = syncMode;
+    }
+}

+ 36 - 0
fs-service/src/main/java/com/fs/tenant/dict/dto/TenantDictSyncRunReq.java

@@ -0,0 +1,36 @@
+package com.fs.tenant.dict.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 租户字典同步请求
+ */
+@Data
+public class TenantDictSyncRunReq {
+
+    /** 同步模式 APPEND / MERGE / OVERWRITE */
+    private String syncMode;
+
+    /** 租户范围 ALL / INCLUDE / EXCLUDE */
+    private String scopeType;
+
+    /** 指定租户ID列表(INCLUDE/EXCLUDE 时使用) */
+    private List<Long> tenantIds;
+
+    /** 指定字典类型,空=全部模板类型 */
+    private List<String> dictTypes;
+
+    /** 是否并行执行 */
+    private Boolean parallel;
+
+    /** 并行线程数 */
+    private Integer threads;
+
+    /** 仅预览差异,不实际写入 */
+    private Boolean dryRun;
+
+    /** OVERWRITE 模式二次确认 */
+    private Boolean overwriteConfirm;
+}

+ 24 - 0
fs-service/src/main/java/com/fs/tenant/dict/dto/TenantDictTemplateImportReq.java

@@ -0,0 +1,24 @@
+package com.fs.tenant.dict.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 从平台主库/租户库导入字典到平台模板
+ */
+@Data
+public class TenantDictTemplateImportReq {
+
+    /** 租户库导入时必填 */
+    private Long tenantId;
+
+    /** 按字典类型导入(推荐) */
+    private List<String> dictTypes;
+
+    /** 平台主库按 dictId 导入(与 dictTypes 二选一) */
+    private List<Long> dictIds;
+
+    /** 模板已存在同类型时:true=覆盖类型+合并数据,false=跳过类型仅追加缺失数据项 */
+    private Boolean overwriteExisting;
+}

+ 95 - 0
fs-service/src/main/java/com/fs/tenant/dict/helper/TenantDictContextHelper.java

@@ -0,0 +1,95 @@
+package com.fs.tenant.dict.helper;
+
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.exception.CustomException;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.function.Supplier;
+
+/**
+ * 租户字典上下文工具:统一切库、Redis 租户前缀、资源释放。
+ * <p>
+ * 所有租户字典读写必须通过本类,避免数据源泄漏和 Redis 缓存串租户。
+ */
+@Component
+public class TenantDictContextHelper {
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
+    /**
+     * 在租户库上下文中执行(无返回值)
+     */
+    public void runInTenant(Long tenantId, Runnable action) {
+        executeInTenant(tenantId, () -> {
+            action.run();
+            return null;
+        });
+    }
+
+    /**
+     * 在租户库上下文中执行(有返回值)
+     */
+    public <T> T executeInTenant(Long tenantId, Supplier<T> action) {
+        TenantInfo tenant = loadActiveTenant(tenantId);
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            tenantDataSourceManager.switchTenant(tenant);
+            RedisTenantContext.setTenantId(tenantId);
+            return action.get();
+        } finally {
+            RedisTenantContext.clear();
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    /**
+     * 在主库上下文中执行
+     */
+    public <T> T executeInMaster(Supplier<T> action) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            return action.get();
+        } finally {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    public void runInMaster(Runnable action) {
+        executeInMaster(() -> {
+            action.run();
+            return null;
+        });
+    }
+
+    /**
+     * 校验并加载启用中的租户
+     */
+    public TenantInfo loadActiveTenant(Long tenantId) {
+        if (tenantId == null) {
+            throw new CustomException("租户ID不能为空");
+        }
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        TenantInfo tenant = tenantInfoMapper.selectTenantInfoById(String.valueOf(tenantId));
+        if (tenant == null) {
+            throw new CustomException("租户不存在: " + tenantId);
+        }
+        if (tenant.getStatus() != null && tenant.getStatus() == 2) {
+            throw new CustomException("租户初始化中,暂不可操作字典");
+        }
+        if (tenant.getStatus() == null || tenant.getStatus() != 1) {
+            throw new CustomException("租户未启用,暂不可操作字典");
+        }
+        return tenant;
+    }
+}

+ 42 - 0
fs-service/src/main/java/com/fs/tenant/dict/mapper/TenantDictTemplateMapper.java

@@ -0,0 +1,42 @@
+package com.fs.tenant.dict.mapper;
+
+import com.fs.tenant.dict.domain.TenantDictTemplateData;
+import com.fs.tenant.dict.domain.TenantDictTemplateType;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 平台字典模板 Mapper(主库 tenant_dict_template_*)
+ */
+public interface TenantDictTemplateMapper {
+
+    List<TenantDictTemplateType> selectTemplateTypeList(TenantDictTemplateType query);
+
+    TenantDictTemplateType selectTemplateTypeById(Long dictId);
+
+    TenantDictTemplateType selectTemplateTypeByType(String dictType);
+
+    int insertTemplateType(TenantDictTemplateType row);
+
+    int updateTemplateType(TenantDictTemplateType row);
+
+    int deleteTemplateTypeByIds(Long[] dictIds);
+
+    List<TenantDictTemplateData> selectTemplateDataList(TenantDictTemplateData query);
+
+    TenantDictTemplateData selectTemplateDataById(Long dictCode);
+
+    TenantDictTemplateData selectTemplateDataByTypeAndValue(@Param("dictType") String dictType,
+                                                            @Param("dictValue") String dictValue);
+
+    List<TenantDictTemplateData> selectTemplateDataByType(String dictType);
+
+    int insertTemplateData(TenantDictTemplateData row);
+
+    int updateTemplateData(TenantDictTemplateData row);
+
+    int deleteTemplateDataByIds(Long[] dictCodes);
+
+    int countTemplateDataByType(String dictType);
+}

+ 36 - 0
fs-service/src/main/java/com/fs/tenant/dict/service/TenantDictManageService.java

@@ -0,0 +1,36 @@
+package com.fs.tenant.dict.service;
+
+import com.fs.common.core.domain.entity.SysDictData;
+import com.fs.common.core.domain.entity.SysDictType;
+
+import java.util.List;
+
+/**
+ * 租户字典管理服务(方案1:总后台代管单个租户字典)
+ */
+public interface TenantDictManageService {
+
+    List<SysDictType> selectDictTypeList(Long tenantId, SysDictType query);
+
+    SysDictType selectDictTypeById(Long tenantId, Long dictId);
+
+    int insertDictType(Long tenantId, SysDictType dict);
+
+    int updateDictType(Long tenantId, SysDictType dict);
+
+    void deleteDictTypeByIds(Long tenantId, Long[] dictIds);
+
+    List<SysDictData> selectDictDataList(Long tenantId, SysDictData query);
+
+    SysDictData selectDictDataById(Long tenantId, Long dictCode);
+
+    int insertDictData(Long tenantId, SysDictData dict);
+
+    int updateDictData(Long tenantId, SysDictData dict);
+
+    void deleteDictDataByIds(Long tenantId, Long[] dictCodes);
+
+    void refreshDictCache(Long tenantId);
+
+    String checkDictTypeUnique(Long tenantId, SysDictType dict);
+}

+ 20 - 0
fs-service/src/main/java/com/fs/tenant/dict/service/TenantDictSyncService.java

@@ -0,0 +1,20 @@
+package com.fs.tenant.dict.service;
+
+import com.fs.tenant.dict.dto.TenantDictSyncRunReq;
+import com.fs.tenant.dict.vo.TenantDictSyncResultVo;
+import com.fs.tenant.dict.vo.TenantDictSyncTaskVo;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 租户字典同步服务(方案2:模板下发到租户库)
+ */
+public interface TenantDictSyncService {
+
+    Map<String, Object> runSync(TenantDictSyncRunReq req, String triggerBy);
+
+    TenantDictSyncTaskVo getTask(String taskNo);
+
+    List<TenantDictSyncResultVo> previewDiff(Long tenantId, List<String> dictTypes);
+}

+ 43 - 0
fs-service/src/main/java/com/fs/tenant/dict/service/TenantDictTemplateService.java

@@ -0,0 +1,43 @@
+package com.fs.tenant.dict.service;
+
+import com.fs.tenant.dict.domain.TenantDictTemplateData;
+import com.fs.tenant.dict.domain.TenantDictTemplateType;
+
+import com.fs.tenant.dict.dto.TenantDictTemplateImportReq;
+import com.fs.tenant.dict.vo.TenantDictTemplateImportResultVo;
+
+import java.util.List;
+
+/**
+ * 平台字典模板服务(主库)
+ */
+public interface TenantDictTemplateService {
+
+    List<TenantDictTemplateType> selectTemplateTypeList(TenantDictTemplateType query);
+
+    TenantDictTemplateType selectTemplateTypeById(Long dictId);
+
+    int insertTemplateType(TenantDictTemplateType row);
+
+    int updateTemplateType(TenantDictTemplateType row);
+
+    void deleteTemplateTypeByIds(Long[] dictIds);
+
+    List<TenantDictTemplateData> selectTemplateDataList(TenantDictTemplateData query);
+
+    TenantDictTemplateData selectTemplateDataById(Long dictCode);
+
+    int insertTemplateData(TenantDictTemplateData row);
+
+    int updateTemplateData(TenantDictTemplateData row);
+
+    void deleteTemplateDataByIds(Long[] dictCodes);
+
+    List<TenantDictTemplateType> selectAllTemplateTypes();
+
+    List<TenantDictTemplateData> selectTemplateDataByType(String dictType);
+
+    TenantDictTemplateImportResultVo importFromPlatform(TenantDictTemplateImportReq req, String operator);
+
+    TenantDictTemplateImportResultVo importFromTenant(TenantDictTemplateImportReq req, String operator);
+}

+ 214 - 0
fs-service/src/main/java/com/fs/tenant/dict/service/impl/TenantDictManageServiceImpl.java

@@ -0,0 +1,214 @@
+package com.fs.tenant.dict.service.impl;
+
+import com.fs.common.core.page.PageDomain;
+import com.fs.common.core.page.TableSupport;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.sql.SqlUtil;
+import com.github.pagehelper.PageHelper;
+import com.fs.common.constant.UserConstants;
+import com.fs.common.core.domain.entity.SysDictData;
+import com.fs.common.core.domain.entity.SysDictType;
+import com.fs.common.exception.CustomException;
+import com.fs.system.service.ISysDictDataService;
+import com.fs.system.service.ISysDictTypeService;
+import com.fs.tenant.dict.constant.TenantDictConstants;
+import com.fs.tenant.dict.helper.TenantDictContextHelper;
+import com.fs.tenant.dict.service.TenantDictManageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 租户字典代管:在指定租户库内复用标准字典 Service。
+ */
+@Service
+public class TenantDictManageServiceImpl implements TenantDictManageService {
+
+    @Autowired
+    private TenantDictContextHelper contextHelper;
+
+    @Autowired
+    private ISysDictTypeService dictTypeService;
+
+    @Autowired
+    private ISysDictDataService dictDataService;
+
+    @Override
+    public List<SysDictType> selectDictTypeList(Long tenantId, SysDictType query) {
+        return contextHelper.executeInTenant(tenantId, () -> {
+            startPage();
+            return dictTypeService.selectDictTypeList(query);
+        });
+    }
+
+    @Override
+    public SysDictType selectDictTypeById(Long tenantId, Long dictId) {
+        return contextHelper.executeInTenant(tenantId, () -> dictTypeService.selectDictTypeById(dictId));
+    }
+
+    @Override
+    public int insertDictType(Long tenantId, SysDictType dict) {
+        markTenantManual(dict);
+        return contextHelper.executeInTenant(tenantId, () -> {
+            if (UserConstants.NOT_UNIQUE.equals(dictTypeService.checkDictTypeUnique(dict))) {
+                throw new CustomException("字典类型已存在: " + dict.getDictType());
+            }
+            return dictTypeService.insertDictType(dict);
+        });
+    }
+
+    @Override
+    public int updateDictType(Long tenantId, SysDictType dict) {
+        return contextHelper.executeInTenant(tenantId, () -> {
+            SysDictType existing = dictTypeService.selectDictTypeById(dict.getDictId());
+            if (existing == null) {
+                throw new CustomException("字典类型不存在");
+            }
+            if (isPlatformLocked(existing)) {
+                throw new CustomException("平台管控字典类型不可直接修改,请通过模板同步更新");
+            }
+            if (UserConstants.NOT_UNIQUE.equals(dictTypeService.checkDictTypeUnique(dict))) {
+                throw new CustomException("字典类型已存在: " + dict.getDictType());
+            }
+            if (dict.getDictSource() == null) {
+                dict.setDictSource(existing.getDictSource());
+            }
+            if (dict.getIsPlatformManaged() == null) {
+                dict.setIsPlatformManaged(existing.getIsPlatformManaged());
+            }
+            return dictTypeService.updateDictType(dict);
+        });
+    }
+
+    @Override
+    public void deleteDictTypeByIds(Long tenantId, Long[] dictIds) {
+        contextHelper.runInTenant(tenantId, () -> {
+            for (Long dictId : dictIds) {
+                SysDictType existing = dictTypeService.selectDictTypeById(dictId);
+                if (existing != null && isPlatformLocked(existing)) {
+                    throw new CustomException("平台管控字典类型不可删除: " + existing.getDictType());
+                }
+            }
+            dictTypeService.deleteDictTypeByIds(dictIds);
+        });
+    }
+
+    @Override
+    public List<SysDictData> selectDictDataList(Long tenantId, SysDictData query) {
+        return contextHelper.executeInTenant(tenantId, () -> {
+            startPage();
+            return dictDataService.selectDictDataList(query);
+        });
+    }
+
+    @Override
+    public SysDictData selectDictDataById(Long tenantId, Long dictCode) {
+        return contextHelper.executeInTenant(tenantId, () -> dictDataService.selectDictDataById(dictCode));
+    }
+
+    @Override
+    public int insertDictData(Long tenantId, SysDictData dict) {
+        markTenantManual(dict);
+        return contextHelper.executeInTenant(tenantId, () -> dictDataService.insertDictData(dict));
+    }
+
+    @Override
+    public int updateDictData(Long tenantId, SysDictData dict) {
+        return contextHelper.executeInTenant(tenantId, () -> {
+            SysDictData existing = dictDataService.selectDictDataById(dict.getDictCode());
+            if (existing == null) {
+                throw new CustomException("字典数据不存在");
+            }
+            if (isPlatformLocked(existing)) {
+                throw new CustomException("平台管控字典数据不可直接修改: " + existing.getDictLabel());
+            }
+            if (dict.getDictSource() == null) {
+                dict.setDictSource(existing.getDictSource());
+            }
+            if (dict.getIsPlatformManaged() == null) {
+                dict.setIsPlatformManaged(existing.getIsPlatformManaged());
+            }
+            return dictDataService.updateDictData(dict);
+        });
+    }
+
+    @Override
+    public void deleteDictDataByIds(Long tenantId, Long[] dictCodes) {
+        contextHelper.runInTenant(tenantId, () -> {
+            for (Long dictCode : dictCodes) {
+                SysDictData existing = dictDataService.selectDictDataById(dictCode);
+                if (existing != null && isPlatformLocked(existing)) {
+                    throw new CustomException("平台管控字典数据不可删除: " + existing.getDictLabel());
+                }
+            }
+            dictDataService.deleteDictDataByIds(dictCodes);
+        });
+    }
+
+    @Override
+    public void refreshDictCache(Long tenantId) {
+        contextHelper.runInTenant(tenantId, () -> dictTypeService.resetDictCache());
+    }
+
+    @Override
+    public String checkDictTypeUnique(Long tenantId, SysDictType dict) {
+        return contextHelper.executeInTenant(tenantId, () -> dictTypeService.checkDictTypeUnique(dict));
+    }
+
+    private void markTenantManual(SysDictType dict) {
+        if (dict.getDictSource() == null) {
+            dict.setDictSource(TenantDictConstants.SOURCE_TENANT);
+        }
+        if (dict.getIsPlatformManaged() == null) {
+            dict.setIsPlatformManaged(0);
+        }
+        if (dict.getStatus() == null) {
+            dict.setStatus(UserConstants.NORMAL);
+        }
+    }
+
+    private void markTenantManual(SysDictData dict) {
+        if (dict.getDictSource() == null) {
+            dict.setDictSource(TenantDictConstants.SOURCE_TENANT);
+        }
+        if (dict.getIsPlatformManaged() == null) {
+            dict.setIsPlatformManaged(0);
+        }
+        if (dict.getStatus() == null) {
+            dict.setStatus(UserConstants.NORMAL);
+        }
+    }
+
+    private boolean isPlatformLocked(SysDictType type) {
+        if (type == null) {
+            return false;
+        }
+        if (type.getIsPlatformManaged() != null) {
+            return type.getIsPlatformManaged() == 1;
+        }
+        return TenantDictConstants.SOURCE_PLATFORM.equals(type.getDictSource());
+    }
+
+    private boolean isPlatformLocked(SysDictData data) {
+        if (data == null) {
+            return false;
+        }
+        if (data.getIsPlatformManaged() != null) {
+            return data.getIsPlatformManaged() == 1;
+        }
+        return TenantDictConstants.SOURCE_PLATFORM.equals(data.getDictSource());
+    }
+
+    /** 切库后再开启分页,避免 loadActiveTenant 查询消耗 PageHelper */
+    private void startPage() {
+        PageDomain pageDomain = TableSupport.buildPageRequest();
+        Integer pageNum = pageDomain.getPageNum();
+        Integer pageSize = pageDomain.getPageSize();
+        if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize)) {
+            String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
+            Boolean reasonable = pageDomain.getReasonable();
+            PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
+        }
+    }
+}

+ 552 - 0
fs-service/src/main/java/com/fs/tenant/dict/service/impl/TenantDictSyncServiceImpl.java

@@ -0,0 +1,552 @@
+package com.fs.tenant.dict.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.domain.entity.SysDictData;
+import com.fs.common.core.domain.entity.SysDictType;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.system.mapper.SysDictDataMapper;
+import com.fs.system.mapper.SysDictTypeMapper;
+import com.fs.system.service.ISysDictTypeService;
+import com.fs.tenant.dict.constant.TenantDictConstants;
+import com.fs.tenant.dict.domain.TenantDictTemplateData;
+import com.fs.tenant.dict.domain.TenantDictTemplateType;
+import com.fs.tenant.dict.dto.TenantDictSyncRunReq;
+import com.fs.tenant.dict.helper.TenantDictContextHelper;
+import com.fs.tenant.dict.service.TenantDictSyncService;
+import com.fs.tenant.dict.service.TenantDictTemplateService;
+import com.fs.tenant.dict.vo.TenantDictSyncResultVo;
+import com.fs.tenant.dict.vo.TenantDictSyncTaskDetailVo;
+import com.fs.tenant.dict.vo.TenantDictSyncTaskVo;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+/**
+ * 租户字典同步引擎:模板 → 租户库,支持 APPEND / MERGE / OVERWRITE。
+ */
+@Service
+public class TenantDictSyncServiceImpl implements TenantDictSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantDictSyncServiceImpl.class);
+
+    @Autowired
+    private TenantDictTemplateService templateService;
+    @Autowired
+    private TenantDictContextHelper contextHelper;
+    @Autowired
+    private TenantInfoService tenantInfoService;
+    @Autowired
+    private SysDictTypeMapper dictTypeMapper;
+    @Autowired
+    private SysDictDataMapper dictDataMapper;
+    @Autowired
+    private ISysDictTypeService dictTypeService;
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    @Override
+    public Map<String, Object> runSync(TenantDictSyncRunReq req, String triggerBy) {
+        validateSyncReq(req);
+        List<TenantDictTemplateType> templateTypes = resolveTemplateTypes(req.getDictTypes());
+        if (templateTypes.isEmpty()) {
+            throw new CustomException("没有可同步的模板字典类型,请先在平台模板中维护");
+        }
+        List<TenantInfo> tenants = resolveTenants(req);
+        if (tenants.isEmpty()) {
+            throw new CustomException("未匹配到可同步租户");
+        }
+
+        if (Boolean.TRUE.equals(req.getDryRun())) {
+            List<TenantDictSyncResultVo> preview = new ArrayList<>();
+            for (TenantInfo tenant : tenants) {
+                preview.add(previewOneTenant(tenant, templateTypes, req.getSyncMode()));
+            }
+            Map<String, Object> resp = new HashMap<>();
+            resp.put("dryRun", true);
+            resp.put("preview", preview);
+            return resp;
+        }
+
+        String taskNo = buildTaskNo();
+        contextHelper.runInMaster(() -> {
+            initMasterTaskTables();
+            insertTask(taskNo, req, tenants.size(), triggerBy);
+        });
+
+        CompletableFuture.runAsync(() -> executeSyncTask(taskNo, req, tenants, templateTypes));
+
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("taskNo", taskNo);
+        resp.put("status", TenantDictConstants.TASK_RUNNING);
+        resp.put("totalTenants", tenants.size());
+        resp.put("syncMode", req.getSyncMode());
+        return resp;
+    }
+
+    @Override
+    public TenantDictSyncTaskVo getTask(String taskNo) {
+        return contextHelper.executeInMaster(() -> {
+            initMasterTaskTables();
+            List<Map<String, Object>> rows = jdbcTemplate.queryForList(
+                    "SELECT task_no,sync_mode,scope_type,dict_types,total_tenants,success_tenants,failed_tenants,status,trigger_by,started_at,finished_at " +
+                            "FROM tenant_dict_sync_task WHERE task_no=?", taskNo);
+            if (rows.isEmpty()) {
+                return null;
+            }
+            Map<String, Object> row = rows.get(0);
+            TenantDictSyncTaskVo vo = new TenantDictSyncTaskVo();
+            vo.setTaskNo(str(row.get("task_no")));
+            vo.setSyncMode(str(row.get("sync_mode")));
+            vo.setScopeType(str(row.get("scope_type")));
+            vo.setDictTypes(str(row.get("dict_types")));
+            vo.setTotalTenants(intVal(row.get("total_tenants")));
+            vo.setSuccessTenants(intVal(row.get("success_tenants")));
+            vo.setFailedTenants(intVal(row.get("failed_tenants")));
+            vo.setStatus(str(row.get("status")));
+            vo.setTriggerBy(str(row.get("trigger_by")));
+            vo.setStartedAt(str(row.get("started_at")));
+            vo.setFinishedAt(str(row.get("finished_at")));
+            vo.setDetails(queryTaskDetails(taskNo));
+            return vo;
+        });
+    }
+
+    @Override
+    public List<TenantDictSyncResultVo> previewDiff(Long tenantId, List<String> dictTypes) {
+        TenantInfo tenant = contextHelper.loadActiveTenant(tenantId);
+        List<TenantDictTemplateType> templateTypes = resolveTemplateTypes(dictTypes);
+        TenantDictSyncResultVo result = previewOneTenant(tenant, templateTypes, TenantDictConstants.SYNC_MERGE);
+        return Collections.singletonList(result);
+    }
+
+    private void executeSyncTask(String taskNo, TenantDictSyncRunReq req, List<TenantInfo> tenants,
+                                 List<TenantDictTemplateType> templateTypes) {
+        int success = 0;
+        int failed = 0;
+        Map<String, List<TenantDictTemplateData>> dataCache = loadTemplateDataCache(templateTypes);
+
+        if (Boolean.TRUE.equals(req.getParallel())) {
+            int threads = req.getThreads() == null || req.getThreads() <= 0 ? 4 : req.getThreads();
+            ExecutorService pool = Executors.newFixedThreadPool(threads);
+            try {
+                List<Future<Boolean>> futures = new ArrayList<>();
+                for (TenantInfo tenant : tenants) {
+                    futures.add(pool.submit(() -> syncOneTenant(taskNo, tenant, templateTypes, dataCache, req.getSyncMode())));
+                }
+                for (Future<Boolean> f : futures) {
+                    try {
+                        if (Boolean.TRUE.equals(f.get())) success++;
+                        else failed++;
+                    } catch (Exception e) {
+                        failed++;
+                        log.error("[TenantDictSync] 并行任务异常", e);
+                    }
+                }
+            } finally {
+                pool.shutdown();
+            }
+        } else {
+            for (TenantInfo tenant : tenants) {
+                if (syncOneTenant(taskNo, tenant, templateTypes, dataCache, req.getSyncMode())) {
+                    success++;
+                } else {
+                    failed++;
+                }
+            }
+        }
+        finalizeTask(taskNo, success, failed);
+    }
+
+    private boolean syncOneTenant(String taskNo, TenantInfo tenant, List<TenantDictTemplateType> templateTypes,
+                                  Map<String, List<TenantDictTemplateData>> dataCache, String syncMode) {
+        Date start = new Date();
+        TenantDictSyncResultVo stats = new TenantDictSyncResultVo();
+        stats.setTenantId(tenant.getId());
+        stats.setTenantCode(tenant.getTenantCode());
+        stats.setTenantName(tenant.getTenantName());
+        String status = TenantDictConstants.DETAIL_SUCCESS;
+        String errorMsg = null;
+        try {
+            contextHelper.executeInTenant(tenant.getId(), () -> {
+                for (TenantDictTemplateType templateType : templateTypes) {
+                    String mode = resolveMode(syncMode, templateType);
+                    syncType(templateType, mode, stats);
+                    List<TenantDictTemplateData> templateDataList = dataCache.getOrDefault(templateType.getDictType(), Collections.emptyList());
+                    if (TenantDictConstants.SYNC_OVERWRITE.equals(mode)) {
+                        overwriteData(templateType.getDictType(), templateDataList, stats);
+                    } else {
+                        for (TenantDictTemplateData templateData : templateDataList) {
+                            syncData(templateData, mode, stats);
+                        }
+                    }
+                }
+                dictTypeService.resetDictCache();
+                return null;
+            });
+        } catch (Exception e) {
+            status = TenantDictConstants.DETAIL_FAILED;
+            errorMsg = e.getMessage();
+            log.error("[TenantDictSync] tenantId={} 同步失败", tenant.getId(), e);
+        }
+        insertTaskDetail(taskNo, tenant, stats, status, errorMsg, start, new Date());
+        return TenantDictConstants.DETAIL_SUCCESS.equals(status);
+    }
+
+    private TenantDictSyncResultVo previewOneTenant(TenantInfo tenant, List<TenantDictTemplateType> templateTypes, String syncMode) {
+        Map<String, List<TenantDictTemplateData>> dataCache = loadTemplateDataCache(templateTypes);
+        TenantDictSyncResultVo stats = new TenantDictSyncResultVo();
+        stats.setTenantId(tenant.getId());
+        stats.setTenantCode(tenant.getTenantCode());
+        stats.setTenantName(tenant.getTenantName());
+        contextHelper.executeInTenant(tenant.getId(), () -> {
+            for (TenantDictTemplateType templateType : templateTypes) {
+                String mode = resolveMode(syncMode, templateType);
+                previewType(templateType, mode, stats);
+                List<TenantDictTemplateData> templateDataList = dataCache.getOrDefault(templateType.getDictType(), Collections.emptyList());
+                if (TenantDictConstants.SYNC_OVERWRITE.equals(mode)) {
+                    previewOverwriteData(templateType.getDictType(), templateDataList, stats);
+                } else {
+                    for (TenantDictTemplateData templateData : templateDataList) {
+                        previewData(templateData, mode, stats);
+                    }
+                }
+            }
+            return null;
+        });
+        stats.setMessage("预览完成(未写入)");
+        return stats;
+    }
+
+    private void syncType(TenantDictTemplateType template, String mode, TenantDictSyncResultVo stats) {
+        SysDictType existing = dictTypeMapper.selectDictTypeByType(template.getDictType());
+        if (existing == null) {
+            SysDictType row = buildTypeFromTemplate(template);
+            dictTypeMapper.insertDictType(row);
+            stats.setTypeAdded(stats.getTypeAdded() + 1);
+            return;
+        }
+        if (TenantDictConstants.SYNC_APPEND.equals(mode)) {
+            stats.setDataSkipped(stats.getDataSkipped() + 1);
+            return;
+        }
+        if (canUpdateType(existing, mode)) {
+            applyTypeFromTemplate(existing, template);
+            dictTypeMapper.updateDictType(existing);
+            stats.setTypeUpdated(stats.getTypeUpdated() + 1);
+        } else {
+            stats.setDataSkipped(stats.getDataSkipped() + 1);
+        }
+    }
+
+    private void previewType(TenantDictTemplateType template, String mode, TenantDictSyncResultVo stats) {
+        SysDictType existing = dictTypeMapper.selectDictTypeByType(template.getDictType());
+        if (existing == null) {
+            stats.setTypeAdded(stats.getTypeAdded() + 1);
+        } else if (!TenantDictConstants.SYNC_APPEND.equals(mode) && canUpdateType(existing, mode)) {
+            stats.setTypeUpdated(stats.getTypeUpdated() + 1);
+        } else {
+            stats.setDataSkipped(stats.getDataSkipped() + 1);
+        }
+    }
+
+    private void syncData(TenantDictTemplateData template, String mode, TenantDictSyncResultVo stats) {
+        SysDictData existing = dictDataMapper.selectDictDataByTypeAndValue(template.getDictType(), template.getDictValue());
+        if (existing == null) {
+            dictDataMapper.insertDictData(buildDataFromTemplate(template));
+            stats.setDataAdded(stats.getDataAdded() + 1);
+            return;
+        }
+        if (TenantDictConstants.SYNC_APPEND.equals(mode)) {
+            stats.setDataSkipped(stats.getDataSkipped() + 1);
+            return;
+        }
+        if (canUpdateData(existing, template, mode)) {
+            applyDataFromTemplate(existing, template);
+            dictDataMapper.updateDictData(existing);
+            stats.setDataUpdated(stats.getDataUpdated() + 1);
+        } else {
+            stats.setDataSkipped(stats.getDataSkipped() + 1);
+        }
+    }
+
+    private void previewData(TenantDictTemplateData template, String mode, TenantDictSyncResultVo stats) {
+        SysDictData existing = dictDataMapper.selectDictDataByTypeAndValue(template.getDictType(), template.getDictValue());
+        if (existing == null) {
+            stats.setDataAdded(stats.getDataAdded() + 1);
+        } else if (!TenantDictConstants.SYNC_APPEND.equals(mode) && canUpdateData(existing, template, mode)) {
+            stats.setDataUpdated(stats.getDataUpdated() + 1);
+        } else {
+            stats.setDataSkipped(stats.getDataSkipped() + 1);
+        }
+    }
+
+    private void overwriteData(String dictType, List<TenantDictTemplateData> templateDataList, TenantDictSyncResultVo stats) {
+        List<SysDictData> tenantDataList = dictDataMapper.selectDictDataList(buildDataQuery(dictType));
+        Set<String> templateValues = templateDataList.stream().map(TenantDictTemplateData::getDictValue).collect(Collectors.toSet());
+        for (SysDictData row : tenantDataList) {
+            boolean platformItem = TenantDictConstants.SOURCE_PLATFORM.equals(row.getDictSource())
+                    || (row.getIsPlatformManaged() != null && row.getIsPlatformManaged() == 1);
+            if (platformItem && !templateValues.contains(row.getDictValue())) {
+                dictDataMapper.deleteDictDataById(row.getDictCode());
+                stats.setDataRemoved(stats.getDataRemoved() + 1);
+            }
+        }
+        for (TenantDictTemplateData template : templateDataList) {
+            SysDictData existing = dictDataMapper.selectDictDataByTypeAndValue(dictType, template.getDictValue());
+            if (existing == null) {
+                dictDataMapper.insertDictData(buildDataFromTemplate(template));
+                stats.setDataAdded(stats.getDataAdded() + 1);
+            } else if (canUpdateData(existing, template, TenantDictConstants.SYNC_OVERWRITE)
+                    || TenantDictConstants.SOURCE_PLATFORM.equals(existing.getDictSource())) {
+                applyDataFromTemplate(existing, template);
+                dictDataMapper.updateDictData(existing);
+                stats.setDataUpdated(stats.getDataUpdated() + 1);
+            } else {
+                stats.setDataSkipped(stats.getDataSkipped() + 1);
+            }
+        }
+    }
+
+    private void previewOverwriteData(String dictType, List<TenantDictTemplateData> templateDataList, TenantDictSyncResultVo stats) {
+        List<SysDictData> tenantDataList = dictDataMapper.selectDictDataList(buildDataQuery(dictType));
+        Set<String> templateValues = templateDataList.stream().map(TenantDictTemplateData::getDictValue).collect(Collectors.toSet());
+        for (SysDictData row : tenantDataList) {
+            boolean platformItem = TenantDictConstants.SOURCE_PLATFORM.equals(row.getDictSource())
+                    || (row.getIsPlatformManaged() != null && row.getIsPlatformManaged() == 1);
+            if (platformItem && !templateValues.contains(row.getDictValue())) {
+                stats.setDataRemoved(stats.getDataRemoved() + 1);
+            }
+        }
+        for (TenantDictTemplateData template : templateDataList) {
+            SysDictData existing = dictDataMapper.selectDictDataByTypeAndValue(dictType, template.getDictValue());
+            if (existing == null) {
+                stats.setDataAdded(stats.getDataAdded() + 1);
+            } else if (canUpdateData(existing, template, TenantDictConstants.SYNC_OVERWRITE)
+                    || TenantDictConstants.SOURCE_PLATFORM.equals(existing.getDictSource())) {
+                stats.setDataUpdated(stats.getDataUpdated() + 1);
+            } else {
+                stats.setDataSkipped(stats.getDataSkipped() + 1);
+            }
+        }
+    }
+
+    private boolean canUpdateType(SysDictType existing, String mode) {
+        if (TenantDictConstants.SYNC_APPEND.equals(mode)) {
+            return false;
+        }
+        // MERGE / OVERWRITE:模板中的类型均写入平台溯源字段,便于租户侧锁定
+        return true;
+    }
+
+    private boolean canUpdateData(SysDictData existing, TenantDictTemplateData template, String mode) {
+        if (TenantDictConstants.SYNC_OVERWRITE.equals(mode)) {
+            return TenantDictConstants.SOURCE_PLATFORM.equals(existing.getDictSource())
+                    || (existing.getIsPlatformManaged() != null && existing.getIsPlatformManaged() == 1)
+                    || (template.getIsManaged() != null && template.getIsManaged() == 1);
+        }
+        if (TenantDictConstants.SYNC_MERGE.equals(mode)) {
+            return TenantDictConstants.SOURCE_PLATFORM.equals(existing.getDictSource())
+                    || (existing.getIsPlatformManaged() != null && existing.getIsPlatformManaged() == 1)
+                    || (template.getIsManaged() != null && template.getIsManaged() == 1);
+        }
+        return false;
+    }
+
+    private SysDictType buildTypeFromTemplate(TenantDictTemplateType template) {
+        SysDictType row = new SysDictType();
+        applyTypeFromTemplate(row, template);
+        row.setCreateBy("platform-sync");
+        return row;
+    }
+
+    private void applyTypeFromTemplate(SysDictType row, TenantDictTemplateType template) {
+        row.setDictName(template.getDictName());
+        row.setDictType(template.getDictType());
+        row.setStatus(template.getStatus());
+        row.setRemark(template.getRemark());
+        row.setDictSource(TenantDictConstants.SOURCE_PLATFORM);
+        row.setIsPlatformManaged(template.getIsManaged() == null ? 1 : template.getIsManaged());
+        row.setUpdateBy("platform-sync");
+    }
+
+    private SysDictData buildDataFromTemplate(TenantDictTemplateData template) {
+        SysDictData row = new SysDictData();
+        applyDataFromTemplate(row, template);
+        row.setCreateBy("platform-sync");
+        return row;
+    }
+
+    private void applyDataFromTemplate(SysDictData row, TenantDictTemplateData template) {
+        row.setDictSort(template.getDictSort());
+        row.setDictLabel(template.getDictLabel());
+        row.setDictValue(template.getDictValue());
+        row.setDictType(template.getDictType());
+        row.setCssClass(template.getCssClass());
+        row.setListClass(template.getListClass());
+        row.setIsDefault(template.getIsDefault());
+        row.setStatus(template.getStatus());
+        row.setRemark(template.getRemark());
+        row.setDictSource(TenantDictConstants.SOURCE_PLATFORM);
+        row.setIsPlatformManaged(template.getIsManaged() == null ? 1 : template.getIsManaged());
+        row.setUpdateBy("platform-sync");
+    }
+
+    private SysDictData buildDataQuery(String dictType) {
+        SysDictData query = new SysDictData();
+        query.setDictType(dictType);
+        return query;
+    }
+
+    private String resolveMode(String globalMode, TenantDictTemplateType template) {
+        if (StringUtils.isNotEmpty(globalMode)) {
+            return globalMode.toUpperCase();
+        }
+        return template.getSyncMode() == null ? TenantDictConstants.SYNC_MERGE : template.getSyncMode().toUpperCase();
+    }
+
+    private Map<String, List<TenantDictTemplateData>> loadTemplateDataCache(List<TenantDictTemplateType> templateTypes) {
+        Map<String, List<TenantDictTemplateData>> cache = new HashMap<>();
+        for (TenantDictTemplateType type : templateTypes) {
+            cache.put(type.getDictType(), templateService.selectTemplateDataByType(type.getDictType()));
+        }
+        return cache;
+    }
+
+    private List<TenantDictTemplateType> resolveTemplateTypes(List<String> dictTypes) {
+        List<TenantDictTemplateType> all = templateService.selectAllTemplateTypes();
+        if (dictTypes == null || dictTypes.isEmpty()) {
+            return all;
+        }
+        Set<String> filter = dictTypes.stream().map(String::trim).filter(StringUtils::isNotEmpty).collect(Collectors.toSet());
+        return all.stream().filter(t -> filter.contains(t.getDictType())).collect(Collectors.toList());
+    }
+
+    private List<TenantInfo> resolveTenants(TenantDictSyncRunReq req) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            List<TenantInfo> all = tenantInfoService.selectTenantInfoList(new TenantInfo());
+            all.removeIf(t -> t.getStatus() == null || t.getStatus() != 1);
+            String scope = req.getScopeType() == null ? TenantDictConstants.SCOPE_ALL : req.getScopeType().toUpperCase();
+            List<Long> ids = req.getTenantIds() == null ? Collections.emptyList() : req.getTenantIds();
+            if (TenantDictConstants.SCOPE_INCLUDE.equals(scope)) {
+                return all.stream().filter(t -> ids.contains(t.getId())).collect(Collectors.toList());
+            }
+            if (TenantDictConstants.SCOPE_EXCLUDE.equals(scope)) {
+                return all.stream().filter(t -> !ids.contains(t.getId())).collect(Collectors.toList());
+            }
+            return all;
+        } finally {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    private void validateSyncReq(TenantDictSyncRunReq req) {
+        if (req == null) {
+            throw new CustomException("同步参数不能为空");
+        }
+        String mode = req.getSyncMode() == null ? TenantDictConstants.SYNC_MERGE : req.getSyncMode().toUpperCase();
+        req.setSyncMode(mode);
+        if (!TenantDictConstants.SYNC_APPEND.equals(mode)
+                && !TenantDictConstants.SYNC_MERGE.equals(mode)
+                && !TenantDictConstants.SYNC_OVERWRITE.equals(mode)) {
+            throw new CustomException("不支持的同步模式: " + mode);
+        }
+        if (TenantDictConstants.SYNC_OVERWRITE.equals(mode) && !Boolean.TRUE.equals(req.getOverwriteConfirm())) {
+            throw new CustomException("OVERWRITE 模式风险较高,请设置 overwriteConfirm=true 后重试");
+        }
+    }
+
+    private String buildTaskNo() {
+        return "DSYNC" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())
+                + String.format("%04d", new Random().nextInt(10000));
+    }
+
+    private void initMasterTaskTables() {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tenant_dict_sync_task (" +
+                "id bigint NOT NULL AUTO_INCREMENT, task_no varchar(32) NOT NULL, sync_mode varchar(20) NOT NULL, " +
+                "scope_type varchar(20) NOT NULL, dict_types text, total_tenants int NOT NULL DEFAULT 0, " +
+                "success_tenants int NOT NULL DEFAULT 0, failed_tenants int NOT NULL DEFAULT 0, status varchar(20) NOT NULL DEFAULT 'RUNNING', " +
+                "trigger_by varchar(64) DEFAULT NULL, started_at datetime DEFAULT NULL, finished_at datetime DEFAULT NULL, remark varchar(500) DEFAULT NULL, " +
+                "PRIMARY KEY (id), UNIQUE KEY uk_task_no (task_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tenant_dict_sync_task_detail (" +
+                "id bigint NOT NULL AUTO_INCREMENT, task_no varchar(32) NOT NULL, tenant_id bigint NOT NULL, tenant_code varchar(64) DEFAULT NULL, " +
+                "tenant_name varchar(128) DEFAULT NULL, status varchar(20) NOT NULL, type_added int DEFAULT 0, type_updated int DEFAULT 0, " +
+                "data_added int DEFAULT 0, data_updated int DEFAULT 0, data_skipped int DEFAULT 0, error_msg text, " +
+                "started_at datetime DEFAULT NULL, finished_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_task_no (task_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+    }
+
+    private void insertTask(String taskNo, TenantDictSyncRunReq req, int total, String triggerBy) {
+        contextHelper.runInMaster(() -> jdbcTemplate.update(
+                "INSERT INTO tenant_dict_sync_task(task_no,sync_mode,scope_type,dict_types,total_tenants,status,trigger_by,started_at) VALUES(?,?,?,?,?,?,?,NOW())",
+                taskNo, req.getSyncMode(), req.getScopeType(), JSON.toJSONString(req.getDictTypes()), total,
+                TenantDictConstants.TASK_RUNNING, triggerBy));
+    }
+
+    private void insertTaskDetail(String taskNo, TenantInfo tenant, TenantDictSyncResultVo stats,
+                                    String status, String errorMsg, Date start, Date end) {
+        contextHelper.runInMaster(() -> jdbcTemplate.update(
+                "INSERT INTO tenant_dict_sync_task_detail(task_no,tenant_id,tenant_code,tenant_name,status,type_added,type_updated,data_added,data_updated,data_skipped,error_msg,started_at,finished_at) " +
+                        "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
+                taskNo, tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), status,
+                stats.getTypeAdded(), stats.getTypeUpdated(), stats.getDataAdded(), stats.getDataUpdated(),
+                stats.getDataSkipped(), errorMsg, start, end));
+    }
+
+    private void finalizeTask(String taskNo, int success, int failed) {
+        contextHelper.runInMaster(() -> {
+            String status = failed == 0 ? TenantDictConstants.TASK_SUCCESS
+                    : (success == 0 ? TenantDictConstants.TASK_FAILED : TenantDictConstants.TASK_PARTIAL);
+            jdbcTemplate.update(
+                    "UPDATE tenant_dict_sync_task SET success_tenants=?, failed_tenants=?, status=?, finished_at=NOW() WHERE task_no=?",
+                    success, failed, status, taskNo);
+        });
+    }
+
+    private List<TenantDictSyncTaskDetailVo> queryTaskDetails(String taskNo) {
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(
+                "SELECT tenant_id,tenant_code,tenant_name,status,type_added,type_updated,data_added,data_updated,data_skipped,error_msg,started_at,finished_at " +
+                        "FROM tenant_dict_sync_task_detail WHERE task_no=? ORDER BY id ASC", taskNo);
+        List<TenantDictSyncTaskDetailVo> list = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            TenantDictSyncTaskDetailVo vo = new TenantDictSyncTaskDetailVo();
+            vo.setTenantId(longVal(row.get("tenant_id")));
+            vo.setTenantCode(str(row.get("tenant_code")));
+            vo.setTenantName(str(row.get("tenant_name")));
+            vo.setStatus(str(row.get("status")));
+            vo.setTypeAdded(intVal(row.get("type_added")));
+            vo.setTypeUpdated(intVal(row.get("type_updated")));
+            vo.setDataAdded(intVal(row.get("data_added")));
+            vo.setDataUpdated(intVal(row.get("data_updated")));
+            vo.setDataSkipped(intVal(row.get("data_skipped")));
+            vo.setErrorMsg(str(row.get("error_msg")));
+            vo.setStartedAt(str(row.get("started_at")));
+            vo.setFinishedAt(str(row.get("finished_at")));
+            list.add(vo);
+        }
+        return list;
+    }
+
+    private String str(Object o) {
+        return o == null ? null : String.valueOf(o);
+    }
+
+    private int intVal(Object o) {
+        return o == null ? 0 : Integer.parseInt(String.valueOf(o));
+    }
+
+    private long longVal(Object o) {
+        return o == null ? 0L : Long.parseLong(String.valueOf(o));
+    }
+}

+ 325 - 0
fs-service/src/main/java/com/fs/tenant/dict/service/impl/TenantDictTemplateServiceImpl.java

@@ -0,0 +1,325 @@
+package com.fs.tenant.dict.service.impl;
+
+import com.fs.common.constant.UserConstants;
+import com.fs.common.exception.CustomException;
+import com.fs.common.core.domain.entity.SysDictData;
+import com.fs.common.core.domain.entity.SysDictType;
+import com.fs.tenant.dict.dto.TenantDictTemplateImportReq;
+import com.fs.tenant.dict.service.TenantDictManageService;
+import com.fs.tenant.dict.vo.TenantDictTemplateImportResultVo;
+import com.fs.system.service.ISysDictDataService;
+import com.fs.system.service.ISysDictTypeService;
+import org.springframework.util.CollectionUtils;
+import com.fs.tenant.dict.domain.TenantDictTemplateData;
+import com.fs.tenant.dict.domain.TenantDictTemplateType;
+import com.fs.tenant.dict.helper.TenantDictContextHelper;
+import com.fs.tenant.dict.mapper.TenantDictTemplateMapper;
+import com.fs.tenant.dict.service.TenantDictTemplateService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class TenantDictTemplateServiceImpl implements TenantDictTemplateService {
+
+    @Autowired
+    private TenantDictTemplateMapper templateMapper;
+
+    @Autowired
+    private TenantDictContextHelper contextHelper;
+
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    @Autowired
+    private ISysDictTypeService sysDictTypeService;
+
+    @Autowired
+    private ISysDictDataService sysDictDataService;
+
+    @Autowired
+    private TenantDictManageService tenantDictManageService;
+
+    @Override
+    public List<TenantDictTemplateType> selectTemplateTypeList(TenantDictTemplateType query) {
+        return contextHelper.executeInMaster(() -> {
+            ensureTemplateTables();
+            return templateMapper.selectTemplateTypeList(query);
+        });
+    }
+
+    @Override
+    public TenantDictTemplateType selectTemplateTypeById(Long dictId) {
+        return contextHelper.executeInMaster(() -> templateMapper.selectTemplateTypeById(dictId));
+    }
+
+    @Override
+    public int insertTemplateType(TenantDictTemplateType row) {
+        normalizeType(row, true);
+        return contextHelper.executeInMaster(() -> {
+            ensureTemplateTables();
+            if (templateMapper.selectTemplateTypeByType(row.getDictType()) != null) {
+                throw new CustomException("模板字典类型已存在: " + row.getDictType());
+            }
+            return templateMapper.insertTemplateType(row);
+        });
+    }
+
+    @Override
+    public int updateTemplateType(TenantDictTemplateType row) {
+        normalizeType(row, false);
+        return contextHelper.executeInMaster(() -> templateMapper.updateTemplateType(row));
+    }
+
+    @Override
+    public void deleteTemplateTypeByIds(Long[] dictIds) {
+        contextHelper.runInMaster(() -> {
+            for (Long dictId : dictIds) {
+                TenantDictTemplateType type = templateMapper.selectTemplateTypeById(dictId);
+                if (type != null && templateMapper.countTemplateDataByType(type.getDictType()) > 0) {
+                    throw new CustomException(type.getDictName() + " 下存在模板数据,不能删除");
+                }
+            }
+            templateMapper.deleteTemplateTypeByIds(dictIds);
+        });
+    }
+
+    @Override
+    public List<TenantDictTemplateData> selectTemplateDataList(TenantDictTemplateData query) {
+        return contextHelper.executeInMaster(() -> templateMapper.selectTemplateDataList(query));
+    }
+
+    @Override
+    public TenantDictTemplateData selectTemplateDataById(Long dictCode) {
+        return contextHelper.executeInMaster(() -> templateMapper.selectTemplateDataById(dictCode));
+    }
+
+    @Override
+    public int insertTemplateData(TenantDictTemplateData row) {
+        normalizeData(row);
+        return contextHelper.executeInMaster(() -> {
+            if (templateMapper.selectTemplateTypeByType(row.getDictType()) == null) {
+                throw new CustomException("模板字典类型不存在,请先创建类型: " + row.getDictType());
+            }
+            if (templateMapper.selectTemplateDataByTypeAndValue(row.getDictType(), row.getDictValue()) != null) {
+                throw new CustomException("同类型下键值已存在: " + row.getDictValue());
+            }
+            return templateMapper.insertTemplateData(row);
+        });
+    }
+
+    @Override
+    public int updateTemplateData(TenantDictTemplateData row) {
+        normalizeData(row);
+        return contextHelper.executeInMaster(() -> templateMapper.updateTemplateData(row));
+    }
+
+    @Override
+    public void deleteTemplateDataByIds(Long[] dictCodes) {
+        contextHelper.runInMaster(() -> templateMapper.deleteTemplateDataByIds(dictCodes));
+    }
+
+    @Override
+    public List<TenantDictTemplateType> selectAllTemplateTypes() {
+        return contextHelper.executeInMaster(() -> templateMapper.selectTemplateTypeList(new TenantDictTemplateType()));
+    }
+
+    @Override
+    public List<TenantDictTemplateData> selectTemplateDataByType(String dictType) {
+        return contextHelper.executeInMaster(() -> templateMapper.selectTemplateDataByType(dictType));
+    }
+
+    @Override
+    public TenantDictTemplateImportResultVo importFromPlatform(TenantDictTemplateImportReq req, String operator) {
+        boolean overwrite = Boolean.TRUE.equals(req.getOverwriteExisting());
+        List<SysDictType> sourceTypes = resolvePlatformSourceTypes(req);
+        if (sourceTypes.isEmpty()) {
+            throw new CustomException("未找到可导入的平台字典类型");
+        }
+        TenantDictTemplateImportResultVo result = new TenantDictTemplateImportResultVo();
+        contextHelper.runInMaster(() -> {
+            ensureTemplateTables();
+            for (SysDictType sourceType : sourceTypes) {
+                importOneTypeAndData(
+                        toTemplateType(sourceType, operator),
+                        loadPlatformData(sourceType.getDictType()),
+                        overwrite,
+                        result,
+                        operator
+                );
+            }
+        });
+        result.setMessage(String.format("导入完成:类型+%d 更新%d 跳过%d;数据+%d 更新%d 跳过%d",
+                result.getTypeAdded(), result.getTypeUpdated(), result.getTypeSkipped(),
+                result.getDataAdded(), result.getDataUpdated(), result.getDataSkipped()));
+        return result;
+    }
+
+    @Override
+    public TenantDictTemplateImportResultVo importFromTenant(TenantDictTemplateImportReq req, String operator) {
+        if (req.getTenantId() == null) {
+            throw new CustomException("请选择租户");
+        }
+        if (CollectionUtils.isEmpty(req.getDictTypes())) {
+            throw new CustomException("请选择要导入的字典类型");
+        }
+        boolean overwrite = Boolean.TRUE.equals(req.getOverwriteExisting());
+        TenantDictTemplateImportResultVo result = new TenantDictTemplateImportResultVo();
+        for (String dictType : req.getDictTypes()) {
+            SysDictType query = new SysDictType();
+            query.setDictType(dictType);
+            List<SysDictType> types = tenantDictManageService.selectDictTypeList(req.getTenantId(), query);
+            if (types.isEmpty()) {
+                result.setTypeSkipped(result.getTypeSkipped() + 1);
+                continue;
+            }
+            SysDictType sourceType = types.get(0);
+            SysDictData dataQuery = new SysDictData();
+            dataQuery.setDictType(dictType);
+            List<SysDictData> dataList = tenantDictManageService.selectDictDataList(req.getTenantId(), dataQuery);
+            contextHelper.runInMaster(() -> {
+                ensureTemplateTables();
+                importOneTypeAndData(toTemplateType(sourceType, operator), dataList, overwrite, result, operator);
+            });
+        }
+        result.setMessage(String.format("导入完成:类型+%d 更新%d 跳过%d;数据+%d 更新%d 跳过%d",
+                result.getTypeAdded(), result.getTypeUpdated(), result.getTypeSkipped(),
+                result.getDataAdded(), result.getDataUpdated(), result.getDataSkipped()));
+        return result;
+    }
+
+    private List<SysDictType> resolvePlatformSourceTypes(TenantDictTemplateImportReq req) {
+        return contextHelper.executeInMaster(() -> {
+            if (!CollectionUtils.isEmpty(req.getDictIds())) {
+                List<SysDictType> list = new java.util.ArrayList<>();
+                for (Long dictId : req.getDictIds()) {
+                    SysDictType row = sysDictTypeService.selectDictTypeById(dictId);
+                    if (row != null) {
+                        list.add(row);
+                    }
+                }
+                return list;
+            }
+            if (!CollectionUtils.isEmpty(req.getDictTypes())) {
+                List<SysDictType> list = new java.util.ArrayList<>();
+                for (String dictType : req.getDictTypes()) {
+                    SysDictType row = sysDictTypeService.selectDictTypeByType(dictType);
+                    if (row != null) {
+                        list.add(row);
+                    }
+                }
+                return list;
+            }
+            return sysDictTypeService.selectDictTypeList(new SysDictType());
+        });
+    }
+
+    private List<SysDictData> loadPlatformData(String dictType) {
+        SysDictData query = new SysDictData();
+        query.setDictType(dictType);
+        return sysDictDataService.selectDictDataList(query);
+    }
+
+    private TenantDictTemplateType toTemplateType(SysDictType source, String operator) {
+        TenantDictTemplateType row = new TenantDictTemplateType();
+        row.setDictName(source.getDictName());
+        row.setDictType(source.getDictType());
+        row.setStatus(source.getStatus());
+        row.setRemark(source.getRemark());
+        row.setIsManaged(0);
+        row.setSyncMode("MERGE");
+        row.setCreateBy(operator);
+        row.setUpdateBy(operator);
+        return row;
+    }
+
+    private TenantDictTemplateData toTemplateData(SysDictData source, String operator) {
+        TenantDictTemplateData row = new TenantDictTemplateData();
+        row.setDictSort(source.getDictSort() == null ? 0L : source.getDictSort());
+        row.setDictLabel(source.getDictLabel());
+        row.setDictValue(source.getDictValue());
+        row.setDictType(source.getDictType());
+        row.setCssClass(source.getCssClass());
+        row.setListClass(source.getListClass());
+        row.setIsDefault(source.getIsDefault());
+        row.setStatus(source.getStatus());
+        row.setRemark(source.getRemark());
+        row.setIsManaged(1);
+        row.setCreateBy(operator);
+        row.setUpdateBy(operator);
+        return row;
+    }
+
+    private void importOneTypeAndData(TenantDictTemplateType typeRow, List<SysDictData> dataList,
+                                      boolean overwrite, TenantDictTemplateImportResultVo result, String operator) {
+        TenantDictTemplateType existingType = templateMapper.selectTemplateTypeByType(typeRow.getDictType());
+        if (existingType == null) {
+            templateMapper.insertTemplateType(typeRow);
+            result.setTypeAdded(result.getTypeAdded() + 1);
+        } else if (overwrite) {
+            typeRow.setDictId(existingType.getDictId());
+            templateMapper.updateTemplateType(typeRow);
+            result.setTypeUpdated(result.getTypeUpdated() + 1);
+        } else {
+            result.setTypeSkipped(result.getTypeSkipped() + 1);
+        }
+        for (SysDictData sourceData : dataList) {
+            TenantDictTemplateData dataRow = toTemplateData(sourceData, operator);
+            TenantDictTemplateData existingData = templateMapper.selectTemplateDataByTypeAndValue(
+                    dataRow.getDictType(), dataRow.getDictValue());
+            if (existingData == null) {
+                templateMapper.insertTemplateData(dataRow);
+                result.setDataAdded(result.getDataAdded() + 1);
+            } else if (overwrite) {
+                dataRow.setDictCode(existingData.getDictCode());
+                templateMapper.updateTemplateData(dataRow);
+                result.setDataUpdated(result.getDataUpdated() + 1);
+            } else {
+                result.setDataSkipped(result.getDataSkipped() + 1);
+            }
+        }
+    }
+
+    private void normalizeType(TenantDictTemplateType row, boolean isCreate) {
+        if (row.getStatus() == null) {
+            row.setStatus(UserConstants.NORMAL);
+        }
+        if (row.getIsManaged() == null) {
+            row.setIsManaged(0);
+        }
+        if (row.getSyncMode() == null || row.getSyncMode().isEmpty()) {
+            row.setSyncMode("MERGE");
+        }
+    }
+
+    private void normalizeData(TenantDictTemplateData row) {
+        if (row.getStatus() == null) {
+            row.setStatus(UserConstants.NORMAL);
+        }
+        if (row.getIsManaged() == null) {
+            row.setIsManaged(1);
+        }
+        if (row.getIsDefault() == null) {
+            row.setIsDefault("N");
+        }
+        if (row.getDictSort() == null) {
+            row.setDictSort(0L);
+        }
+    }
+
+    private void ensureTemplateTables() {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tenant_dict_template_type (" +
+                "dict_id bigint NOT NULL AUTO_INCREMENT, dict_name varchar(100) NOT NULL, dict_type varchar(100) NOT NULL, " +
+                "status char(1) NOT NULL DEFAULT '0', is_managed tinyint NOT NULL DEFAULT 1, sync_mode varchar(20) NOT NULL DEFAULT 'MERGE', " +
+                "remark varchar(500) DEFAULT NULL, create_by varchar(64) DEFAULT '', create_time datetime DEFAULT NULL, " +
+                "update_by varchar(64) DEFAULT '', update_time datetime DEFAULT NULL, PRIMARY KEY (dict_id), UNIQUE KEY uk_dict_type (dict_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tenant_dict_template_data (" +
+                "dict_code bigint NOT NULL AUTO_INCREMENT, dict_sort int DEFAULT 0, dict_label varchar(100) NOT NULL, dict_value varchar(100) NOT NULL, " +
+                "dict_type varchar(100) NOT NULL, css_class varchar(100) DEFAULT NULL, list_class varchar(100) DEFAULT NULL, is_default char(1) DEFAULT 'N', " +
+                "status char(1) NOT NULL DEFAULT '0', is_managed tinyint NOT NULL DEFAULT 1, remark varchar(500) DEFAULT NULL, " +
+                "create_by varchar(64) DEFAULT '', create_time datetime DEFAULT NULL, update_by varchar(64) DEFAULT '', update_time datetime DEFAULT NULL, " +
+                "PRIMARY KEY (dict_code), UNIQUE KEY uk_type_value (dict_type, dict_value), KEY idx_dict_type (dict_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+    }
+}

+ 21 - 0
fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictSyncResultVo.java

@@ -0,0 +1,21 @@
+package com.fs.tenant.dict.vo;
+
+import lombok.Data;
+
+/**
+ * 单租户同步统计(dryRun 或明细共用)
+ */
+@Data
+public class TenantDictSyncResultVo {
+
+    private Long tenantId;
+    private String tenantCode;
+    private String tenantName;
+    private int typeAdded;
+    private int typeUpdated;
+    private int dataAdded;
+    private int dataUpdated;
+    private int dataSkipped;
+    private int dataRemoved;
+    private String message;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictSyncTaskDetailVo.java

@@ -0,0 +1,20 @@
+package com.fs.tenant.dict.vo;
+
+import lombok.Data;
+
+@Data
+public class TenantDictSyncTaskDetailVo {
+
+    private Long tenantId;
+    private String tenantCode;
+    private String tenantName;
+    private String status;
+    private Integer typeAdded;
+    private Integer typeUpdated;
+    private Integer dataAdded;
+    private Integer dataUpdated;
+    private Integer dataSkipped;
+    private String errorMsg;
+    private String startedAt;
+    private String finishedAt;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictSyncTaskVo.java

@@ -0,0 +1,22 @@
+package com.fs.tenant.dict.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class TenantDictSyncTaskVo {
+
+    private String taskNo;
+    private String syncMode;
+    private String scopeType;
+    private String dictTypes;
+    private Integer totalTenants;
+    private Integer successTenants;
+    private Integer failedTenants;
+    private String status;
+    private String triggerBy;
+    private String startedAt;
+    private String finishedAt;
+    private List<TenantDictSyncTaskDetailVo> details;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/tenant/dict/vo/TenantDictTemplateImportResultVo.java

@@ -0,0 +1,18 @@
+package com.fs.tenant.dict.vo;
+
+import lombok.Data;
+
+/**
+ * 导入平台模板结果
+ */
+@Data
+public class TenantDictTemplateImportResultVo {
+
+    private int typeAdded;
+    private int typeUpdated;
+    private int typeSkipped;
+    private int dataAdded;
+    private int dataUpdated;
+    private int dataSkipped;
+    private String message;
+}

+ 3 - 0
fs-service/src/main/resources/db/20250530-初始化表结构.sql

@@ -9,6 +9,9 @@ DROP TABLE IF EXISTS `sys_keyword`;
 CREATE TABLE `sys_keyword`  (
                                 `keyword_id` bigint NOT NULL AUTO_INCREMENT COMMENT '关键字id',
                                 `keyword` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '关键字',
+                                `keyword_type` int NULL DEFAULT 1 COMMENT '关键字类型,字典 keyword_type',
+                                `keyword_content` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '关键字内容/扩展说明',
+                                `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
                                 `company_id` bigint NULL DEFAULT NULL COMMENT '所属公司',
                                 `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
                                 `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',

+ 1 - 1
fs-service/src/main/resources/db/tenant-initData.sql

@@ -1426,7 +1426,7 @@ INSERT INTO `sys_dict_data` VALUES (1426, 12, '修改红包', 'update_sop_temp_r
 INSERT INTO `sys_dict_data` VALUES (1427, 11, '修改课程', 'update_sop_temp_scourse', 'company_sop_role', NULL, NULL, 'N', '0', 'admin', '2025-05-07 14:16:45', '', NULL, NULL);
 INSERT INTO `sys_dict_data` VALUES (1428, 5, '课程模板', '11', 'sys_qw_sop_type', NULL, NULL, 'N', '0', 'admin', '2025-04-21 14:05:28', 'admin', '2025-05-27 19:37:42', NULL);
 INSERT INTO `sys_dict_data` VALUES (1429, 3, '抖音', '3', 'ad_type', NULL, 'default', 'N', '0', 'admin', '2025-05-27 14:10:17', '', NULL, NULL);
-INSERT INTO `sys_dict_data` VALUES (1430, 0, '看课弹幕', '1376', 'keyword_type', NULL, 'default', 'N', '0', 'admin', '2025-05-27 18:28:25', '', NULL, NULL);
+INSERT INTO `sys_dict_data` VALUES (1430, 0, '看课弹幕', '1', 'keyword_type', NULL, 'default', 'N', '0', 'admin', '2025-05-27 18:28:25', '', NULL, NULL);
 INSERT INTO `sys_dict_data` VALUES (1443, 6, '客户群群发', '6', 'sys_qw_sop_course_type', NULL, 'default', 'N', '1', 'sgw', '2025-04-16 19:55:21', '', NULL, NULL);
 INSERT INTO `sys_dict_data` VALUES (1444, 7, '欢迎语补发', '7', 'sys_qw_sop_course_type', NULL, 'default', 'N', '1', 'sgw', '2025-04-16 19:55:35', '', NULL, NULL);
 INSERT INTO `sys_dict_data` VALUES (1445, 8, 'AI对话', '8', 'sys_qw_sop_course_type', NULL, 'default', 'N', '1', 'sgw', '2025-04-16 19:55:55', '', NULL, NULL);

+ 9 - 0
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -16341,6 +16341,8 @@ CREATE TABLE `sys_dict_data`
     `list_class`  varchar(100)   NULL DEFAULT NULL COMMENT '表格回显样式',
     `is_default`  char(1)   NULL DEFAULT 'N' COMMENT '是否默认(Y是 N否)',
     `status`      char(1)   NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
+    `dict_source` varchar(20) NULL DEFAULT 'tenant' COMMENT '来源 platform/tenant',
+    `is_platform_managed` tinyint NULL DEFAULT 0 COMMENT '是否平台管控 1是 0否',
     `create_by`   varchar(64)   NULL DEFAULT '' COMMENT '创建者',
     `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
     `update_by`   varchar(64)   NULL DEFAULT '' COMMENT '更新者',
@@ -16359,6 +16361,8 @@ CREATE TABLE `sys_dict_type`
     `dict_name`   varchar(100)   NULL DEFAULT '' COMMENT '字典名称',
     `dict_type`   varchar(100)   NULL DEFAULT '' COMMENT '字典类型',
     `status`      char(1)   NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
+    `dict_source` varchar(20) NULL DEFAULT 'tenant' COMMENT '来源 platform/tenant',
+    `is_platform_managed` tinyint NULL DEFAULT 0 COMMENT '是否平台管控 1是 0否',
     `create_by`   varchar(64)   NULL DEFAULT '' COMMENT '创建者',
     `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
     `update_by`   varchar(64)   NULL DEFAULT '' COMMENT '更新者',
@@ -16378,6 +16382,8 @@ CREATE TABLE `sys_dict_type_copy1`
     `dict_name`   varchar(100)   NULL DEFAULT '' COMMENT '字典名称',
     `dict_type`   varchar(100)   NULL DEFAULT '' COMMENT '字典类型',
     `status`      char(1)   NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
+    `dict_source` varchar(20) NULL DEFAULT 'tenant' COMMENT '来源 platform/tenant',
+    `is_platform_managed` tinyint NULL DEFAULT 0 COMMENT '是否平台管控 1是 0否',
     `create_by`   varchar(64)   NULL DEFAULT '' COMMENT '创建者',
     `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
     `update_by`   varchar(64)   NULL DEFAULT '' COMMENT '更新者',
@@ -16434,6 +16440,9 @@ CREATE TABLE `sys_keyword`
 (
     `keyword_id`  bigint NOT NULL AUTO_INCREMENT COMMENT '关键字id',
     `keyword`     varchar(50)   NULL DEFAULT NULL COMMENT '关键字',
+    `keyword_type` int NULL DEFAULT 1 COMMENT '关键字类型,字典 keyword_type',
+    `keyword_content` varchar(500) NULL DEFAULT NULL COMMENT '关键字内容/扩展说明',
+    `remark` varchar(500) NULL DEFAULT NULL COMMENT '备注',
     `company_id`  bigint NULL DEFAULT NULL COMMENT '所属公司',
     `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
     `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',

+ 9 - 1
fs-service/src/main/resources/mapper/system/SysDictDataMapper.xml

@@ -14,6 +14,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 		<result property="listClass"  column="list_class"  />
 		<result property="isDefault"  column="is_default"  />
 		<result property="status"     column="status"      />
+		<result property="dictSource" column="dict_source" />
+		<result property="isPlatformManaged" column="is_platform_managed" />
 		<result property="createBy"   column="create_by"   />
 		<result property="createTime" column="create_time" />
 		<result property="updateBy"   column="update_by"   />
@@ -21,7 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 	</resultMap>
 	
 	<sql id="selectDictDataVo">
-        select dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark 
+        select dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, dict_source, is_platform_managed, create_by, create_time, remark 
 		from sys_dict_data
     </sql>
 
@@ -85,6 +87,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  			<if test="listClass != null">list_class = #{listClass},</if>
  			<if test="isDefault != null and isDefault != ''">is_default = #{isDefault},</if>
  			<if test="status != null">status = #{status},</if>
+ 			<if test="dictSource != null and dictSource != ''">dict_source = #{dictSource},</if>
+ 			<if test="isPlatformManaged != null">is_platform_managed = #{isPlatformManaged},</if>
  			<if test="remark != null">remark = #{remark},</if>
  			<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
  			update_time = sysdate()
@@ -106,6 +110,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  			<if test="listClass != null and listClass != ''">list_class,</if>
  			<if test="isDefault != null and isDefault != ''">is_default,</if>
  			<if test="status != null">status,</if>
+ 			<if test="dictSource != null and dictSource != ''">dict_source,</if>
+ 			<if test="isPlatformManaged != null">is_platform_managed,</if>
  			<if test="remark != null and remark != ''">remark,</if>
  			<if test="createBy != null and createBy != ''">create_by,</if>
  			create_time
@@ -118,6 +124,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  			<if test="listClass != null and listClass != ''">#{listClass},</if>
  			<if test="isDefault != null and isDefault != ''">#{isDefault},</if>
  			<if test="status != null">#{status},</if>
+ 			<if test="dictSource != null and dictSource != ''">#{dictSource},</if>
+ 			<if test="isPlatformManaged != null">#{isPlatformManaged},</if>
  			<if test="remark != null and remark != ''">#{remark},</if>
  			<if test="createBy != null and createBy != ''">#{createBy},</if>
  			sysdate()

+ 9 - 1
fs-service/src/main/resources/mapper/system/SysDictTypeMapper.xml

@@ -9,6 +9,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 		<result property="dictName"   column="dict_name"   />
 		<result property="dictType"   column="dict_type"   />
 		<result property="status"     column="status"      />
+		<result property="dictSource" column="dict_source" />
+		<result property="isPlatformManaged" column="is_platform_managed" />
 		<result property="createBy"   column="create_by"   />
 		<result property="createTime" column="create_time" />
 		<result property="updateBy"   column="update_by"   />
@@ -16,7 +18,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 	</resultMap>
 	
 	<sql id="selectDictTypeVo">
-        select dict_id, dict_name, dict_type, status, create_by, create_time, remark 
+        select dict_id, dict_name, dict_type, status, dict_source, is_platform_managed, create_by, create_time, remark 
 		from sys_dict_type
     </sql>
 
@@ -77,6 +79,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  			<if test="dictName != null and dictName != ''">dict_name = #{dictName},</if>
  			<if test="dictType != null and dictType != ''">dict_type = #{dictType},</if>
  			<if test="status != null">status = #{status},</if>
+ 			<if test="dictSource != null and dictSource != ''">dict_source = #{dictSource},</if>
+ 			<if test="isPlatformManaged != null">is_platform_managed = #{isPlatformManaged},</if>
  			<if test="remark != null">remark = #{remark},</if>
  			<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
  			update_time = sysdate()
@@ -89,6 +93,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  			<if test="dictName != null and dictName != ''">dict_name,</if>
  			<if test="dictType != null and dictType != ''">dict_type,</if>
  			<if test="status != null">status,</if>
+ 			<if test="dictSource != null and dictSource != ''">dict_source,</if>
+ 			<if test="isPlatformManaged != null">is_platform_managed,</if>
  			<if test="remark != null and remark != ''">remark,</if>
  			<if test="createBy != null and createBy != ''">create_by,</if>
  			create_time
@@ -96,6 +102,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  			<if test="dictName != null and dictName != ''">#{dictName},</if>
  			<if test="dictType != null and dictType != ''">#{dictType},</if>
  			<if test="status != null">#{status},</if>
+ 			<if test="dictSource != null and dictSource != ''">#{dictSource},</if>
+ 			<if test="isPlatformManaged != null">#{isPlatformManaged},</if>
  			<if test="remark != null and remark != ''">#{remark},</if>
  			<if test="createBy != null and createBy != ''">#{createBy},</if>
  			sysdate()

+ 26 - 4
fs-service/src/main/resources/mapper/system/SysKeywordMapper.xml

@@ -7,34 +7,47 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <resultMap type="SysKeyword" id="SysKeywordResult">
         <result property="keywordId"    column="keyword_id"    />
         <result property="keyword"    column="keyword"    />
+        <result property="keywordContent"    column="keyword_content"    />
+        <result property="keywordType"    column="keyword_type"    />
+        <result property="remark"    column="remark"    />
         <result property="companyId"    column="company_id"    />
         <result property="createTime"    column="create_time"    />
         <result property="updateTime"    column="update_time"    />
+        <result property="typeName"    column="type_name"    />
+        <result property="companyName"    column="company_name"    />
     </resultMap>
 
     <sql id="selectSysKeyword">
-        select keyword_id, keyword, company_id, create_time, update_time from sys_keyword
+        select keyword_id, keyword, keyword_type, keyword_content, remark, company_id, create_time, update_time from sys_keyword
     </sql>
 
     <sql id="selectSysKeywordVo">
         SELECT
-            keyword_id,
-            keyword,
+            sys_keyword.keyword_id,
+            sys_keyword.keyword,
+            sys_keyword.keyword_type,
+            sys_keyword.keyword_content,
+            sys_keyword.remark,
             sys_keyword.company_id,
             sys_keyword.create_time,
             sys_keyword.update_time,
+            d.dict_label AS type_name,
             company.company_name
         FROM
             sys_keyword
                 LEFT JOIN company ON company.company_id = sys_keyword.company_id
+                LEFT JOIN sys_dict_data d ON d.dict_type = 'keyword_type'
+                    AND d.dict_value = CAST(sys_keyword.keyword_type AS CHAR)
     </sql>
 
     <select id="selectSysKeywordList" resultMap="SysKeywordResult">
         <include refid="selectSysKeywordVo"/>
         <where>
-            <if test="keyword != null  and keyword != ''"> and keyword like concat('%', #{keyword}, '%')</if>
+            <if test="keyword != null  and keyword != ''"> and sys_keyword.keyword like concat('%', #{keyword}, '%')</if>
+            <if test="keywordType != null"> and sys_keyword.keyword_type = #{keywordType}</if>
             <if test="companyId != null "> and sys_keyword.company_id = #{companyId}</if>
         </where>
+        order by sys_keyword.keyword_id desc
     </select>
 
     <select id="selectSysKeywordById" resultMap="SysKeywordResult">
@@ -46,12 +59,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         insert into sys_keyword
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="keyword != null">keyword,</if>
+            <if test="keywordType != null">keyword_type,</if>
+            <if test="keywordContent != null">keyword_content,</if>
+            <if test="remark != null">remark,</if>
             <if test="companyId != null">company_id,</if>
             <if test="createTime != null">create_time,</if>
             <if test="updateTime != null">update_time,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="keyword != null">#{keyword},</if>
+            <if test="keywordType != null">#{keywordType},</if>
+            <if test="keywordContent != null">#{keywordContent},</if>
+            <if test="remark != null">#{remark},</if>
             <if test="companyId != null">#{companyId},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="updateTime != null">#{updateTime},</if>
@@ -62,6 +81,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         update sys_keyword
         <trim prefix="SET" suffixOverrides=",">
             <if test="keyword != null">keyword = #{keyword},</if>
+            <if test="keywordType != null">keyword_type = #{keywordType},</if>
+            <if test="keywordContent != null">keyword_content = #{keywordContent},</if>
+            <if test="remark != null">remark = #{remark},</if>
             <if test="companyId != null">company_id = #{companyId},</if>
             <if test="createTime != null">create_time = #{createTime},</if>
             <if test="updateTime != null">update_time = #{updateTime},</if>

+ 146 - 0
fs-service/src/main/resources/mapper/tenant/TenantDictTemplateMapper.xml

@@ -0,0 +1,146 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.tenant.dict.mapper.TenantDictTemplateMapper">
+
+    <resultMap id="TemplateTypeResult" type="com.fs.tenant.dict.domain.TenantDictTemplateType">
+        <id property="dictId" column="dict_id"/>
+        <result property="dictName" column="dict_name"/>
+        <result property="dictType" column="dict_type"/>
+        <result property="status" column="status"/>
+        <result property="isManaged" column="is_managed"/>
+        <result property="syncMode" column="sync_mode"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="remark" column="remark"/>
+    </resultMap>
+
+    <resultMap id="TemplateDataResult" type="com.fs.tenant.dict.domain.TenantDictTemplateData">
+        <id property="dictCode" column="dict_code"/>
+        <result property="dictSort" column="dict_sort"/>
+        <result property="dictLabel" column="dict_label"/>
+        <result property="dictValue" column="dict_value"/>
+        <result property="dictType" column="dict_type"/>
+        <result property="cssClass" column="css_class"/>
+        <result property="listClass" column="list_class"/>
+        <result property="isDefault" column="is_default"/>
+        <result property="status" column="status"/>
+        <result property="isManaged" column="is_managed"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="remark" column="remark"/>
+    </resultMap>
+
+    <sql id="selectTemplateTypeVo">
+        select dict_id, dict_name, dict_type, status, is_managed, sync_mode, create_by, create_time, update_by, update_time, remark
+        from tenant_dict_template_type
+    </sql>
+
+    <sql id="selectTemplateDataVo">
+        select dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, is_managed,
+               create_by, create_time, update_by, update_time, remark
+        from tenant_dict_template_data
+    </sql>
+
+    <select id="selectTemplateTypeList" resultMap="TemplateTypeResult">
+        <include refid="selectTemplateTypeVo"/>
+        <where>
+            <if test="dictName != null and dictName != ''">and dict_name like concat('%', #{dictName}, '%')</if>
+            <if test="dictType != null and dictType != ''">and dict_type like concat('%', #{dictType}, '%')</if>
+            <if test="status != null and status != ''">and status = #{status}</if>
+        </where>
+        order by dict_id desc
+    </select>
+
+    <select id="selectTemplateTypeById" resultMap="TemplateTypeResult">
+        <include refid="selectTemplateTypeVo"/> where dict_id = #{dictId}
+    </select>
+
+    <select id="selectTemplateTypeByType" resultMap="TemplateTypeResult">
+        <include refid="selectTemplateTypeVo"/> where dict_type = #{dictType} limit 1
+    </select>
+
+    <insert id="insertTemplateType" useGeneratedKeys="true" keyProperty="dictId">
+        insert into tenant_dict_template_type(dict_name, dict_type, status, is_managed, sync_mode, remark, create_by, create_time)
+        values(#{dictName}, #{dictType}, #{status}, #{isManaged}, #{syncMode}, #{remark}, #{createBy}, sysdate())
+    </insert>
+
+    <update id="updateTemplateType">
+        update tenant_dict_template_type
+        <set>
+            <if test="dictName != null and dictName != ''">dict_name = #{dictName},</if>
+            <if test="dictType != null and dictType != ''">dict_type = #{dictType},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="isManaged != null">is_managed = #{isManaged},</if>
+            <if test="syncMode != null and syncMode != ''">sync_mode = #{syncMode},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+            update_time = sysdate()
+        </set>
+        where dict_id = #{dictId}
+    </update>
+
+    <delete id="deleteTemplateTypeByIds">
+        delete from tenant_dict_template_type where dict_id in
+        <foreach collection="array" item="dictId" open="(" separator="," close=")">#{dictId}</foreach>
+    </delete>
+
+    <select id="selectTemplateDataList" resultMap="TemplateDataResult">
+        <include refid="selectTemplateDataVo"/>
+        <where>
+            <if test="dictType != null and dictType != ''">and dict_type = #{dictType}</if>
+            <if test="dictLabel != null and dictLabel != ''">and dict_label like concat('%', #{dictLabel}, '%')</if>
+            <if test="status != null and status != ''">and status = #{status}</if>
+        </where>
+        order by dict_sort asc, dict_code asc
+    </select>
+
+    <select id="selectTemplateDataById" resultMap="TemplateDataResult">
+        <include refid="selectTemplateDataVo"/> where dict_code = #{dictCode}
+    </select>
+
+    <select id="selectTemplateDataByTypeAndValue" resultMap="TemplateDataResult">
+        <include refid="selectTemplateDataVo"/>
+        where dict_type = #{dictType} and dict_value = #{dictValue} limit 1
+    </select>
+
+    <select id="selectTemplateDataByType" resultMap="TemplateDataResult">
+        <include refid="selectTemplateDataVo"/> where dict_type = #{dictType} order by dict_sort asc
+    </select>
+
+    <insert id="insertTemplateData" useGeneratedKeys="true" keyProperty="dictCode">
+        insert into tenant_dict_template_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, is_managed, remark, create_by, create_time)
+        values(#{dictSort}, #{dictLabel}, #{dictValue}, #{dictType}, #{cssClass}, #{listClass}, #{isDefault}, #{status}, #{isManaged}, #{remark}, #{createBy}, sysdate())
+    </insert>
+
+    <update id="updateTemplateData">
+        update tenant_dict_template_data
+        <set>
+            <if test="dictSort != null">dict_sort = #{dictSort},</if>
+            <if test="dictLabel != null and dictLabel != ''">dict_label = #{dictLabel},</if>
+            <if test="dictValue != null and dictValue != ''">dict_value = #{dictValue},</if>
+            <if test="dictType != null and dictType != ''">dict_type = #{dictType},</if>
+            <if test="cssClass != null">css_class = #{cssClass},</if>
+            <if test="listClass != null">list_class = #{listClass},</if>
+            <if test="isDefault != null">is_default = #{isDefault},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="isManaged != null">is_managed = #{isManaged},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+            update_time = sysdate()
+        </set>
+        where dict_code = #{dictCode}
+    </update>
+
+    <delete id="deleteTemplateDataByIds">
+        delete from tenant_dict_template_data where dict_code in
+        <foreach collection="array" item="dictCode" open="(" separator="," close=")">#{dictCode}</foreach>
+    </delete>
+
+    <select id="countTemplateDataByType" resultType="int">
+        select count(1) from tenant_dict_template_data where dict_type = #{dictType}
+    </select>
+</mapper>