Przeglądaj źródła

fs-agent 登录修改

xgb 1 tydzień temu
rodzic
commit
30290d5370
62 zmienionych plików z 5481 dodań i 993 usunięć
  1. 0 225
      fs-admin-saas/src/main/java/com/fs/proxy/controller/BalanceController.java
  2. 0 230
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyController.java
  3. 0 65
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyLoginController.java
  4. 0 37
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyModuleConsumptionController.java
  5. 0 83
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyModuleUsageController.java
  6. 0 63
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyQuotaController.java
  7. 0 110
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyServicePriceController.java
  8. 0 131
      fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyTenantRelController.java
  9. 1 12
      fs-agent/pom.xml
  10. 182 0
      fs-agent/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  11. 79 0
      fs-agent/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  12. 319 0
      fs-agent/src/main/java/com/fs/framework/aspectj/LogAspect.java
  13. 241 0
      fs-agent/src/main/java/com/fs/framework/aspectj/ProxyLogAspect.java
  14. 117 0
      fs-agent/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  15. 31 0
      fs-agent/src/main/java/com/fs/framework/config/ApplicationConfig.java
  16. 58 0
      fs-agent/src/main/java/com/fs/framework/config/ArrayStringTypeHandler.java
  17. 85 0
      fs-agent/src/main/java/com/fs/framework/config/CaptchaConfig.java
  18. 109 0
      fs-agent/src/main/java/com/fs/framework/config/DataSourceConfig.java
  19. 126 0
      fs-agent/src/main/java/com/fs/framework/config/DruidConfig.java
  20. 72 0
      fs-agent/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  21. 59 0
      fs-agent/src/main/java/com/fs/framework/config/FilterConfig.java
  22. 27 0
      fs-agent/src/main/java/com/fs/framework/config/HybridBeanNameGenerator.java
  23. 76 0
      fs-agent/src/main/java/com/fs/framework/config/KaptchaTextCreator.java
  24. 58 0
      fs-agent/src/main/java/com/fs/framework/config/LogInterceptor.java
  25. 149 0
      fs-agent/src/main/java/com/fs/framework/config/MyBatisConfig.java
  26. 29 0
      fs-agent/src/main/java/com/fs/framework/config/OverridingBeanNameGenerator.java
  27. 81 0
      fs-agent/src/main/java/com/fs/framework/config/ResourcesConfig.java
  28. 181 0
      fs-agent/src/main/java/com/fs/framework/config/SecurityConfig.java
  29. 33 0
      fs-agent/src/main/java/com/fs/framework/config/ServerConfig.java
  30. 10 0
      fs-agent/src/main/java/com/fs/framework/config/TenantPrincipal.java
  31. 63 0
      fs-agent/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  32. 35 0
      fs-agent/src/main/java/com/fs/framework/config/ThreadPoolTaskWrapExecutor.java
  33. 77 0
      fs-agent/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  34. 27 0
      fs-agent/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  35. 45 0
      fs-agent/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  36. 102 0
      fs-agent/src/main/java/com/fs/framework/datasource/TenantDataSourceContextHelper.java
  37. 147 0
      fs-agent/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java
  38. 56 0
      fs-agent/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  39. 126 0
      fs-agent/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  40. 56 0
      fs-agent/src/main/java/com/fs/framework/manager/AsyncManager.java
  41. 40 0
      fs-agent/src/main/java/com/fs/framework/manager/ShutdownManager.java
  42. 145 0
      fs-agent/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java
  43. 111 0
      fs-agent/src/main/java/com/fs/framework/security/filter/JwtAuthenticationTokenFilter.java
  44. 35 0
      fs-agent/src/main/java/com/fs/framework/security/handle/AuthenticationEntryPointImpl.java
  45. 54 0
      fs-agent/src/main/java/com/fs/framework/security/handle/LogoutSuccessHandlerImpl.java
  46. 120 0
      fs-agent/src/main/java/com/fs/framework/task/TenantTaskRunner.java
  47. 60 0
      fs-agent/src/main/java/com/fs/framework/util/ThreadMdcUtil.java
  48. 237 0
      fs-agent/src/main/java/com/fs/framework/web/domain/Server.java
  49. 101 0
      fs-agent/src/main/java/com/fs/framework/web/domain/server/Cpu.java
  50. 123 0
      fs-agent/src/main/java/com/fs/framework/web/domain/server/Jvm.java
  51. 61 0
      fs-agent/src/main/java/com/fs/framework/web/domain/server/Mem.java
  52. 84 0
      fs-agent/src/main/java/com/fs/framework/web/domain/server/Sys.java
  53. 114 0
      fs-agent/src/main/java/com/fs/framework/web/domain/server/SysFile.java
  54. 126 0
      fs-agent/src/main/java/com/fs/framework/web/exception/GlobalExceptionHandler.java
  55. 166 0
      fs-agent/src/main/java/com/fs/framework/web/service/PermissionService.java
  56. 464 0
      fs-agent/src/main/java/com/fs/framework/web/service/SysLoginService.java
  57. 67 0
      fs-agent/src/main/java/com/fs/framework/web/service/SysPermissionService.java
  58. 115 0
      fs-agent/src/main/java/com/fs/framework/web/service/SysRegisterService.java
  59. 311 0
      fs-agent/src/main/java/com/fs/framework/web/service/TokenService.java
  60. 57 0
      fs-agent/src/main/java/com/fs/framework/web/service/UserDetailsServiceImpl.java
  61. 11 37
      fs-agent/src/main/java/com/fs/proxy/controller/ProxyLoginController.java
  62. 22 0
      fs-framework/src/main/java/com/fs/framework/web/service/TokenService.java

+ 0 - 225
fs-admin-saas/src/main/java/com/fs/proxy/controller/BalanceController.java

@@ -1,225 +0,0 @@
-package com.fs.proxy.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.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.proxy.domain.TenantBalance;
-import com.fs.proxy.domain.TenantConsumeRecord;
-import com.fs.proxy.domain.ServiceFeeConfig;
-import com.fs.proxy.enums.ConsumeTypeEnum;
-import com.fs.proxy.domain.ProxyTenantRel;
-import com.fs.proxy.service.BalanceService;
-import com.fs.proxy.service.ProxyTenantRelService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-import java.math.BigDecimal;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * 租户余额消费控制器
- *
- * @author fs
- * @date 2024-01-01
- */
-@RestController
-@RequestMapping("/proxy/balance")
-public class BalanceController extends BaseController {
-
-    @Autowired
-    private BalanceService balanceService;
-
-    @Autowired
-    private ProxyTenantRelService proxyTenantRelService;
-
-    private boolean verifyTenantAccess(Long tenantId) {
-        Long proxyId = com.fs.common.utils.SecurityUtils.getLoginUser().getProxyId();
-        if (proxyId == null) {
-            return false;
-        }
-        ProxyTenantRel rel = proxyTenantRelService.selectProxyTenantRelByTenantId(tenantId);
-        return rel != null && rel.getProxyId().equals(proxyId);
-    }
-
-    /**
-     * 获取租户余额信息
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:view')")
-    @GetMapping("/info/{tenantId}")
-    public AjaxResult getBalanceInfo(@PathVariable Long tenantId) {
-        if (!verifyTenantAccess(tenantId)) {
-            return AjaxResult.error("无权查看该租户余额");
-        }
-        TenantBalance balance = balanceService.getTenantBalance(tenantId);
-        return AjaxResult.success(balance);
-    }
-
-    /**
-     * 充值到总账户
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:recharge')")
-    @Log(title = "余额充值", businessType = BusinessType.INSERT)
-    @PostMapping("/recharge")
-    public AjaxResult recharge(@RequestBody Map<String, Object> params) {
-        if (params.get("tenantId") == null || params.get("amount") == null) {
-            return AjaxResult.error("参数不完整");
-        }
-        Long tenantId = Long.parseLong(params.get("tenantId").toString());
-        if (!verifyTenantAccess(tenantId)) {
-            return AjaxResult.error("无权操作该租户余额");
-        }
-        BigDecimal amount = new BigDecimal(params.get("amount").toString());
-        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
-            return AjaxResult.error("充值金额必须大于0");
-        }
-        String orderNo = params.getOrDefault("orderNo", UUID.randomUUID().toString().replace("-", "").substring(0, 20)).toString();
-        
-        boolean success = balanceService.rechargeToTotal(tenantId, amount, orderNo);
-        return success ? AjaxResult.success("充值成功") : AjaxResult.error("充值失败");
-    }
-
-    /**
-     * 购买服务
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:purchase')")
-    @Log(title = "购买服务", businessType = BusinessType.UPDATE)
-    @PostMapping("/purchase")
-    public AjaxResult purchaseService(@RequestBody Map<String, Object> params) {
-        if (params.get("tenantId") == null || params.get("serviceType") == null || params.get("quantity") == null) {
-            return AjaxResult.error("参数不完整");
-        }
-        Long tenantId = Long.parseLong(params.get("tenantId").toString());
-        if (!verifyTenantAccess(tenantId)) {
-            return AjaxResult.error("无权操作该租户余额");
-        }
-        Integer serviceType = Integer.parseInt(params.get("serviceType").toString());
-        Integer quantity = Integer.parseInt(params.get("quantity").toString());
-        if (quantity <= 0) {
-            return AjaxResult.error("购买数量必须大于0");
-        }
-        String orderNo = params.getOrDefault("orderNo", UUID.randomUUID().toString().replace("-", "").substring(0, 20)).toString();
-
-        ConsumeTypeEnum consumeType = ConsumeTypeEnum.getByCode(serviceType);
-        if (consumeType == null) {
-            return AjaxResult.error("无效的服务类型");
-        }
-
-        boolean success = balanceService.purchaseService(tenantId, consumeType, quantity, orderNo);
-        return success ? AjaxResult.success("购买成功") : AjaxResult.error("购买失败,余额不足");
-    }
-
-    /**
-     * 服务余额转总余额
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:transfer')")
-    @Log(title = "余额转换", businessType = BusinessType.UPDATE)
-    @PostMapping("/transfer")
-    public AjaxResult transferToTotal(@RequestBody Map<String, Object> params) {
-        if (params.get("tenantId") == null || params.get("serviceType") == null || params.get("quantity") == null) {
-            return AjaxResult.error("参数不完整");
-        }
-        Long tenantId = Long.parseLong(params.get("tenantId").toString());
-        if (!verifyTenantAccess(tenantId)) {
-            return AjaxResult.error("无权操作该租户余额");
-        }
-        Integer serviceType = Integer.parseInt(params.get("serviceType").toString());
-        Integer quantity = Integer.parseInt(params.get("quantity").toString());
-        if (quantity <= 0) {
-            return AjaxResult.error("转换数量必须大于0");
-        }
-
-        ConsumeTypeEnum consumeType = ConsumeTypeEnum.getByCode(serviceType);
-        if (consumeType == null) {
-            return AjaxResult.error("无效的服务类型");
-        }
-
-        boolean success = balanceService.transferToTotal(tenantId, consumeType, quantity);
-        return success ? AjaxResult.success("转换成功") : AjaxResult.error("转换失败");
-    }
-
-    /**
-     * 获取消费记录列表
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:view')")
-    @GetMapping("/records")
-    public TableDataInfo getConsumeRecords(Long tenantId, Integer serviceType, Integer pageNum, Integer pageSize) {
-        if (tenantId == null || !verifyTenantAccess(tenantId)) {
-            return getDataTable(new java.util.ArrayList<>());
-        }
-        startPage();
-        List<TenantConsumeRecord> list = balanceService.getConsumeRecords(tenantId, serviceType, null, null);
-        return getDataTable(list);
-    }
-
-    /**
-     * 获取余额流水
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:view')")
-    @GetMapping("/flow")
-    public TableDataInfo getBalanceFlow(Long tenantId, Integer consumeType,
-                                        @RequestParam(required = false) String keyword,
-                                        @RequestParam(required = false) String startTime,
-                                        @RequestParam(required = false) String endTime) {
-        if (tenantId == null || !verifyTenantAccess(tenantId)) {
-            return getDataTable(new java.util.ArrayList<>());
-        }
-        startPage();
-        List<TenantConsumeRecord> list = balanceService.getConsumeRecords(tenantId, consumeType, null, null);
-        return getDataTable(list);
-    }
-
-    /**
-     * 获取服务收费配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:config')")
-    @GetMapping("/fee/config")
-    public AjaxResult getFeeConfigs() {
-        List<ServiceFeeConfig> configs = balanceService.getAllFeeConfigs();
-        return AjaxResult.success(configs);
-    }
-
-    /**
-     * 更新服务收费配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:config')")
-    @Log(title = "更新收费配置", businessType = BusinessType.UPDATE)
-    @PostMapping("/fee/config/update")
-    public AjaxResult updateFeeConfig(@RequestBody ServiceFeeConfig config) {
-        boolean success = balanceService.updateFeeConfig(config);
-        return success ? AjaxResult.success("更新成功") : AjaxResult.error("更新失败");
-    }
-
-    /**
-     * 计算账户年费
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:fee')")
-    @Log(title = "计算账户年费", businessType = BusinessType.UPDATE)
-    @PostMapping("/account/fee")
-    public AjaxResult calculateAccountFee(@RequestBody Map<String, Object> params) {
-        Long tenantId = Long.parseLong(params.get("tenantId").toString());
-        if (!verifyTenantAccess(tenantId)) {
-            return AjaxResult.error("无权操作该租户余额");
-        }
-        boolean success = balanceService.calculateAccountFee(tenantId);
-        return success ? AjaxResult.success("扣费成功") : AjaxResult.error("扣费失败,余额不足");
-    }
-
-    /**
-     * 检查服务余额
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:balance:view')")
-    @GetMapping("/check")
-    public AjaxResult checkBalance(Long tenantId, Integer serviceType, Integer quantity) {
-        ConsumeTypeEnum consumeType = ConsumeTypeEnum.getByCode(serviceType);
-        if (consumeType == null) {
-            return AjaxResult.error("无效的服务类型");
-        }
-        boolean sufficient = balanceService.checkBalance(tenantId, consumeType, quantity);
-        return AjaxResult.success(sufficient);
-    }
-}

+ 0 - 230
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyController.java

@@ -1,230 +0,0 @@
-package com.fs.proxy.controller;
-
-import java.util.List;
-import java.util.Map;
-
-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.model.LoginUser;
-import com.fs.common.core.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.common.utils.SecurityUtils;
-import com.fs.proxy.domain.Proxy;
-import com.fs.proxy.domain.ProxyServicePrice;
-import com.fs.proxy.domain.ProxyTenantRel;
-import com.fs.proxy.domain.ProxyWithdraw;
-import com.fs.proxy.service.ProxyDashboardService;
-import com.fs.proxy.service.ProxyService;
-import com.fs.proxy.service.ProxyServicePriceService;
-import com.fs.proxy.service.ProxyTenantRelService;
-import com.fs.proxy.service.ProxyWithdrawService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-@RestController
-@RequestMapping("/proxy")
-public class ProxyController extends BaseController
-{
-    @Autowired
-    private ProxyTenantRelService proxyTenantRelService;
-
-    @Autowired
-    private ProxyDashboardService proxyDashboardService;
-
-    @Autowired
-    private ProxyServicePriceService proxyServicePriceService;
-
-    @Autowired
-    private ProxyService proxyService;
-
-    @Autowired
-    private ProxyWithdrawService proxyWithdrawService;
-
-    @PreAuthorize("@ss.hasPermi('proxy:dashboard:view')")
-    @GetMapping("/dashboard")
-    public AjaxResult getDashboard() {
-        Long proxyId = getCurrentProxyId();
-        Map<String, Object> statistics = proxyDashboardService.getDashboardStatistics(proxyId);
-        return AjaxResult.success(statistics);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:tenant:list')")
-    @GetMapping("/tenants")
-    public TableDataInfo getTenantList() {
-        Long proxyId = getCurrentProxyId();
-        startPage();
-        List<ProxyTenantRel> list = proxyTenantRelService.selectProxyTenantRelByProxyId(proxyId);
-        return getDataTable(list);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:tenant:statistics')")
-    @GetMapping("/tenant/consume")
-    public AjaxResult getTenantConsume(@RequestParam(required = false) String month) {
-        Long proxyId = getCurrentProxyId();
-        Map<String, Object> statistics = proxyDashboardService.getTenantConsumeStatistics(proxyId, month);
-        return AjaxResult.success(statistics);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:profit:view')")
-    @GetMapping("/profit")
-    public AjaxResult getProfitStatistics(@RequestParam(required = false) String month) {
-        Long proxyId = getCurrentProxyId();
-        Map<String, Object> statistics = proxyDashboardService.getProfitStatistics(proxyId, month);
-        return AjaxResult.success(statistics);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:price:view')")
-    @GetMapping("/priceConfig")
-    public AjaxResult getPriceConfig() {
-        Long proxyId = getCurrentProxyId();
-        List<ProxyServicePrice> priceList = proxyServicePriceService.selectProxyServicePriceByProxyId(proxyId);
-        return AjaxResult.success(priceList);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:info:view')")
-    @GetMapping("/info")
-    public AjaxResult getProxyInfo() {
-        Long proxyId = getCurrentProxyId();
-        Proxy proxy = proxyService.selectProxyById(proxyId);
-        if (proxy == null) {
-            return AjaxResult.error("代理信息不存在");
-        }
-        return AjaxResult.success(proxy);
-    }
-
-    /**
-     * 从登录上下文获取当前代理ID
-     * 优先从 LoginUser.proxyId 获取,最后尝试通过 userId 查代理
-     */
-    private Long getCurrentProxyId() {
-        LoginUser loginUser = SecurityUtils.getLoginUser();
-        if (loginUser != null && loginUser.getProxyId() != null) {
-            return loginUser.getProxyId();
-        }
-        if (loginUser != null && loginUser.getUserId() != null) {
-            Proxy proxy = proxyService.selectProxyById(loginUser.getUserId());
-            if (proxy != null) return proxy.getProxyId();
-        }
-        throw new RuntimeException("无法确定当前代理身份");
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:tenant:add')")
-    @Log(title = "新增租户", businessType = BusinessType.INSERT)
-    @PostMapping("/tenants")
-    public AjaxResult addTenant(@RequestBody ProxyTenantRel proxyTenantRel) {
-        Long proxyId = getCurrentProxyId();
-        proxyTenantRel.setProxyId(proxyId);
-        return toAjax(proxyTenantRelService.insertProxyTenantRel(proxyTenantRel));
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:tenant:edit')")
-    @Log(title = "更新租户", businessType = BusinessType.UPDATE)
-    @PutMapping("/tenants")
-    public AjaxResult updateTenant(@RequestBody ProxyTenantRel proxyTenantRel) {
-        Long proxyId = getCurrentProxyId();
-        if (proxyTenantRel.getTenantId() != null) {
-            ProxyTenantRel existing = proxyTenantRelService.selectProxyTenantRelByTenantId(proxyTenantRel.getTenantId());
-            if (existing == null || !existing.getProxyId().equals(proxyId)) {
-                return AjaxResult.error("无权操作该租户");
-            }
-        }
-        proxyTenantRel.setProxyId(proxyId);
-        return toAjax(proxyTenantRelService.updateProxyTenantRel(proxyTenantRel));
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:tenant:edit')")
-    @Log(title = "切换租户状态", businessType = BusinessType.UPDATE)
-    @PutMapping("/tenants/{tenantId}/status")
-    public AjaxResult toggleTenantStatus(@PathVariable Long tenantId, @RequestParam Integer status) {
-        Long proxyId = getCurrentProxyId();
-        ProxyTenantRel rel = proxyTenantRelService.selectProxyTenantRelByTenantId(tenantId);
-        if (rel == null) {
-            return AjaxResult.error("租户关联不存在");
-        }
-        if (!rel.getProxyId().equals(proxyId)) {
-            return AjaxResult.error("无权操作该租户");
-        }
-        rel.setStatus(status);
-        return toAjax(proxyTenantRelService.updateProxyTenantRel(rel));
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:profit:view')")
-    @GetMapping("/profit/list")
-    public TableDataInfo getProfitList(@RequestParam(required = false) String month) {
-        Long proxyId = getCurrentProxyId();
-        startPage();
-        Map<String, Object> statistics = proxyDashboardService.getProfitStatistics(proxyId, month);
-        return getDataTable((List<?>) statistics.get("tenantProfit"));
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:performance:view')")
-    @GetMapping("/performance/list")
-    public TableDataInfo getPerformanceList(@RequestParam(required = false) String period) {
-        Long proxyId = getCurrentProxyId();
-        startPage();
-        Map<String, Object> statistics = proxyDashboardService.getTenantConsumeStatistics(proxyId, period);
-        return getDataTable((List<?>) statistics.get("tenantList"));
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:withdraw:list')")
-    @GetMapping("/withdraw/list")
-    public TableDataInfo getWithdrawList(ProxyWithdraw proxyWithdraw) {
-        Long proxyId = getCurrentProxyId();
-        proxyWithdraw.setProxyId(proxyId);
-        startPage();
-        List<ProxyWithdraw> list = proxyWithdrawService.selectProxyWithdrawList(proxyWithdraw);
-        return getDataTable(list);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:withdraw:add')")
-    @Log(title = "申请提现", businessType = BusinessType.INSERT)
-    @PostMapping("/withdraw")
-    public AjaxResult addWithdraw(@RequestBody ProxyWithdraw proxyWithdraw) {
-        Long proxyId = getCurrentProxyId();
-        proxyWithdraw.setProxyId(proxyId);
-        proxyWithdraw.setStatus(0);
-        return toAjax(proxyWithdrawService.insertProxyWithdraw(proxyWithdraw));
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:withdraw:audit')")
-    @Log(title = "审核提现", businessType = BusinessType.UPDATE)
-    @PutMapping("/withdraw/audit/{id}")
-    public AjaxResult auditWithdraw(@PathVariable Long id, @RequestParam Integer status,
-                                    @RequestParam(required = false) String failReason) {
-        Long proxyId = getCurrentProxyId();
-        ProxyWithdraw withdraw = proxyWithdrawService.selectProxyWithdrawById(id);
-        if (withdraw == null) {
-            return AjaxResult.error("提现记录不存在");
-        }
-        if (!withdraw.getProxyId().equals(proxyId)) {
-            return AjaxResult.error("无权审核该提现记录");
-        }
-        Long auditorId = SecurityUtils.getLoginUser().getUserId();
-        int result = proxyWithdrawService.auditWithdraw(id, status, auditorId, failReason);
-        return result > 0 ? AjaxResult.success("审核成功") : AjaxResult.error("审核失败");
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:withdraw:pay')")
-    @Log(title = "提现打款", businessType = BusinessType.UPDATE)
-    @PutMapping("/withdraw/pay/{id}")
-    public AjaxResult payWithdraw(@PathVariable Long id) {
-        Long proxyId = getCurrentProxyId();
-        ProxyWithdraw withdraw = proxyWithdrawService.selectProxyWithdrawById(id);
-        if (withdraw == null) {
-            return AjaxResult.error("提现记录不存在");
-        }
-        if (!withdraw.getProxyId().equals(proxyId)) {
-            return AjaxResult.error("无权操作该提现记录");
-        }
-        int result = proxyWithdrawService.payWithdraw(id);
-        return result > 0 ? AjaxResult.success("打款成功") : AjaxResult.error("打款失败");
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:withdraw:list')")
-    @GetMapping("/withdraw/countPending")
-    public AjaxResult countPendingWithdraw() {
-        int count = proxyWithdrawService.countPendingWithdraw();
-        return AjaxResult.success(count);
-    }
-}

+ 0 - 65
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyLoginController.java

@@ -1,65 +0,0 @@
-package com.fs.proxy.controller;
-
-import com.fs.common.constant.Constants;
-import com.fs.common.core.domain.AjaxResult;
-import com.fs.common.core.domain.entity.SysUser;
-import com.fs.common.core.domain.model.LoginBody;
-import com.fs.common.core.domain.model.LoginUser;
-import com.fs.common.utils.SecurityUtils;
-import com.fs.framework.web.service.SysLoginService;
-import com.fs.framework.web.service.TokenService;
-import com.fs.proxy.domain.Proxy;
-import com.fs.proxy.service.ProxyService;
-import com.fs.system.service.ISysUserService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
-@RestController
-@RequestMapping("/proxy")
-public class ProxyLoginController {
-
-    @Autowired
-    private SysLoginService loginService;
-
-    @Autowired
-    private TokenService tokenService;
-
-    @Autowired
-    private ProxyService proxyService;
-
-    @Autowired
-    private ISysUserService userService;
-
-    @PostMapping("/login")
-    public AjaxResult login(@RequestBody LoginBody loginBody) {
-        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(),
-                loginBody.getCode(), loginBody.getUuid(), loginBody.getTenantCode());
-
-        Proxy proxy = proxyService.selectProxyByName(loginBody.getUsername());
-        if (proxy == null) {
-            SysUser user = userService.selectUserByUserName(loginBody.getUsername());
-            if (user != null) {
-                proxy = proxyService.selectProxyById(user.getUserId());
-            }
-        }
-        if (proxy != null) {
-            if (proxy.getStatus() != null && proxy.getStatus() != 1) {
-                return AjaxResult.error("该代理账号已被禁用");
-            }
-            try {
-                LoginUser loginUser = SecurityUtils.getLoginUser();
-                if (loginUser != null) {
-                    loginUser.setProxyId(proxy.getProxyId());
-                    tokenService.refreshToken(loginUser);
-                }
-            } catch (Exception ignored) {
-                // 无状态JWT模式,登录时SecurityContext未设置,忽略
-            }
-            AjaxResult ajax = AjaxResult.success();
-            ajax.put(Constants.TOKEN, token);
-            ajax.put("proxyInfo", proxy);
-            return ajax;
-        } else {
-            return AjaxResult.error("该账号不是代理账号");
-        }
-    }
-}

+ 0 - 37
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyModuleConsumptionController.java

@@ -1,37 +0,0 @@
-package com.fs.proxy.controller;
-
-import com.fs.billing.service.ModuleConsumptionService;
-import com.fs.billing.vo.ModuleConsumptionVo;
-import com.fs.common.core.controller.BaseController;
-import com.fs.common.core.domain.AjaxResult;
-import com.fs.common.utils.SecurityUtils;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-/**
- * Agent代理端-租户模块消费统计控制器
- * 代理登录后可查看其下所有租户的模块消费情况
- */
-@RestController
-@RequestMapping("/proxy/module-consumption")
-public class ProxyModuleConsumptionController extends BaseController {
-
-    @Autowired
-    private ModuleConsumptionService moduleConsumptionService;
-
-    /**
-     * 获取归属代理下所有租户的模块消费统计报告
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:list')")
-    @GetMapping("/report")
-    public AjaxResult report(@RequestParam(required = false) String beginTime,
-                             @RequestParam(required = false) String endTime) {
-        Long proxyId = SecurityUtils.getLoginUser().getProxyId();
-        if (proxyId == null) {
-            return AjaxResult.error("当前用户无代理身份");
-        }
-        ModuleConsumptionVo vo = moduleConsumptionService.reportForProxy(proxyId, beginTime, endTime);
-        return AjaxResult.success(vo);
-    }
-}

+ 0 - 83
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyModuleUsageController.java

@@ -1,83 +0,0 @@
-package com.fs.proxy.controller;
-
-import java.util.List;
-import java.util.Map;
-
-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.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.proxy.domain.TenantModuleUsage;
-import com.fs.proxy.service.TenantModuleUsageService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-/**
- * Agent端-租户模块使用统计控制器
- * 代理登录后可查看其下所有租户的模块使用情况
- * 用途:了解客户消耗、发现未使用模块、推广新模块
- */
-@RestController
-@RequestMapping("/proxy/module-usage")
-public class ProxyModuleUsageController extends BaseController {
-
-    @Autowired
-    private TenantModuleUsageService tenantModuleUsageService;
-
-    /**
-     * 查询租户模块使用统计列表
-     * 支持按租户名称搜索、日期范围筛选
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(TenantModuleUsage query) {
-        startPage();
-        List<TenantModuleUsage> list = tenantModuleUsageService.selectTenantModuleUsageList(query);
-        return getDataTable(list);
-    }
-
-    /**
-     * 获取模块使用概览(仪表盘数据)
-     * 返回:活跃租户数、各模块使用率、未使用模块的租户列表
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:list')")
-    @GetMapping("/overview")
-    public AjaxResult overview(@RequestParam Long proxyId) {
-        Map<String, Object> data = tenantModuleUsageService.getModuleUsageOverview(proxyId);
-        return AjaxResult.success(data);
-    }
-
-    /**
-     * 查询指定租户的最新统计详情
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:list')")
-    @GetMapping("/tenant/{tenantId}")
-    public AjaxResult tenantDetail(@PathVariable Long tenantId) {
-        TenantModuleUsage usage = tenantModuleUsageService.selectLatestByTenantId(tenantId);
-        return AjaxResult.success(usage);
-    }
-
-    /**
-     * 手动触发统计汇总(用于测试或补数据)
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:refresh')")
-    @Log(title = "租户模块使用统计", businessType = BusinessType.INSERT)
-    @PostMapping("/refresh")
-    public AjaxResult refreshStatistics() {
-        tenantModuleUsageService.executeDailyStatistics();
-        return AjaxResult.success("统计任务已触发");
-    }
-
-    /**
-     * 手动触发指定租户的统计汇总
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:refresh')")
-    @Log(title = "租户模块使用统计", businessType = BusinessType.INSERT)
-    @PostMapping("/refresh/{tenantId}")
-    public AjaxResult refreshTenantStatistics(@PathVariable Long tenantId) {
-        tenantModuleUsageService.statisticsForTenant(tenantId);
-        return AjaxResult.success("租户" + tenantId + "统计已刷新");
-    }
-}

+ 0 - 63
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyQuotaController.java

@@ -1,63 +0,0 @@
-package com.fs.proxy.controller;
-
-import java.util.List;
-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.model.LoginUser;
-import com.fs.common.core.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.common.utils.SecurityUtils;
-import com.fs.proxy.domain.ProxyTenantQuota;
-import com.fs.proxy.domain.ProxyTenantRel;
-import com.fs.proxy.service.ProxyTenantQuotaService;
-import com.fs.proxy.service.ProxyTenantRelService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-@RestController
-@RequestMapping("/proxy/quota")
-public class ProxyQuotaController extends BaseController {
-
-    @Autowired
-    private ProxyTenantQuotaService proxyTenantQuotaService;
-
-    @Autowired
-    private ProxyTenantRelService proxyTenantRelService;
-
-    private Long getCurrentProxyId() {
-        LoginUser loginUser = SecurityUtils.getLoginUser();
-        if (loginUser != null && loginUser.getProxyId() != null) {
-            return loginUser.getProxyId();
-        }
-        throw new RuntimeException("无法确定当前代理身份");
-    }
-
-    private boolean verifyTenantAccess(Long tenantId) {
-        Long proxyId = getCurrentProxyId();
-        ProxyTenantRel rel = proxyTenantRelService.selectProxyTenantRelByTenantId(tenantId);
-        return rel != null && rel.getProxyId().equals(proxyId);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:quota:list')")
-    @GetMapping("/list")
-    public TableDataInfo getQuotaList(ProxyTenantQuota quota) {
-        Long proxyId = getCurrentProxyId();
-        startPage();
-        List<ProxyTenantQuota> list = proxyTenantQuotaService.selectQuotaByProxyId(proxyId);
-        return getDataTable(list);
-    }
-
-    @PreAuthorize("@ss.hasPermi('proxy:quota:edit')")
-    @Log(title = "调整配额", businessType = BusinessType.UPDATE)
-    @PostMapping("/update")
-    public AjaxResult updateQuota(@RequestBody ProxyTenantQuota quota) {
-        if (quota.getQuotaId() == null) {
-            return AjaxResult.error("配额ID不能为空");
-        }
-        if (quota.getTenantId() != null && !verifyTenantAccess(quota.getTenantId())) {
-            return AjaxResult.error("无权操作该租户配额");
-        }
-        return toAjax(proxyTenantQuotaService.updateQuota(quota));
-    }
-}

+ 0 - 110
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyServicePriceController.java

@@ -1,110 +0,0 @@
-package com.fs.proxy.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.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.proxy.domain.ProxyServicePrice;
-import com.fs.proxy.service.ProxyServicePriceService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
-
-/**
- * 代理服务价格配置控制器
- */
-@RestController
-@RequestMapping("/proxy/servicePrice")
-public class ProxyServicePriceController extends BaseController {
-
-    @Autowired
-    private ProxyServicePriceService proxyServicePriceService;
-
-    /**
-     * 查询代理服务价格配置列表
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(ProxyServicePrice proxyServicePrice) {
-        startPage();
-        List<ProxyServicePrice> list = proxyServicePriceService.selectProxyServicePriceList(proxyServicePrice);
-        return getDataTable(list);
-    }
-
-    /**
-     * 获取代理服务价格配置详细信息
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:query')")
-    @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id) {
-        return AjaxResult.success(proxyServicePriceService.selectProxyServicePriceById(id));
-    }
-
-    /**
-     * 根据代理商ID查询服务价格配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:query')")
-    @GetMapping("/byProxy/{proxyId}")
-    public AjaxResult getByProxyId(@PathVariable("proxyId") Long proxyId) {
-        List<ProxyServicePrice> list = proxyServicePriceService.selectProxyServicePriceByProxyId(proxyId);
-        return AjaxResult.success(list);
-    }
-
-    /**
-     * 新增代理服务价格配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:add')")
-    @Log(title = "代理服务价格配置", businessType = BusinessType.INSERT)
-    @PostMapping
-    public AjaxResult add(@RequestBody ProxyServicePrice proxyServicePrice) {
-        return toAjax(proxyServicePriceService.insertProxyServicePrice(proxyServicePrice));
-    }
-
-    /**
-     * 修改代理服务价格配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:edit')")
-    @Log(title = "代理服务价格配置", businessType = BusinessType.UPDATE)
-    @PutMapping
-    public AjaxResult edit(@RequestBody ProxyServicePrice proxyServicePrice) {
-        return toAjax(proxyServicePriceService.updateProxyServicePrice(proxyServicePrice));
-    }
-
-    /**
-     * 删除代理服务价格配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:remove')")
-    @Log(title = "代理服务价格配置", businessType = BusinessType.DELETE)
-    @DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids) {
-        return toAjax(proxyServicePriceService.deleteProxyServicePriceByIds(ids));
-    }
-
-    /**
-     * 为代理商初始化默认价格配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:add')")
-    @Log(title = "代理服务价格配置", businessType = BusinessType.INSERT)
-    @PostMapping("/init/{proxyId}")
-    public AjaxResult initDefaultConfig(@PathVariable Long proxyId, @RequestParam String proxyName) {
-        int count = proxyServicePriceService.initDefaultPriceConfig(proxyId, proxyName);
-        return AjaxResult.success("初始化成功,共创建 " + count + " 条配置");
-    }
-
-    /**
-     * 批量更新代理商的服务价格配置
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:servicePrice:edit')")
-    @Log(title = "代理服务价格配置", businessType = BusinessType.UPDATE)
-    @PutMapping("/batch")
-    public AjaxResult batchUpdate(@RequestBody List<ProxyServicePrice> configs) {
-        int count = 0;
-        for (ProxyServicePrice config : configs) {
-            count += proxyServicePriceService.updateProxyServicePrice(config);
-        }
-        return AjaxResult.success("批量更新成功,共更新 " + count + " 条配置");
-    }
-}

+ 0 - 131
fs-admin-saas/src/main/java/com/fs/proxy/controller/ProxyTenantRelController.java

@@ -1,131 +0,0 @@
-package com.fs.proxy.controller;
-
-import java.util.List;
-
-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.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.proxy.domain.ProxyTenantRel;
-import com.fs.proxy.service.ProxyTenantRelService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-/**
- * 代理-租户关联Controller
- * 
- * @author fs
- * @date 2024-01-01
- */
-@RestController
-@RequestMapping("/proxy/tenantRel")
-public class ProxyTenantRelController extends BaseController
-{
-    @Autowired
-    private ProxyTenantRelService proxyTenantRelService;
-
-    /**
-     * 查询代理-租户关联列表
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(ProxyTenantRel proxyTenantRel) {
-        startPage();
-        List<ProxyTenantRel> list = proxyTenantRelService.selectProxyTenantRelList(proxyTenantRel);
-        return getDataTable(list);
-    }
-
-    /**
-     * 查询代理名下租户列表
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:list')")
-    @GetMapping("/byProxy/{proxyId}")
-    public AjaxResult listByProxy(@PathVariable Long proxyId) {
-        List<ProxyTenantRel> list = proxyTenantRelService.selectProxyTenantRelByProxyId(proxyId);
-        return AjaxResult.success(list);
-    }
-
-    /**
-     * 获取代理-租户关联详细信息
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:query')")
-    @GetMapping(value = "/{relId}")
-    public AjaxResult getInfo(@PathVariable("relId") Long relId) {
-        return AjaxResult.success(proxyTenantRelService.selectProxyTenantRelById(relId));
-    }
-
-    /**
-     * 根据租户ID查询所属代理
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:query')")
-    @GetMapping("/byTenant/{tenantId}")
-    public AjaxResult getByTenantId(@PathVariable Long tenantId) {
-        ProxyTenantRel rel = proxyTenantRelService.selectProxyTenantRelByTenantId(tenantId);
-        return AjaxResult.success(rel);
-    }
-
-    /**
-     * 新增代理-租户关联
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:add')")
-    @Log(title = "代理-租户关联", businessType = BusinessType.INSERT)
-    @PostMapping
-    public AjaxResult add(@RequestBody ProxyTenantRel proxyTenantRel) {
-        return toAjax(proxyTenantRelService.insertProxyTenantRel(proxyTenantRel));
-    }
-
-    /**
-     * 修改代理-租户关联
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:edit')")
-    @Log(title = "代理-租户关联", businessType = BusinessType.UPDATE)
-    @PutMapping
-    public AjaxResult edit(@RequestBody ProxyTenantRel proxyTenantRel) {
-        return toAjax(proxyTenantRelService.updateProxyTenantRel(proxyTenantRel));
-    }
-
-    /**
-     * 删除代理-租户关联
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:remove')")
-    @Log(title = "代理-租户关联", businessType = BusinessType.DELETE)
-    @DeleteMapping("/{relIds}")
-    public AjaxResult remove(@PathVariable Long[] relIds) {
-        return toAjax(proxyTenantRelService.deleteProxyTenantRelByIds(relIds));
-    }
-
-    /**
-     * 绑定租户到代理
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:bind')")
-    @Log(title = "代理-租户绑定", businessType = BusinessType.UPDATE)
-    @PostMapping("/bind")
-    public AjaxResult bindTenant(@RequestParam Long proxyId, @RequestParam Long tenantId, 
-                                 @RequestParam(required = false) java.math.BigDecimal profitShareRatio) {
-        boolean result = proxyTenantRelService.bindTenantToProxy(proxyId, tenantId, profitShareRatio);
-        return result ? AjaxResult.success("绑定成功") : AjaxResult.error("绑定失败");
-    }
-
-    /**
-     * 解绑租户与代理
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:unbind')")
-    @Log(title = "代理-租户解绑", businessType = BusinessType.UPDATE)
-    @PostMapping("/unbind/{tenantId}")
-    public AjaxResult unbindTenant(@PathVariable Long tenantId) {
-        boolean result = proxyTenantRelService.unbindTenantFromProxy(tenantId);
-        return result ? AjaxResult.success("解绑成功") : AjaxResult.error("解绑失败");
-    }
-
-    /**
-     * 检查租户是否已绑定代理
-     */
-    @PreAuthorize("@ss.hasPermi('proxy:tenantRel:query')")
-    @GetMapping("/checkBind/{tenantId}")
-    public AjaxResult checkTenantBind(@PathVariable Long tenantId) {
-        boolean isBound = proxyTenantRelService.isTenantBound(tenantId);
-        return AjaxResult.success(isBound);
-    }
-}

+ 1 - 12
fs-agent/pom.xml

@@ -72,20 +72,9 @@
             <artifactId>mysql-connector-java</artifactId>
         </dependency>
 
-        <!-- 核心模块-->
         <dependency>
             <groupId>com.fs</groupId>
-            <artifactId>fs-framework</artifactId>
-            <exclusions>
-                <exclusion>
-                    <groupId>javax.annotation</groupId>
-                    <artifactId>annotations-api</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>org.apache.tomcat</groupId>
-                    <artifactId>annotations-api</artifactId>
-                </exclusion>
-            </exclusions>
+            <artifactId>fs-service</artifactId>
         </dependency>
 
 

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

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

+ 79 - 0
fs-agent/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java

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

+ 319 - 0
fs-agent/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -0,0 +1,319 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.bean.BeanUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.hisStore.domain.SysOperLogScrm;
+import com.fs.system.domain.SysOperLog;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.*;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * 操作日志记录处理
+ *
+
+ */
+@Aspect
+@Component
+public class LogAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.Log)")
+    public void logPointCut()
+    {
+    }
+
+    /**
+     * 处理完请求后执行
+     *
+     * @param joinPoint 切点
+     */
+    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
+    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult)
+    {
+        handleLog(joinPoint, null, jsonResult);
+    }
+
+    /**
+     * 拦截异常操作
+     *
+     * @param joinPoint 切点
+     * @param e 异常
+     */
+    @AfterThrowing(value = "logPointCut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e)
+    {
+        handleLog(joinPoint, e, null);
+    }
+
+    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult)
+    {
+        try
+        {
+            // 获得注解
+            Log controllerLog = getAnnotationLog(joinPoint);
+            if (controllerLog == null)
+            {
+                return;
+            }
+
+            // 获取当前的用户
+            LoginUser loginUser = SecurityUtils.getLoginUser();
+
+            // *========数据库日志=========*//
+            SysOperLog operLog = new SysOperLog();
+            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
+            // 请求的地址
+            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+            operLog.setOperIp(ip);
+            // 返回参数
+            operLog.setJsonResult(JSON.toJSONString(jsonResult));
+
+            operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
+            if (loginUser != null)
+            {
+                operLog.setOperName(loginUser.getUsername());
+            }
+
+            if (e != null)
+            {
+                operLog.setStatus(BusinessStatus.FAIL.ordinal());
+                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
+            }
+            // 设置方法名称
+            String className = joinPoint.getTarget().getClass().getName();
+            String methodName = joinPoint.getSignature().getName();
+            operLog.setMethod(className + "." + methodName + "()");
+            // 设置请求方式
+            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
+            // 处理设置注解上的参数
+            getControllerMethodDescription(joinPoint, controllerLog, operLog);
+            // 保存数据库
+            Long tenantId = loginUser != null ? loginUser.getTenantId() : null;
+            AsyncManager.me().execute(AsyncFactory.recordOper(tenantId, operLog));
+            if(controllerLog.isStoreLog() ){
+                SysOperLogScrm operLogScrm = new SysOperLogScrm();
+                //插入operId
+                String operId = MDC.get("operIds");
+                BeanUtils.copyProperties(operLog, operLogScrm);
+                Long[] operIds = new Long[operId.split(",").length];
+                String[] operIdStrs = operId.split(",");
+                for (int i = 0; i < operIdStrs.length; i++) {
+                    operIds[i] = Long.parseLong(operIdStrs[i]);
+                }
+                operLogScrm.setOperIds(operIds);
+                resolveParam((ProceedingJoinPoint)joinPoint,operLogScrm);
+                AsyncManager.me().execute(AsyncFactory.recordOperScrm(operLogScrm));
+            }
+        }
+        catch (Exception exp)
+        {
+            // 记录本地异常日志
+            log.error("==前置通知异常==");
+            log.error("异常信息:{}", exp.getMessage());
+            exp.printStackTrace();
+        } catch (Throwable ex) {
+            throw new RuntimeException(ex);
+        }
+    }
+
+    /**
+     * 获取注解中对方法的描述信息 用于Controller层注解
+     *
+     * @param log 日志
+     * @param operLog 操作日志
+     * @throws Exception
+     */
+    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog) throws Exception
+    {
+        // 设置action动作
+        operLog.setBusinessType(log.businessType().ordinal());
+        // 设置标题
+        operLog.setTitle(log.title());
+        // 设置操作人类别
+        operLog.setOperatorType(log.operatorType().ordinal());
+        // 是否需要保存request,参数和值
+        if (log.isSaveRequestData())
+        {
+            // 获取参数的信息,传入到数据库中。
+            setRequestValue(joinPoint, operLog);
+        }
+    }
+
+    /**
+     * 获取请求的参数,放到log中
+     *
+     * @param operLog 操作日志
+     * @throws Exception 异常
+     */
+    private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception
+    {
+        String requestMethod = operLog.getRequestMethod();
+        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
+        {
+            String params = argsArrayToString(joinPoint.getArgs());
+            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
+        }
+        else
+        {
+            Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
+            operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private Log getAnnotationLog(JoinPoint joinPoint) throws Exception
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(Log.class);
+        }
+        return null;
+    }
+
+    /**
+     * 参数拼装
+     */
+    private String argsArrayToString(Object[] paramsArray)
+    {
+        String params = "";
+        if (paramsArray != null && paramsArray.length > 0)
+        {
+            for (int i = 0; i < paramsArray.length; i++)
+            {
+                if (StringUtils.isNotNull(paramsArray[i]) && !isFilterObject(paramsArray[i]))
+                {
+                    Object jsonObj = JSON.toJSON(paramsArray[i]);
+                    params += jsonObj.toString() + " ";
+                }
+            }
+        }
+        return params.trim();
+    }
+
+    /**
+     * 判断是否需要过滤的对象。
+     *
+     * @param o 对象信息。
+     * @return 如果是需要过滤的对象,则返回true;否则返回false。
+     */
+    @SuppressWarnings("rawtypes")
+    public boolean isFilterObject(final Object o)
+    {
+        Class<?> clazz = o.getClass();
+        if (clazz.isArray())
+        {
+            return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
+        }
+        else if (Collection.class.isAssignableFrom(clazz))
+        {
+            Collection collection = (Collection) o;
+            for (Iterator iter = collection.iterator(); iter.hasNext();)
+            {
+                return iter.next() instanceof MultipartFile;
+            }
+        }
+        else if (Map.class.isAssignableFrom(clazz))
+        {
+            Map map = (Map) o;
+            for (Iterator iter = map.entrySet().iterator(); iter.hasNext();)
+            {
+                Map.Entry entry = (Map.Entry) iter.next();
+                return entry.getValue() instanceof MultipartFile;
+            }
+        }
+        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
+                || o instanceof BindingResult;
+    }
+
+    private final ExpressionParser parser = new SpelExpressionParser();
+
+    public void resolveParam(ProceedingJoinPoint joinPoint, SysOperLogScrm operLog) {
+        // 1. 获取注解实例
+        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
+        Log annotation = method.getAnnotation(Log.class);
+
+        // 2. 准备简单上下文
+        StandardEvaluationContext context = new StandardEvaluationContext();
+        Object[] args = joinPoint.getArgs();
+        String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
+
+        // 设置参数名称和值到上下文
+        for (int i = 0; i < args.length; i++) {
+            context.setVariable("p" + i, args[i]); // 支持 #p0,#p1...
+            if (paramNames != null && i < paramNames.length) {
+                context.setVariable(paramNames[i], args[i]); // 支持参数名引用
+            }
+        }
+
+        // 3. 处理动态 businessType
+        BusinessType businessType = annotation.businessType();
+        if (!annotation.businessTypeExpression().isEmpty()) {
+            try {
+                businessType = parser.parseExpression(annotation.businessTypeExpression())
+                        .getValue(context, BusinessType.class);
+            } catch (Exception e) {
+                log.warn("解析 businessType 表达式失败,使用默认值: {}", e.getMessage());
+            }
+        }
+        // 4. 处理动态 logParam
+        String[] logParam = annotation.logParam();
+        if (!annotation.logParamExpression().isEmpty()) {
+            try {
+                logParam = parser.parseExpression(annotation.logParamExpression())
+                        .getValue(context, String[].class);
+            }catch (Exception e){
+                log.warn("解析 logParam 表达式失败,使用默认值: {}", e.getMessage());
+            }
+        }
+        operLog.setBusinessType(businessType.ordinal());
+        operLog.setMainType(logParam[0]);
+        operLog.setDes(logParam[1]);
+    }
+
+
+    @After("logPointCut()")
+    public void after(JoinPoint joinPoint) throws Throwable {
+        // 移除 MDC 中的 logId
+        MDC.remove("operIds");
+    }
+
+}

+ 241 - 0
fs-agent/src/main/java/com/fs/framework/aspectj/ProxyLogAspect.java

@@ -0,0 +1,241 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.ProxyLog;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.proxy.domain.ProxyOperLog;
+import com.fs.proxy.service.IProxyOperLogService;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.TimerTask;
+
+/**
+ * 代理操作日志记录处理
+ *
+ * @author fs
+ */
+@Aspect
+@Component
+public class ProxyLogAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(ProxyLogAspect.class);
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.ProxyLog)")
+    public void proxyLogPointCut()
+    {
+    }
+
+    /**
+     * 处理完请求后执行
+     *
+     * @param joinPoint 切点
+     * @param jsonResult 返回结果
+     */
+    @AfterReturning(pointcut = "proxyLogPointCut()", returning = "jsonResult")
+    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult)
+    {
+        handleLog(joinPoint, null, jsonResult);
+    }
+
+    /**
+     * 拦截异常操作
+     *
+     * @param joinPoint 切点
+     * @param e 异常
+     */
+    @AfterThrowing(value = "proxyLogPointCut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e)
+    {
+        handleLog(joinPoint, e, null);
+    }
+
+    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult)
+    {
+        try
+        {
+            // 获得注解
+            ProxyLog controllerLog = getAnnotationLog(joinPoint);
+            if (controllerLog == null)
+            {
+                return;
+            }
+
+            // 获取当前的用户
+            LoginUser loginUser = SecurityUtils.getLoginUser();
+
+            // *========数据库日志=========*//
+            ProxyOperLog operLog = new ProxyOperLog();
+            // 设置代理ID
+            if (loginUser != null && loginUser.getProxyId() != null)
+            {
+                operLog.setProxyId(loginUser.getProxyId());
+            }
+            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
+            // 请求的地址
+            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+            operLog.setOperIp(ip);
+            // 返回参数
+            operLog.setJsonResult(JSON.toJSONString(jsonResult));
+
+            operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
+            if (loginUser != null)
+            {
+                operLog.setOperName(loginUser.getUsername());
+            }
+
+            if (e != null)
+            {
+                operLog.setStatus(BusinessStatus.FAIL.ordinal());
+                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
+            }
+            // 设置方法名称
+            String className = joinPoint.getTarget().getClass().getName();
+            String methodName = joinPoint.getSignature().getName();
+            operLog.setMethod(className + "." + methodName + "()");
+            // 设置请求方式
+            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
+            // 设置操作时间
+            operLog.setOperTime(new java.util.Date());
+            // 处理设置注解上的参数
+            getControllerMethodDescription(joinPoint, controllerLog, operLog);
+            // 异步保存数据库
+            AsyncManager.me().execute(new TimerTask()
+            {
+                @Override
+                public void run()
+                {
+                    try
+                    {
+                        com.fs.common.utils.spring.SpringUtils.getBean(IProxyOperLogService.class).insertProxyOperLog(operLog);
+                    }
+                    catch (Exception exp)
+                    {
+                        log.error("==代理操作日志异步写入异常==");
+                        log.error("异常信息:{}", exp.getMessage());
+                    }
+                }
+            });
+        }
+        catch (Exception exp)
+        {
+            // 记录本地异常日志
+            log.error("==代理操作日志前置通知异常==");
+            log.error("异常信息:{}", exp.getMessage());
+            exp.printStackTrace();
+        }
+    }
+
+    /**
+     * 获取注解中对方法的描述信息 用于Controller层注解
+     *
+     * @param proxyLog 日志注解
+     * @param operLog 操作日志
+     * @throws Exception
+     */
+    public void getControllerMethodDescription(JoinPoint joinPoint, ProxyLog proxyLog, ProxyOperLog operLog) throws Exception
+    {
+        // 设置action动作
+        operLog.setBusinessType(proxyLog.businessType().ordinal());
+        // 设置标题
+        operLog.setTitle(proxyLog.title());
+        // 设置操作人类别
+        operLog.setOperatorType(proxyLog.operatorType().ordinal());
+        // 是否需要保存request,参数和值
+        if (proxyLog.isSaveRequestData())
+        {
+            // 获取参数的信息,传入到数据库中。
+            setRequestValue(joinPoint, operLog);
+        }
+    }
+
+    /**
+     * 获取请求的参数,放到log中
+     *
+     * @param operLog 操作日志
+     * @throws Exception 异常
+     */
+    private void setRequestValue(JoinPoint joinPoint, ProxyOperLog operLog) throws Exception
+    {
+        String requestMethod = operLog.getRequestMethod();
+        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
+        {
+            String params = argsArrayToString(joinPoint.getArgs());
+            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
+        }
+        else
+        {
+            Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
+            operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private ProxyLog getAnnotationLog(JoinPoint joinPoint) throws Exception
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(ProxyLog.class);
+        }
+        return null;
+    }
+
+    /**
+     * 参数拼装
+     */
+    private String argsArrayToString(Object[] paramsArray)
+    {
+        String params = "";
+        if (paramsArray != null && paramsArray.length > 0)
+        {
+            for (int i = 0; i < paramsArray.length; i++)
+            {
+                if (!isFilterObject(paramsArray[i]))
+                {
+                    Object jsonObj = JSON.toJSON(paramsArray[i]);
+                    params += jsonObj.toString() + " ";
+                }
+            }
+        }
+        return params.trim();
+    }
+
+    /**
+     * 判断是否需要过滤的对象。
+     *
+     * @param o 对象信息。
+     * @return 如果是需要过滤的对象,则返回true;否则返回false。
+     */
+    public boolean isFilterObject(final Object o)
+    {
+        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse;
+    }
+}

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

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

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

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

+ 58 - 0
fs-agent/src/main/java/com/fs/framework/config/ArrayStringTypeHandler.java

@@ -0,0 +1,58 @@
+package com.fs.framework.config;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.springframework.context.annotation.Configuration;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+
+@Configuration
+public class ArrayStringTypeHandler extends BaseTypeHandler<List<String>> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
+        // 将 List<String> 转换为字符串,ClickHouse 支持的格式为 "['item1', 'item2']"
+        StringBuilder sb = new StringBuilder();
+        sb.append("[");
+        for (int j = 0; j < parameter.size(); j++) {
+            sb.append("'").append(parameter.get(j)).append("'");
+            if (j < parameter.size() - 1) {
+                sb.append(",");
+            }
+        }
+        sb.append("]");
+        ps.setString(i, sb.toString());
+    }
+
+    @Override
+    public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        // 处理查询结果,将其转换为 List<String>
+        String result = rs.getString(columnName);
+        return parseArray(result);
+    }
+
+    @Override
+    public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String result = rs.getString(columnIndex);
+        return parseArray(result);
+    }
+
+    @Override
+    public List<String> getNullableResult(java.sql.CallableStatement cs, int columnIndex) throws SQLException {
+        String result = cs.getString(columnIndex);
+        return parseArray(result);
+    }
+
+    private List<String> parseArray(String arrayStr) {
+        // 将 ClickHouse 的 Array 字符串转换为 List<String>
+        if (arrayStr == null || arrayStr.isEmpty()) {
+            return null;
+        }
+        arrayStr = arrayStr.substring(1, arrayStr.length() - 1);  // 去掉 "[" 和 "]"
+        String[] elements = arrayStr.split(",");
+        return java.util.Arrays.asList(elements);
+    }
+}

+ 85 - 0
fs-agent/src/main/java/com/fs/framework/config/CaptchaConfig.java

@@ -0,0 +1,85 @@
+package com.fs.framework.config;
+
+import com.google.code.kaptcha.impl.DefaultKaptcha;
+import com.google.code.kaptcha.util.Config;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Properties;
+
+import static com.google.code.kaptcha.Constants.*;
+
+/**
+ * 验证码配置
+ *
+
+ */
+@Configuration
+public class CaptchaConfig
+{
+    @Bean(name = "captchaProducer")
+    public DefaultKaptcha getKaptchaBean()
+    {
+        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
+        Properties properties = new Properties();
+        // 是否有边框 默认为true 我们可以自己设置yes,no
+        properties.setProperty(KAPTCHA_BORDER, "yes");
+        // 验证码文本字符颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, "black");
+        // 验证码图片宽度 默认为200
+        properties.setProperty(KAPTCHA_IMAGE_WIDTH, "160");
+        // 验证码图片高度 默认为50
+        properties.setProperty(KAPTCHA_IMAGE_HEIGHT, "60");
+        // 验证码文本字符大小 默认为40
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, "38");
+        // KAPTCHA_SESSION_KEY
+        properties.setProperty(KAPTCHA_SESSION_CONFIG_KEY, "kaptchaCode");
+        // 验证码文本字符长度 默认为5
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
+        // 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Arial,Courier");
+        // 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy 阴影com.google.code.kaptcha.impl.ShadowGimpy
+        properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.ShadowGimpy");
+        Config config = new Config(properties);
+        defaultKaptcha.setConfig(config);
+        return defaultKaptcha;
+    }
+
+    @Bean(name = "captchaProducerMath")
+    public DefaultKaptcha getKaptchaBeanMath()
+    {
+        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
+        Properties properties = new Properties();
+        // 是否有边框 默认为true 我们可以自己设置yes,no
+        properties.setProperty(KAPTCHA_BORDER, "yes");
+        // 边框颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_BORDER_COLOR, "105,179,90");
+        // 验证码文本字符颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, "blue");
+        // 验证码图片宽度 默认为200
+        properties.setProperty(KAPTCHA_IMAGE_WIDTH, "160");
+        // 验证码图片高度 默认为50
+        properties.setProperty(KAPTCHA_IMAGE_HEIGHT, "60");
+        // 验证码文本字符大小 默认为40
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, "35");
+        // KAPTCHA_SESSION_KEY
+        properties.setProperty(KAPTCHA_SESSION_CONFIG_KEY, "kaptchaCodeMath");
+        // 验证码文本生成器
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_IMPL, "com.fs.framework.config.KaptchaTextCreator");
+        // 验证码文本字符间距 默认为2
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_SPACE, "3");
+        // 验证码文本字符长度 默认为5
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "6");
+        // 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Arial,Courier");
+        // 验证码噪点颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_NOISE_COLOR, "white");
+        // 干扰实现类
+        properties.setProperty(KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
+        // 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy 阴影com.google.code.kaptcha.impl.ShadowGimpy
+        properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.ShadowGimpy");
+        Config config = new Config(properties);
+        defaultKaptcha.setConfig(config);
+        return defaultKaptcha;
+    }
+}

+ 109 - 0
fs-agent/src/main/java/com/fs/framework/config/DataSourceConfig.java

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

+ 126 - 0
fs-agent/src/main/java/com/fs/framework/config/DruidConfig.java

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

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

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

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

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

+ 27 - 0
fs-agent/src/main/java/com/fs/framework/config/HybridBeanNameGenerator.java

@@ -0,0 +1,27 @@
+package com.fs.framework.config;
+
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.context.annotation.AnnotationBeanNameGenerator;
+
+/**
+ * Controller ʹ��ȫ���������� fs-saasadmin ͬ����ͻ��Service ������Ĭ�϶�����
+ */
+public class HybridBeanNameGenerator extends AnnotationBeanNameGenerator {
+
+    @Override
+    public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) {
+        String className = definition.getBeanClassName();
+        if (className != null && className.contains(".controller.")) {
+            return className;
+        }
+        String beanName = super.generateBeanName(definition, registry);
+        if (className != null && registry.containsBeanDefinition(beanName)) {
+            String existing = registry.getBeanDefinition(beanName).getBeanClassName();
+            if (existing != null && !existing.equals(className)) {
+                return className;
+            }
+        }
+        return beanName;
+    }
+}

+ 76 - 0
fs-agent/src/main/java/com/fs/framework/config/KaptchaTextCreator.java

@@ -0,0 +1,76 @@
+package com.fs.framework.config;
+
+import com.google.code.kaptcha.text.impl.DefaultTextCreator;
+
+import java.util.Random;
+
+/**
+ * 验证码文本生成器
+ *
+
+ */
+public class KaptchaTextCreator extends DefaultTextCreator
+{
+    private static final String[] CNUMBERS = "0,1,2,3,4,5,6,7,8,9,10".split(",");
+
+    @Override
+    public String getText()
+    {
+        Integer result = 0;
+        Random random = new Random();
+        int x = random.nextInt(10);
+        int y = random.nextInt(10);
+        StringBuilder suChinese = new StringBuilder();
+        int randomoperands = (int) Math.round(Math.random() * 2);
+        if (randomoperands == 0)
+        {
+            result = x * y;
+            suChinese.append(CNUMBERS[x]);
+            suChinese.append("*");
+            suChinese.append(CNUMBERS[y]);
+        }
+        else if (randomoperands == 1)
+        {
+            if (!(x == 0) && y % x == 0)
+            {
+                result = y / x;
+                suChinese.append(CNUMBERS[y]);
+                suChinese.append("/");
+                suChinese.append(CNUMBERS[x]);
+            }
+            else
+            {
+                result = x + y;
+                suChinese.append(CNUMBERS[x]);
+                suChinese.append("+");
+                suChinese.append(CNUMBERS[y]);
+            }
+        }
+        else if (randomoperands == 2)
+        {
+            if (x >= y)
+            {
+                result = x - y;
+                suChinese.append(CNUMBERS[x]);
+                suChinese.append("-");
+                suChinese.append(CNUMBERS[y]);
+            }
+            else
+            {
+                result = y - x;
+                suChinese.append(CNUMBERS[y]);
+                suChinese.append("-");
+                suChinese.append(CNUMBERS[x]);
+            }
+        }
+        else
+        {
+            result = x + y;
+            suChinese.append(CNUMBERS[x]);
+            suChinese.append("+");
+            suChinese.append(CNUMBERS[y]);
+        }
+        suChinese.append("=?@" + result);
+        return suChinese.toString();
+    }
+}

+ 58 - 0
fs-agent/src/main/java/com/fs/framework/config/LogInterceptor.java

@@ -0,0 +1,58 @@
+package com.fs.framework.config;
+
+
+
+import com.fs.common.utils.SecurityUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.UUID;
+
+
+/**
+ * @description: 日志拦截器
+ * @author: xdd
+ * @date: 2025/3/13
+ */
+@Component
+public class LogInterceptor implements HandlerInterceptor {
+
+    private static final String traceId = "traceId";
+    private static final String tenantId = "tenantId";
+    private static final String dataSource = "dataSource";
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        String tid = UUID.randomUUID().toString().replace("-", "");
+        if (!StringUtils.isEmpty(request.getHeader("traceId"))) {
+            tid = request.getHeader("traceId");
+        }
+        MDC.put(traceId, tid);
+        Long currentTenantId = SecurityUtils.getTenantId();
+        if (currentTenantId != null) {
+            MDC.put(tenantId, String.valueOf(currentTenantId));
+        } else {
+            MDC.remove(tenantId);
+        }
+        String currentDataSource = DynamicDataSourceContextHolder.getDataSourceType();
+        if (!StringUtils.isEmpty(currentDataSource)) {
+            MDC.put(dataSource, currentDataSource);
+        } else {
+            MDC.remove(dataSource);
+        }
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
+                                Exception ex) {
+        MDC.remove(traceId);
+        MDC.remove(tenantId);
+        MDC.remove(dataSource);
+    }
+}

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

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

+ 29 - 0
fs-agent/src/main/java/com/fs/framework/config/OverridingBeanNameGenerator.java

@@ -0,0 +1,29 @@
+package com.fs.framework.config;
+
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.context.annotation.AnnotationBeanNameGenerator;
+
+/**
+ * 同名 Bean 来自不同实现类时,使用全类名作为 Bean 名称,避免启动期冲突。
+ */
+public class OverridingBeanNameGenerator extends AnnotationBeanNameGenerator {
+
+    @Override
+    public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) {
+        String beanName = super.generateBeanName(definition, registry);
+        if (!registry.containsBeanDefinition(beanName)) {
+            return beanName;
+        }
+        String newClass = definition.getBeanClassName();
+        if (newClass == null) {
+            return beanName;
+        }
+        BeanDefinition existingDef = registry.getBeanDefinition(beanName);
+        String existingClass = existingDef.getBeanClassName();
+        if (existingClass != null && !existingClass.equals(newClass)) {
+            return newClass;
+        }
+        return beanName;
+    }
+}

+ 81 - 0
fs-agent/src/main/java/com/fs/framework/config/ResourcesConfig.java

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

+ 181 - 0
fs-agent/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,181 @@
+package com.fs.framework.config;
+
+import com.fs.framework.security.filter.JwtAuthenticationTokenFilter;
+import com.fs.framework.security.handle.AuthenticationEntryPointImpl;
+import com.fs.framework.security.handle.LogoutSuccessHandlerImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.authentication.logout.LogoutFilter;
+import org.springframework.web.filter.CorsFilter;
+
+/**
+ * spring security配置
+ *
+
+ */
+@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
+public class SecurityConfig extends WebSecurityConfigurerAdapter
+{
+    /**
+     * 自定义用户认证逻辑
+     */
+    @Autowired
+    private UserDetailsService userDetailsService;
+
+    /**
+     * 认证失败处理类
+     */
+    @Autowired
+    private AuthenticationEntryPointImpl unauthorizedHandler;
+
+    /**
+     * 退出处理类
+     */
+    @Autowired
+    private LogoutSuccessHandlerImpl logoutSuccessHandler;
+
+    /**
+     * token认证过滤器
+     */
+    @Autowired
+    private JwtAuthenticationTokenFilter authenticationTokenFilter;
+
+    /**
+     * 跨域过滤器
+     */
+    @Autowired
+    private CorsFilter corsFilter;
+
+    /**
+     * 解决 无法直接注入 AuthenticationManager
+     *
+     * @return
+     * @throws Exception
+     */
+    @Bean
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception
+    {
+        return super.authenticationManagerBean();
+    }
+
+    /**
+     * anyRequest          |   匹配所有请求路径
+     * access              |   SpringEl表达式结果为true时可以访问
+     * anonymous           |   匿名可以访问
+     * denyAll             |   用户不能访问
+     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
+     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
+     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
+     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
+     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
+     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
+     * permitAll           |   用户可以任意访问
+     * rememberMe          |   允许通过remember-me登录的用户访问
+     * authenticated       |   用户登录后可访问
+     */
+    @Override
+    protected void configure(HttpSecurity httpSecurity) throws Exception
+    {
+        httpSecurity
+                // CSRF禁用,因为不使用session
+                .csrf().disable()
+                // 认证失败处理类
+                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
+                // 基于token,所以不需要session
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
+                // 过滤请求
+                .authorizeRequests()
+                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
+                .antMatchers("/login", "/register", "/captchaImage","/getWechatQrCode","/checkWechatScan","/callback","/checkIsNeedCheck").anonymous()
+                                .antMatchers("/company/login", "/company/register", "/company/captchaImage").anonymous()
+                .antMatchers("/proxy/login").anonymous()
+                .antMatchers("/app/common/test").anonymous()
+                .antMatchers("/ad/adDyApi/authorized").anonymous()
+                .antMatchers(
+                        HttpMethod.GET,
+                        "/",
+                        "/*.html",
+                        "/**/*.html",
+                        "/**/*.css",
+                        "/**/*.js",
+                        "/profile/**"
+                ).permitAll()
+                .antMatchers("/baidu/**").anonymous()
+                .antMatchers("/baiduBack/**").anonymous()
+                .antMatchers("/test/gtp/*").anonymous()
+                .antMatchers("common/getTask/*").anonymous()
+                .antMatchers("/his/data/endFollow/*").anonymous()
+                .antMatchers("/his/data/end/*").anonymous()
+                .antMatchers("/his/data/addCF/*").anonymous()
+                .antMatchers("/his/data/addCom/*").anonymous()
+                .antMatchers("/his/data/testSendSub/*").anonymous()
+                .antMatchers("/his/data/test/*").anonymous()
+                .antMatchers("/his/data/Follow/*").anonymous()
+                .antMatchers("/company/companyVoiceRobotic/callerResult").anonymous()
+                .antMatchers("/qw/data/*").anonymous()
+                .antMatchers("/app/common/expressNotify").anonymous()
+                .antMatchers("/app/common/integral").anonymous()
+                .antMatchers("/his/sms/deliver").anonymous()
+                .antMatchers("/his/sms/templateState").anonymous()
+                .antMatchers("/his/sms/report").anonymous()
+                .antMatchers("/his/sms/notify").anonymous()
+                .antMatchers("/huFu/*").anonymous()
+                .antMatchers("/tzPay/*").anonymous()
+                .antMatchers("/his/pay/*").anonymous()
+                .antMatchers("/common/getId**").anonymous()
+                .antMatchers("/common/uploadOSS**").anonymous()
+                // presignedUploadUrl 需要登录认证,不在anonymous列表中
+                .antMatchers("/chat/upload/uploadFile**").anonymous()
+                .antMatchers("/common/uploadWang**").anonymous()
+                .antMatchers("/common/download**").anonymous()
+                .antMatchers("/common/download/resource**").anonymous()
+                .antMatchers("/common/unbindQwUserByServerIds").anonymous()
+                .antMatchers("/swagger-ui.html").anonymous()
+                .antMatchers("/swagger-resources/**").anonymous()
+                .antMatchers("/webjars/**").anonymous()
+                .antMatchers("/*/api-docs").anonymous()
+                .antMatchers("/druid/**").anonymous()
+                .antMatchers("/course/userVideo/videoTranscode").anonymous()
+                .antMatchers("/system/config/getConfigByKey/his.adminUi.config").permitAll()
+                .antMatchers("/erp/call/**").anonymous()
+                // 除上面外的所有请求全部需要鉴权认证
+                .anyRequest().authenticated()
+                .and()
+                .headers().frameOptions().disable();
+        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
+        // 添加JWT filter
+        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
+        // 添加CORS filter
+        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
+        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
+    }
+
+    /**
+     * 强散列哈希加密实现
+     */
+    @Bean
+    public BCryptPasswordEncoder bCryptPasswordEncoder()
+    {
+        return new BCryptPasswordEncoder();
+    }
+
+    /**
+     * 身份认证接口
+     */
+    @Override
+    protected void configure(AuthenticationManagerBuilder auth) throws Exception
+    {
+        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
+    }
+}

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

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

+ 10 - 0
fs-agent/src/main/java/com/fs/framework/config/TenantPrincipal.java

@@ -0,0 +1,10 @@
+package com.fs.framework.config;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class TenantPrincipal {
+    private final Long tenantId;
+}

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

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

+ 35 - 0
fs-agent/src/main/java/com/fs/framework/config/ThreadPoolTaskWrapExecutor.java

@@ -0,0 +1,35 @@
+package com.fs.framework.config;
+
+import com.fs.framework.util.ThreadMdcUtil;
+import org.slf4j.MDC;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+/**
+ * @description:
+ * @author: xdd
+ * @date: 2025/3/13
+ */
+public final class ThreadPoolTaskWrapExecutor extends ThreadPoolTaskExecutor {
+    public ThreadPoolTaskWrapExecutor() {
+        super();
+    }
+
+    @Override
+    public void execute(Runnable task) {
+        super.execute(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
+    }
+
+
+    @Override
+    public <T> Future<T> submit(Callable<T> task) {
+        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
+    }
+
+    @Override
+    public Future<?> submit(Runnable task) {
+        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
+    }
+}

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

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

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

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

+ 45 - 0
fs-agent/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java

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

+ 102 - 0
fs-agent/src/main/java/com/fs/framework/datasource/TenantDataSourceContextHelper.java

@@ -0,0 +1,102 @@
+package com.fs.framework.datasource;
+
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.exception.CustomException;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.function.Supplier;
+
+/**
+ * 多租户数据源上下文工具:统一切库、Redis 租户前缀、资源释放。
+ * <p>
+ * 总后台代管租户数据时,在 Controller / 任务编排层调用本类,Service 仅处理当前数据源下的业务。
+ */
+@Component
+public class TenantDataSourceContextHelper {
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    /**
+     * 在租户库上下文中执行(无返回值)
+     */
+    public void runInTenant(Long tenantId, Runnable action) {
+        executeInTenant(tenantId, () -> {
+            action.run();
+            return null;
+        });
+    }
+
+    /**
+     * 在租户库上下文中执行(有返回值)
+     */
+    public <T> T executeInTenant(Long tenantId, Supplier<T> action) {
+        return executeInTenant(loadActiveTenant(tenantId), action);
+    }
+
+    /**
+     * 在租户库上下文中执行(已加载租户信息)
+     */
+    public <T> T executeInTenant(TenantInfo tenant, Supplier<T> action) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            tenantDataSourceManager.switchTenant(tenant);
+            RedisTenantContext.setTenantId(tenant.getId());
+            return action.get();
+        } finally {
+            RedisTenantContext.clear();
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    /**
+     * 在主库上下文中执行(无返回值)
+     */
+    public void runInMaster(Runnable action) {
+        executeInMaster(() -> {
+            action.run();
+            return null;
+        });
+    }
+
+    /**
+     * 在主库上下文中执行(有返回值)
+     */
+    public <T> T executeInMaster(Supplier<T> action) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            return action.get();
+        } finally {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    /**
+     * 校验并加载启用中的租户(查询走主库)
+     */
+    public TenantInfo loadActiveTenant(Long tenantId) {
+        if (tenantId == null) {
+            throw new CustomException("租户ID不能为空");
+        }
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        TenantInfo tenant = tenantInfoService.getById(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;
+    }
+}

+ 147 - 0
fs-agent/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java

@@ -0,0 +1,147 @@
+package com.fs.framework.datasource;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class TenantDataSourceManager {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantDataSourceManager.class);
+
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+
+    @Resource
+    private TenantInfoService tenantInfoService;
+
+    /**
+     * 租户数据源缓存
+     */
+    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
+
+    /**
+     * 切换到租户数据源(不存在则创建)
+     */
+    public void switchTenant(TenantInfo tenantInfo) {
+
+        // 用租户主键作为唯一标识
+        String tenantKey = buildTenantKey(tenantInfo.getId());
+
+        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+            synchronized (this) {
+                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+
+                    DataSource tenantDs = createTenantDataSource(tenantInfo);
+                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
+
+                    // 动态追加到已解析的数据源
+                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
+                    resolvedMap.put(tenantKey, tenantDs);
+                }
+            }
+        }
+
+        // ThreadLocal 切库
+        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+    }
+
+    private String buildTenantKey(Long tenantId) {
+        return "tenant:" + tenantId;
+    }
+
+
+
+    /**
+     * 清理 ThreadLocal
+     */
+    public void clear() {
+        DynamicDataSourceContextHolder.clearDataSourceType();
+    }
+
+    /**
+     * 根据租户ID确保数据源已注册并切换(用于 Filter/拦截器等非登录场景)
+     * 解决 JVM 重启后 TENANT_DS_CACHE 被清空,导致 resolvedDataSources 中找不到租户数据源的问题
+     *
+     * @param tenantId 租户ID
+     */
+    public void ensureSwitchByTenantId(Long tenantId) {
+        String tenantKey = buildTenantKey(tenantId);
+
+        // 如果缓存中已有,直接切库
+        if (TENANT_DS_CACHE.containsKey(tenantKey)) {
+            DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+            log.debug("[TenantDS] 数据源已缓存,直接切换: {}", tenantKey);
+            return;
+        }
+
+        // 缓存中没有,需要从主库查租户信息并注册数据源
+        // 先临时切到主库
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            TenantInfo tenantInfo = tenantInfoService.getById(tenantId);
+            if (tenantInfo == null) {
+                log.warn("[TenantDS] 租户ID={} 在主库中不存在,回退到主库", tenantId);
+                DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                return;
+            }
+            if (!tenantInfo.getStatus().equals(1)) {
+                log.warn("[TenantDS] 租户ID={} 已禁用,回退到主库", tenantId);
+                DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                return;
+            }
+            // 注册并切换
+            switchTenant(tenantInfo);
+            log.info("[TenantDS] 动态注册并切换数据源: key={}, url={}", tenantKey, tenantInfo.getDbUrl());
+        } catch (Exception e) {
+            log.error("[TenantDS] 动态注册租户数据源失败, tenantId={}, 回退到主库", tenantId, e);
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    /**
+     * 创建租户数据源(MySQL + Druid)
+     */
+    private DataSource createTenantDataSource(TenantInfo tenant) {
+
+        DruidDataSource ds = new DruidDataSource();
+        ds.setUrl(tenant.getDbUrl());
+        ds.setUsername(tenant.getDbAccount());
+        ds.setPassword(tenant.getDbPwd());
+
+        // 统一 MySQL
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+
+        ds.setInitialSize(5);
+        ds.setMinIdle(10);
+        ds.setMaxActive(20);
+        ds.setMaxWait(60000);
+
+        return ds;
+    }
+
+    /**
+     * 反射获取 AbstractRoutingDataSource.resolvedDataSources
+     */
+    @SuppressWarnings("unchecked")
+    private Map<Object, DataSource> getResolvedDataSources() {
+        try {
+            Field field = org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
+                    .class.getDeclaredField("resolvedDataSources");
+            field.setAccessible(true);
+            return (Map<Object, DataSource>) field.get(dynamicDataSource);
+        } catch (Exception e) {
+            throw new IllegalStateException("获取 resolvedDataSources 失败", e);
+        }
+    }
+}

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

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

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

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

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

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

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

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

+ 145 - 0
fs-agent/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java

@@ -0,0 +1,145 @@
+package com.fs.framework.manager.factory;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.LogUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.AddressUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.hisStore.domain.SysOperLogScrm;
+import com.fs.hisStore.service.ISysOperLogScrmService;
+import com.fs.system.domain.SysLogininfor;
+import com.fs.system.domain.SysOperLog;
+import com.fs.system.service.ISysLogininforService;
+import com.fs.system.service.ISysOperLogService;
+import eu.bitwalker.useragentutils.UserAgent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Date;
+import java.util.TimerTask;
+
+/**
+ * 异步工厂(产生任务用)
+ */
+public class AsyncFactory
+{
+    private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");
+
+    public static TimerTask recordLogininfor(final String username, final String status, final String message,
+            final Object... args)
+    {
+        return recordLogininfor(null, username, status, message, args);
+    }
+
+    /**
+     * 记录登录信息(多租户场景需传 tenantId,异步任务内切租户库写 sys_logininfor)
+     */
+    public static TimerTask recordLogininfor(final Long tenantId, final String username, final String status,
+            final String message, final Object... args)
+    {
+        final UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        final String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                String address = AddressUtils.getRealAddressByIP(ip);
+                StringBuilder s = new StringBuilder();
+                s.append(LogUtils.getBlock(ip));
+                s.append(address);
+                s.append(LogUtils.getBlock(username));
+                s.append(LogUtils.getBlock(status));
+                s.append(LogUtils.getBlock(message));
+                sys_user_logger.info(s.toString(), args);
+
+                executeWithTenantDatasource(tenantId, () -> {
+                    String os = userAgent.getOperatingSystem().getName();
+                    String browser = userAgent.getBrowser().getName();
+                    SysLogininfor logininfor = new SysLogininfor();
+                    logininfor.setUserName(username);
+                    logininfor.setIpaddr(ip);
+                    logininfor.setLoginLocation(address);
+                    logininfor.setBrowser(browser);
+                    logininfor.setOs(os);
+                    logininfor.setMsg(message);
+                    logininfor.setLoginTime(new Date());
+                    if (StringUtils.equalsAny(status, Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER))
+                    {
+                        logininfor.setStatus(Constants.SUCCESS);
+                    }
+                    else if (Constants.LOGIN_FAIL.equals(status))
+                    {
+                        logininfor.setStatus(Constants.FAIL);
+                    }
+                    SpringUtils.getBean(ISysLogininforService.class).insertLogininfor(logininfor);
+                });
+            }
+        };
+    }
+
+    public static TimerTask recordOper(final SysOperLog operLog)
+    {
+        return recordOper(null, operLog);
+    }
+
+    /**
+     * 操作日志记录(多租户场景需传 tenantId)
+     */
+    public static TimerTask recordOper(final Long tenantId, final SysOperLog operLog)
+    {
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                executeWithTenantDatasource(tenantId, () -> {
+                    operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
+                    SpringUtils.getBean(ISysOperLogService.class).insertOperlog(operLog);
+                });
+            }
+        };
+    }
+
+    public static TimerTask recordOperScrm(final SysOperLogScrm operLog)
+    {
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
+                SpringUtils.getBean(ISysOperLogScrmService.class).updateOperLog(operLog);
+            }
+        };
+    }
+
+    private static void executeWithTenantDatasource(Long tenantId, Runnable action)
+    {
+        if (tenantId == null)
+        {
+            action.run();
+            return;
+        }
+        TenantDataSourceManager tenantDataSourceManager = SpringUtils.getBean(TenantDataSourceManager.class);
+        try
+        {
+            tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+            action.run();
+        }
+        catch (Exception ex)
+        {
+            sys_user_logger.warn("租户异步写库失败 tenantId={}: {}", tenantId, ex.getMessage());
+        }
+        finally
+        {
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+}

+ 111 - 0
fs-agent/src/main/java/com/fs/framework/security/filter/JwtAuthenticationTokenFilter.java

@@ -0,0 +1,111 @@
+package com.fs.framework.security.filter;
+
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.config.saas.ProjectConfig;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.framework.datasource.DynamicDataSource;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.web.service.TokenService;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * token过滤器 验证token有效性
+ *
+
+ */
+@Component
+public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
+{
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private DynamicDataSource dynamicDataSource;
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException
+    {
+        try {
+            LoginUser loginUser = tokenService.getLoginUser(request);
+
+            if (StringUtils.isNotNull(loginUser)
+                    && StringUtils.isNull(SecurityUtils.getAuthentication()))
+            {
+                // 根据 tenantId 切换数据源(先确保数据源已注册到 DynamicDataSource)
+                Long tenantId = loginUser.getTenantId();
+
+                // 判断前端类型:admin 端不根据 tenant-code 切库(菜单/角色在主库)
+                String frontendType = request.getHeader("X-Frontend-Type");
+                boolean isAdmin = "admin".equals(frontendType);
+
+                // 如果 token 中没有 tenantId,且不是 admin 端,尝试从请求头 tenant-code 解析
+                if (tenantId == null && !isAdmin) {
+                    String tenantCode = request.getHeader("tenant-code");
+                    if (StringUtils.isNotBlank(tenantCode)) {
+                        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                        TenantInfo tenant = tenantInfoService.selectTenantInfoByCode(tenantCode);
+                        if (tenant != null && tenant.getStatus() != null && tenant.getStatus() == 1) {
+                            tenantId = tenant.getId();
+                        }
+                    }
+                }
+
+                if (tenantId != null) {
+                    tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+                } else {
+                    // 没有租户,回主库
+                    DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                }
+
+                if (tenantId != null) {
+                    SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+                    ProjectConfig.safeLoadTenantConfigFromValue(cfg != null ? cfg.getConfigValue() : null);
+                }
+
+                tokenService.verifyToken(loginUser);
+
+                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
+                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
+            }
+            chain.doFilter(request, response);
+        }
+        finally {
+//            List<WxMpProperties.MpConfig> configs = wxMpProperties.getConfigs();
+//            for (WxMpProperties.MpConfig cfg : configs) {
+//                System.out.println("当前租户公众号 AppId = " + cfg.getAppId());
+//                System.out.println("当前租户公众号 Secret = " + cfg.getSecret());
+//                System.out.println("当前租户公众号 Token = " + cfg.getToken());
+//                System.out.println("当前租户公众号 AesKey = " + cfg.getAesKey());
+//            }
+            ProjectConfig.clearTenantConfigs();
+            TenantConfigContext.clear();
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+}

+ 35 - 0
fs-agent/src/main/java/com/fs/framework/security/handle/AuthenticationEntryPointImpl.java

@@ -0,0 +1,35 @@
+package com.fs.framework.security.handle;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Serializable;
+
+/**
+ * 认证失败处理类 返回未授权
+ *
+
+ */
+@Component
+public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
+{
+    private static final long serialVersionUID = -8970718410437077606L;
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
+            throws IOException
+    {
+        int code = HttpStatus.UNAUTHORIZED;
+        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
+    }
+}

+ 54 - 0
fs-agent/src/main/java/com/fs/framework/security/handle/LogoutSuccessHandlerImpl.java

@@ -0,0 +1,54 @@
+package com.fs.framework.security.handle;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.Constants;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.web.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 自定义退出处理类 返回成功
+ *
+
+ */
+@Configuration
+public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
+{
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 退出处理
+     *
+     * @return
+     */
+    @Override
+    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
+            throws IOException, ServletException
+    {
+        LoginUser loginUser = tokenService.getLoginUser(request);
+        if (StringUtils.isNotNull(loginUser))
+        {
+            String userName = loginUser.getUsername();
+            // 删除用户缓存记录
+            tokenService.delLoginUser(loginUser.getToken());
+            // 记录用户退出日志
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getTenantId(), userName, Constants.LOGOUT, "退出成功"));
+        }
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));
+    }
+}

+ 120 - 0
fs-agent/src/main/java/com/fs/framework/task/TenantTaskRunner.java

@@ -0,0 +1,120 @@
+package com.fs.framework.task;
+
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.enums.DataSourceType;
+import com.fs.config.saas.ProjectConfig;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * SaaS 模式下按租户执行定时任务:从主库查启用租户,逐租户切库并设置租户配置后执行传入的逻辑。
+ * 供 fs-admin 等使用 fs-framework 的模块在 @Scheduled 中按租户执行任务。
+ */
+@Slf4j
+@Component
+public class TenantTaskRunner {
+
+    @Resource
+    private TenantDataSourceManager tenantDataSourceManager;
+    @Resource
+    private TenantInfoService tenantInfoService;
+    @Resource
+    private SysConfigMapper sysConfigMapper;
+
+    /**
+     * 对每个启用且未过期的租户执行一次 action(已切到该租户库并设置 TenantConfigContext)。
+     */
+    public void runForEachTenant(Consumer<TenantInfo> action) {
+        runForEachTenant(null, action);
+    }
+
+    /**
+     * 对每个启用且未过期的租户执行一次 action,日志中输出任务名。
+     * @param taskName 定时任务名称,会输出在「定时任务切换数据源...」后面
+     */
+    public void runForEachTenant(String taskName, Consumer<TenantInfo> action) {
+        List<TenantInfo> validTenants = getValidTenants();
+        if (validTenants == null || validTenants.isEmpty()) {
+            return;
+        }
+        for (TenantInfo tenant : validTenants) {
+            runForOneTenant(tenant, taskName, action);
+        }
+    }
+
+    /**
+     * 对每个租户执行无参逻辑(不需要 TenantInfo 时使用)。
+     */
+    public void runForEachTenant(Runnable action) {
+        runForEachTenant(null, t -> action.run());
+    }
+
+    /**
+     * 对每个租户执行无参逻辑,日志中输出任务名。
+     * @param taskName 定时任务名称,会输出在「定时任务切换数据源...」后面
+     */
+    public void runForEachTenant(String taskName, Runnable action) {
+        runForEachTenant(taskName, t -> action.run());
+    }
+
+    private List<TenantInfo> getValidTenants() {
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            TenantInfo query = new TenantInfo();
+            query.setStatus(1);
+            List<TenantInfo> tenants = tenantInfoService.selectTenantInfoList(query);
+            if (tenants == null || tenants.isEmpty()) {
+                log.debug("[SaaS Task] 无启用租户,跳过");
+                return null;
+            }
+            Date now = new Date();
+            return tenants.stream()
+                    .filter(t -> t.getExpireTime() == null || !t.getExpireTime().before(now))
+                    .collect(Collectors.toList());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    private void runForOneTenant(TenantInfo tenant, String taskName, Consumer<TenantInfo> action) {
+        String dataSourceKey = "tenant:" + tenant.getId();
+        try {
+            // 切换到租户数据源
+            tenantDataSourceManager.switchTenant(tenant);
+            // 切换Redis租户上下文
+            RedisTenantContext.setTenantId(tenant.getId());
+            log.info("[SaaS Task] 定时任务切换数据源和Redis dataSource={}, tenantId={}, tenantCode={}, task={}",
+                    dataSourceKey, tenant.getId(), tenant.getTenantCode(), taskName != null ? taskName : "");
+
+            // 加载租户项目配置(安全解析,防止非法JSON导致级联崩溃)
+            SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+            ProjectConfig.safeLoadTenantConfigFromValue(cfg != null ? cfg.getConfigValue() : null);
+
+            // 执行租户任务
+            action.accept(tenant);
+
+        } catch (Exception e) {
+            log.error("[SaaS Task] 租户 tenantId={}, tenantCode={} 执行异常, task={}",
+                    tenant.getId(), tenant.getTenantCode(), taskName, e);
+        } finally {
+            ProjectConfig.clearTenantConfigs();
+            TenantConfigContext.clear();
+            // 清理Redis租户上下文
+            RedisTenantContext.clear();
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+}

+ 60 - 0
fs-agent/src/main/java/com/fs/framework/util/ThreadMdcUtil.java

@@ -0,0 +1,60 @@
+package com.fs.framework.util;
+
+
+import org.slf4j.MDC;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+/**
+ * @description:
+ * @author: xdd
+ * @date: 2025/3/13
+ * @Description:
+ */
+public final class ThreadMdcUtil {
+    private static final String traceId = "traceId";
+
+    public static String generateTraceId() {
+        return UUID.randomUUID().toString().replace("-", "");
+    }
+
+    public static void setTraceIdIfAbsent() {
+        if (MDC.get(traceId) == null) {
+            MDC.put(traceId, generateTraceId());
+        }
+    }
+
+    public static<T> Callable<T> wrap(final Callable<T> callable, final Map<String, String> context) {
+        return () -> {
+            if (context == null) {
+                MDC.clear();
+            } else {
+                MDC.setContextMap(context);
+            }
+            setTraceIdIfAbsent();
+            try {
+                return callable.call();
+            } finally {
+                MDC.clear();
+            }
+        };
+    }
+
+
+    public static Runnable wrap(final Runnable runnable, final Map<String, String> context) {
+        return () -> {
+            if (context == null) {
+                MDC.clear();
+            } else {
+                MDC.setContextMap(context);
+            }
+            setTraceIdIfAbsent();
+            try {
+                runnable.run();
+            } finally {
+                MDC.clear();
+            }
+        };
+    }
+}

+ 237 - 0
fs-agent/src/main/java/com/fs/framework/web/domain/Server.java

@@ -0,0 +1,237 @@
+package com.fs.framework.web.domain;
+
+import com.fs.common.utils.Arith;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.framework.web.domain.server.*;
+import oshi.SystemInfo;
+import oshi.hardware.CentralProcessor;
+import oshi.hardware.CentralProcessor.TickType;
+import oshi.hardware.GlobalMemory;
+import oshi.hardware.HardwareAbstractionLayer;
+import oshi.software.os.FileSystem;
+import oshi.software.os.OSFileStore;
+import oshi.software.os.OperatingSystem;
+import oshi.util.Util;
+
+import java.net.UnknownHostException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+
+/**
+ * 服务器相关信息
+ *
+
+ */
+public class Server
+{
+    private static final int OSHI_WAIT_SECOND = 1000;
+
+    /**
+     * CPU相关信息
+     */
+    private Cpu cpu = new Cpu();
+
+    /**
+     * 內存相关信息
+     */
+    private Mem mem = new Mem();
+
+    /**
+     * JVM相关信息
+     */
+    private Jvm jvm = new Jvm();
+
+    /**
+     * 服务器相关信息
+     */
+    private Sys sys = new Sys();
+
+    /**
+     * 磁盘相关信息
+     */
+    private List<SysFile> sysFiles = new LinkedList<SysFile>();
+
+    public Cpu getCpu()
+    {
+        return cpu;
+    }
+
+    public void setCpu(Cpu cpu)
+    {
+        this.cpu = cpu;
+    }
+
+    public Mem getMem()
+    {
+        return mem;
+    }
+
+    public void setMem(Mem mem)
+    {
+        this.mem = mem;
+    }
+
+    public Jvm getJvm()
+    {
+        return jvm;
+    }
+
+    public void setJvm(Jvm jvm)
+    {
+        this.jvm = jvm;
+    }
+
+    public Sys getSys()
+    {
+        return sys;
+    }
+
+    public void setSys(Sys sys)
+    {
+        this.sys = sys;
+    }
+
+    public List<SysFile> getSysFiles()
+    {
+        return sysFiles;
+    }
+
+    public void setSysFiles(List<SysFile> sysFiles)
+    {
+        this.sysFiles = sysFiles;
+    }
+
+    public void copyTo() throws Exception
+    {
+        SystemInfo si = new SystemInfo();
+        HardwareAbstractionLayer hal = si.getHardware();
+
+        setCpuInfo(hal.getProcessor());
+
+        setMemInfo(hal.getMemory());
+
+        setSysInfo();
+
+        setJvmInfo();
+
+        setSysFiles(si.getOperatingSystem());
+    }
+
+    /**
+     * 设置CPU信息
+     */
+    private void setCpuInfo(CentralProcessor processor)
+    {
+        // CPU信息
+        long[] prevTicks = processor.getSystemCpuLoadTicks();
+        Util.sleep(OSHI_WAIT_SECOND);
+        long[] ticks = processor.getSystemCpuLoadTicks();
+        long nice = ticks[TickType.NICE.getIndex()] - prevTicks[TickType.NICE.getIndex()];
+        long irq = ticks[TickType.IRQ.getIndex()] - prevTicks[TickType.IRQ.getIndex()];
+        long softirq = ticks[TickType.SOFTIRQ.getIndex()] - prevTicks[TickType.SOFTIRQ.getIndex()];
+        long steal = ticks[TickType.STEAL.getIndex()] - prevTicks[TickType.STEAL.getIndex()];
+        long cSys = ticks[TickType.SYSTEM.getIndex()] - prevTicks[TickType.SYSTEM.getIndex()];
+        long user = ticks[TickType.USER.getIndex()] - prevTicks[TickType.USER.getIndex()];
+        long iowait = ticks[TickType.IOWAIT.getIndex()] - prevTicks[TickType.IOWAIT.getIndex()];
+        long idle = ticks[TickType.IDLE.getIndex()] - prevTicks[TickType.IDLE.getIndex()];
+        long totalCpu = user + nice + cSys + idle + iowait + irq + softirq + steal;
+        cpu.setCpuNum(processor.getLogicalProcessorCount());
+        cpu.setTotal(totalCpu);
+        cpu.setSys(cSys);
+        cpu.setUsed(user);
+        cpu.setWait(iowait);
+        cpu.setFree(idle);
+    }
+
+    /**
+     * 设置内存信息
+     */
+    private void setMemInfo(GlobalMemory memory)
+    {
+        mem.setTotal(memory.getTotal());
+        mem.setUsed(memory.getTotal() - memory.getAvailable());
+        mem.setFree(memory.getAvailable());
+    }
+
+    /**
+     * 设置服务器信息
+     */
+    private void setSysInfo()
+    {
+        Properties props = System.getProperties();
+        sys.setComputerName(IpUtils.getHostName());
+        sys.setComputerIp(IpUtils.getHostIp());
+        sys.setOsName(props.getProperty("os.name"));
+        sys.setOsArch(props.getProperty("os.arch"));
+        sys.setUserDir(props.getProperty("user.dir"));
+    }
+
+    /**
+     * 设置Java虚拟机
+     */
+    private void setJvmInfo() throws UnknownHostException
+    {
+        Properties props = System.getProperties();
+        jvm.setTotal(Runtime.getRuntime().totalMemory());
+        jvm.setMax(Runtime.getRuntime().maxMemory());
+        jvm.setFree(Runtime.getRuntime().freeMemory());
+        jvm.setVersion(props.getProperty("java.version"));
+        jvm.setHome(props.getProperty("java.home"));
+    }
+
+    /**
+     * 设置磁盘信息
+     */
+    private void setSysFiles(OperatingSystem os)
+    {
+        FileSystem fileSystem = os.getFileSystem();
+        List<OSFileStore> fsArray = fileSystem.getFileStores();
+        for (OSFileStore fs : fsArray)
+        {
+            long free = fs.getUsableSpace();
+            long total = fs.getTotalSpace();
+            long used = total - free;
+            SysFile sysFile = new SysFile();
+            sysFile.setDirName(fs.getMount());
+            sysFile.setSysTypeName(fs.getType());
+            sysFile.setTypeName(fs.getName());
+            sysFile.setTotal(convertFileSize(total));
+            sysFile.setFree(convertFileSize(free));
+            sysFile.setUsed(convertFileSize(used));
+            sysFile.setUsage(Arith.mul(Arith.div(used, total, 4), 100));
+            sysFiles.add(sysFile);
+        }
+    }
+
+    /**
+     * 字节转换
+     *
+     * @param size 字节大小
+     * @return 转换后值
+     */
+    public String convertFileSize(long size)
+    {
+        long kb = 1024;
+        long mb = kb * 1024;
+        long gb = mb * 1024;
+        if (size >= gb)
+        {
+            return String.format("%.1f GB", (float) size / gb);
+        }
+        else if (size >= mb)
+        {
+            float f = (float) size / mb;
+            return String.format(f > 100 ? "%.0f MB" : "%.1f MB", f);
+        }
+        else if (size >= kb)
+        {
+            float f = (float) size / kb;
+            return String.format(f > 100 ? "%.0f KB" : "%.1f KB", f);
+        }
+        else
+        {
+            return String.format("%d B", size);
+        }
+    }
+}

+ 101 - 0
fs-agent/src/main/java/com/fs/framework/web/domain/server/Cpu.java

@@ -0,0 +1,101 @@
+package com.fs.framework.web.domain.server;
+
+import com.fs.common.utils.Arith;
+
+/**
+ * CPU相关信息
+ * 
+
+ */
+public class Cpu
+{
+    /**
+     * 核心数
+     */
+    private int cpuNum;
+
+    /**
+     * CPU总的使用率
+     */
+    private double total;
+
+    /**
+     * CPU系统使用率
+     */
+    private double sys;
+
+    /**
+     * CPU用户使用率
+     */
+    private double used;
+
+    /**
+     * CPU当前等待率
+     */
+    private double wait;
+
+    /**
+     * CPU当前空闲率
+     */
+    private double free;
+
+    public int getCpuNum()
+    {
+        return cpuNum;
+    }
+
+    public void setCpuNum(int cpuNum)
+    {
+        this.cpuNum = cpuNum;
+    }
+
+    public double getTotal()
+    {
+        return Arith.round(Arith.mul(total, 100), 2);
+    }
+
+    public void setTotal(double total)
+    {
+        this.total = total;
+    }
+
+    public double getSys()
+    {
+        return Arith.round(Arith.mul(sys / total, 100), 2);
+    }
+
+    public void setSys(double sys)
+    {
+        this.sys = sys;
+    }
+
+    public double getUsed()
+    {
+        return Arith.round(Arith.mul(used / total, 100), 2);
+    }
+
+    public void setUsed(double used)
+    {
+        this.used = used;
+    }
+
+    public double getWait()
+    {
+        return Arith.round(Arith.mul(wait / total, 100), 2);
+    }
+
+    public void setWait(double wait)
+    {
+        this.wait = wait;
+    }
+
+    public double getFree()
+    {
+        return Arith.round(Arith.mul(free / total, 100), 2);
+    }
+
+    public void setFree(double free)
+    {
+        this.free = free;
+    }
+}

+ 123 - 0
fs-agent/src/main/java/com/fs/framework/web/domain/server/Jvm.java

@@ -0,0 +1,123 @@
+package com.fs.framework.web.domain.server;
+
+import com.fs.common.utils.Arith;
+import com.fs.common.utils.DateUtils;
+
+import java.lang.management.ManagementFactory;
+
+/**
+ * JVM相关信息
+ *
+
+ */
+public class Jvm
+{
+    /**
+     * 当前JVM占用的内存总数(M)
+     */
+    private double total;
+
+    /**
+     * JVM最大可用内存总数(M)
+     */
+    private double max;
+
+    /**
+     * JVM空闲内存(M)
+     */
+    private double free;
+
+    /**
+     * JDK版本
+     */
+    private String version;
+
+    /**
+     * JDK路径
+     */
+    private String home;
+
+    public double getTotal()
+    {
+        return Arith.div(total, (1024 * 1024), 2);
+    }
+
+    public void setTotal(double total)
+    {
+        this.total = total;
+    }
+
+    public double getMax()
+    {
+        return Arith.div(max, (1024 * 1024), 2);
+    }
+
+    public void setMax(double max)
+    {
+        this.max = max;
+    }
+
+    public double getFree()
+    {
+        return Arith.div(free, (1024 * 1024), 2);
+    }
+
+    public void setFree(double free)
+    {
+        this.free = free;
+    }
+
+    public double getUsed()
+    {
+        return Arith.div(total - free, (1024 * 1024), 2);
+    }
+
+    public double getUsage()
+    {
+        return Arith.mul(Arith.div(total - free, total, 4), 100);
+    }
+
+    /**
+     * 获取JDK名称
+     */
+    public String getName()
+    {
+        return ManagementFactory.getRuntimeMXBean().getVmName();
+    }
+
+    public String getVersion()
+    {
+        return version;
+    }
+
+    public void setVersion(String version)
+    {
+        this.version = version;
+    }
+
+    public String getHome()
+    {
+        return home;
+    }
+
+    public void setHome(String home)
+    {
+        this.home = home;
+    }
+
+    /**
+     * JDK启动时间
+     */
+    public String getStartTime()
+    {
+        return DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, DateUtils.getServerStartDate());
+    }
+
+    /**
+     * JDK运行时间
+     */
+    public String getRunTime()
+    {
+        return DateUtils.getDatePoor(DateUtils.getNowDate(), DateUtils.getServerStartDate());
+    }
+}

+ 61 - 0
fs-agent/src/main/java/com/fs/framework/web/domain/server/Mem.java

@@ -0,0 +1,61 @@
+package com.fs.framework.web.domain.server;
+
+import com.fs.common.utils.Arith;
+
+/**
+ * 內存相关信息
+ * 
+
+ */
+public class Mem
+{
+    /**
+     * 内存总量
+     */
+    private double total;
+
+    /**
+     * 已用内存
+     */
+    private double used;
+
+    /**
+     * 剩余内存
+     */
+    private double free;
+
+    public double getTotal()
+    {
+        return Arith.div(total, (1024 * 1024 * 1024), 2);
+    }
+
+    public void setTotal(long total)
+    {
+        this.total = total;
+    }
+
+    public double getUsed()
+    {
+        return Arith.div(used, (1024 * 1024 * 1024), 2);
+    }
+
+    public void setUsed(long used)
+    {
+        this.used = used;
+    }
+
+    public double getFree()
+    {
+        return Arith.div(free, (1024 * 1024 * 1024), 2);
+    }
+
+    public void setFree(long free)
+    {
+        this.free = free;
+    }
+
+    public double getUsage()
+    {
+        return Arith.mul(Arith.div(used, total, 4), 100);
+    }
+}

+ 84 - 0
fs-agent/src/main/java/com/fs/framework/web/domain/server/Sys.java

@@ -0,0 +1,84 @@
+package com.fs.framework.web.domain.server;
+
+/**
+ * 系统相关信息
+ * 
+
+ */
+public class Sys
+{
+    /**
+     * 服务器名称
+     */
+    private String computerName;
+
+    /**
+     * 服务器Ip
+     */
+    private String computerIp;
+
+    /**
+     * 项目路径
+     */
+    private String userDir;
+
+    /**
+     * 操作系统
+     */
+    private String osName;
+
+    /**
+     * 系统架构
+     */
+    private String osArch;
+
+    public String getComputerName()
+    {
+        return computerName;
+    }
+
+    public void setComputerName(String computerName)
+    {
+        this.computerName = computerName;
+    }
+
+    public String getComputerIp()
+    {
+        return computerIp;
+    }
+
+    public void setComputerIp(String computerIp)
+    {
+        this.computerIp = computerIp;
+    }
+
+    public String getUserDir()
+    {
+        return userDir;
+    }
+
+    public void setUserDir(String userDir)
+    {
+        this.userDir = userDir;
+    }
+
+    public String getOsName()
+    {
+        return osName;
+    }
+
+    public void setOsName(String osName)
+    {
+        this.osName = osName;
+    }
+
+    public String getOsArch()
+    {
+        return osArch;
+    }
+
+    public void setOsArch(String osArch)
+    {
+        this.osArch = osArch;
+    }
+}

+ 114 - 0
fs-agent/src/main/java/com/fs/framework/web/domain/server/SysFile.java

@@ -0,0 +1,114 @@
+package com.fs.framework.web.domain.server;
+
+/**
+ * 系统文件相关信息
+ * 
+
+ */
+public class SysFile
+{
+    /**
+     * 盘符路径
+     */
+    private String dirName;
+
+    /**
+     * 盘符类型
+     */
+    private String sysTypeName;
+
+    /**
+     * 文件类型
+     */
+    private String typeName;
+
+    /**
+     * 总大小
+     */
+    private String total;
+
+    /**
+     * 剩余大小
+     */
+    private String free;
+
+    /**
+     * 已经使用量
+     */
+    private String used;
+
+    /**
+     * 资源的使用率
+     */
+    private double usage;
+
+    public String getDirName()
+    {
+        return dirName;
+    }
+
+    public void setDirName(String dirName)
+    {
+        this.dirName = dirName;
+    }
+
+    public String getSysTypeName()
+    {
+        return sysTypeName;
+    }
+
+    public void setSysTypeName(String sysTypeName)
+    {
+        this.sysTypeName = sysTypeName;
+    }
+
+    public String getTypeName()
+    {
+        return typeName;
+    }
+
+    public void setTypeName(String typeName)
+    {
+        this.typeName = typeName;
+    }
+
+    public String getTotal()
+    {
+        return total;
+    }
+
+    public void setTotal(String total)
+    {
+        this.total = total;
+    }
+
+    public String getFree()
+    {
+        return free;
+    }
+
+    public void setFree(String free)
+    {
+        this.free = free;
+    }
+
+    public String getUsed()
+    {
+        return used;
+    }
+
+    public void setUsed(String used)
+    {
+        this.used = used;
+    }
+
+    public double getUsage()
+    {
+        return usage;
+    }
+
+    public void setUsage(double usage)
+    {
+        this.usage = usage;
+    }
+}

+ 126 - 0
fs-agent/src/main/java/com/fs/framework/web/exception/GlobalExceptionHandler.java

@@ -0,0 +1,126 @@
+package com.fs.framework.web.exception;
+
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.exception.CustomException;
+import com.fs.common.exception.DemoModeException;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 全局异常处理器
+ *
+
+ */
+@Component("frameworkWebGlobalExceptionHandler")
+@RestControllerAdvice
+public class GlobalExceptionHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    /**
+     * 权限校验异常
+     */
+    @ExceptionHandler(AccessDeniedException.class)
+    public AjaxResult handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage());
+        return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
+    }
+
+    /**
+     * 请求方式不支持
+     */
+    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+    public AjaxResult handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
+            HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 业务异常
+     */
+    @ExceptionHandler(ServiceException.class)
+    public AjaxResult handleServiceException(ServiceException e, HttpServletRequest request)
+    {
+        log.error(e.getMessage(), e);
+        Integer code = e.getCode();
+        return StringUtils.isNotNull(code) ? AjaxResult.error(code, e.getMessage()) : AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 拦截未知的运行时异常
+     */
+    @ExceptionHandler(RuntimeException.class)
+    public AjaxResult handleRuntimeException(RuntimeException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生未知异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    @ExceptionHandler(CustomException.class)
+    public AjaxResult handleCustomException(CustomException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生未知异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 系统异常
+     */
+    @ExceptionHandler(Exception.class)
+    public AjaxResult handleException(Exception e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生系统异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 自定义验证异常
+     */
+    @ExceptionHandler(BindException.class)
+    public AjaxResult handleBindException(BindException e)
+    {
+        log.error(e.getMessage(), e);
+        String message = e.getAllErrors().get(0).getDefaultMessage();
+        return AjaxResult.error(message);
+    }
+
+    /**
+     * 自定义验证异常
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e)
+    {
+        log.error(e.getMessage(), e);
+        String message = e.getBindingResult().getFieldError().getDefaultMessage();
+        return AjaxResult.error(message);
+    }
+
+    /**
+     * 演示模式异常
+     */
+    @ExceptionHandler(DemoModeException.class)
+    public AjaxResult handleDemoModeException(DemoModeException e)
+    {
+        return AjaxResult.error("演示模式,不允许操作");
+    }
+}

+ 166 - 0
fs-agent/src/main/java/com/fs/framework/web/service/PermissionService.java

@@ -0,0 +1,166 @@
+package com.fs.framework.web.service;
+
+import com.fs.common.core.domain.entity.SysRole;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Set;
+
+/**
+ *  自定义权限实现,ss取自SpringSecurity首字母
+ *
+
+ */
+@Service("ss")
+public class PermissionService
+{
+    /** 所有权限标识 */
+    private static final String ALL_PERMISSION = "*:*:*";
+
+    /** 管理员角色权限标识 */
+    private static final String SUPER_ADMIN = "admin";
+
+    private static final String ROLE_DELIMETER = ",";
+
+    private static final String PERMISSION_DELIMETER = ",";
+
+    /**
+     * 验证用户是否具备某权限
+     *
+     * @param permission 权限字符串
+     * @return 用户是否具备某权限
+     */
+    public boolean hasPermi(String permission)
+    {
+        if (StringUtils.isEmpty(permission))
+        {
+            return false;
+        }
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
+        {
+            return false;
+        }
+        return hasPermissions(loginUser.getPermissions(), permission);
+    }
+
+    /**
+     * 验证用户是否不具备某权限,与 hasPermi逻辑相反
+     *
+     * @param permission 权限字符串
+     * @return 用户是否不具备某权限
+     */
+    public boolean lacksPermi(String permission)
+    {
+        return hasPermi(permission) != true;
+    }
+
+    /**
+     * 验证用户是否具有以下任意一个权限
+     *
+     * @param permissions 以 PERMISSION_NAMES_DELIMETER 为分隔符的权限列表
+     * @return 用户是否具有以下任意一个权限
+     */
+    public boolean hasAnyPermi(String permissions)
+    {
+        if (StringUtils.isEmpty(permissions))
+        {
+            return false;
+        }
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
+        {
+            return false;
+        }
+        Set<String> authorities = loginUser.getPermissions();
+        for (String permission : permissions.split(PERMISSION_DELIMETER))
+        {
+            if (permission != null && hasPermissions(authorities, permission))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断用户是否拥有某个角色
+     *
+     * @param role 角色字符串
+     * @return 用户是否具备某角色
+     */
+    public boolean hasRole(String role)
+    {
+        if (StringUtils.isEmpty(role))
+        {
+            return false;
+        }
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
+        {
+            return false;
+        }
+        for (SysRole sysRole : loginUser.getUser().getRoles())
+        {
+            String roleKey = sysRole.getRoleKey();
+            if (SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role)))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 验证用户是否不具备某角色,与 isRole逻辑相反。
+     *
+     * @param role 角色名称
+     * @return 用户是否不具备某角色
+     */
+    public boolean lacksRole(String role)
+    {
+        return hasRole(role) != true;
+    }
+
+    /**
+     * 验证用户是否具有以下任意一个角色
+     *
+     * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
+     * @return 用户是否具有以下任意一个角色
+     */
+    public boolean hasAnyRoles(String roles)
+    {
+        if (StringUtils.isEmpty(roles))
+        {
+            return false;
+        }
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
+        {
+            return false;
+        }
+        for (String role : roles.split(ROLE_DELIMETER))
+        {
+            if (hasRole(role))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断是否包含权限
+     *
+     * @param permissions 权限列表
+     * @param permission 权限字符串
+     * @return 用户是否具备某权限
+     */
+    private boolean hasPermissions(Set<String> permissions, String permission)
+    {
+        return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
+    }
+}

+ 464 - 0
fs-agent/src/main/java/com/fs/framework/web/service/SysLoginService.java

@@ -0,0 +1,464 @@
+package com.fs.framework.web.service;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.exception.user.CaptchaException;
+import com.fs.common.exception.user.CaptchaExpireException;
+import com.fs.common.exception.user.UserPasswordNotMatchException;
+import com.fs.common.service.WechatLoginService;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.MessageUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.system.service.ISysConfigService;
+import com.fs.system.service.ISysUserService;
+import com.fs.tenant.domain.TenantInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 登录校验方法
+ *
+
+ */
+@Component
+public class SysLoginService
+{
+    @Autowired
+    private TokenService tokenService;
+
+    @Resource
+    private AuthenticationManager authenticationManager;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private ISysUserService userService;
+
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private WechatLoginService wechatLoginService;
+
+    @Value("${wechat.admin.appid:#{null}}")
+    private String appId;
+    @Value("${wechat.admin.secret:#{null}}")
+    private String secret;
+    @Value("${wechat.admin.redirectUri:#{null}}")
+    private String redirectUri;
+    @Value("${wechat.isNeedScan:false}")
+    private Boolean isNeedScan;
+
+    /**
+     * 登录验证
+     *
+     * @param username 用户名
+     * @param password 密码
+     * @param code 验证码
+     * @param uuid 唯一标识
+     * @return 结果
+     */
+    public String login(String username, String password, String code, String uuid, String tenantCode)
+    {
+        Long tenantId = resolveTenantIdQuiet(tenantCode);
+        boolean captchaOnOff = configService.selectCaptchaOnOff();
+        if (captchaOnOff)
+        {
+            validateCaptcha(username, code, uuid, tenantId);
+        }
+
+        TenantInfo tenantInfo = null;
+
+        // 默认使用主库
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+
+        // ===== 只有传了 tenantCode 才查询租户并切库 =====
+        if (StringUtils.isNotBlank(tenantCode))
+        {
+            tenantInfo = userService.getTenantInfo(tenantCode);
+            if (BeanUtil.isEmpty(tenantInfo)) {
+                throw new ServiceException("企业不存在");
+            }
+
+            if (!tenantInfo.getStatus().equals(1)) {
+                throw new ServiceException("企业已禁用");
+            }
+
+            tenantId = tenantInfo.getId();
+            tenantDataSourceManager.switchTenant(tenantInfo);
+        }
+
+        try {
+            Authentication authentication = null;
+            try {
+                authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
+            } catch (Exception e) {
+                if (e instanceof BadCredentialsException) {
+                    AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
+                    throw new UserPasswordNotMatchException();
+                } else {
+                    AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, e.getMessage()));
+                    throw new ServiceException(e.getMessage());
+                }
+            }
+
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
+
+            LoginUser loginUser = (LoginUser) authentication.getPrincipal();
+
+            // 只有多租户登录才设置 tenantId
+            if (tenantInfo != null) {
+                loginUser.setTenantId(tenantInfo.getId());
+            }
+
+            // 首次登录标记:loginDate 为 null 说明从未登录过
+            if (loginUser.getUser().getLoginDate() == null) {
+                redisCache.setCacheObject("firstLogin:admin:" + loginUser.getUser().getUserId(), true, 5, TimeUnit.MINUTES);
+            }
+
+            recordLoginInfo(loginUser.getUser());
+
+            // 生成 token
+            return tokenService.createToken(loginUser);
+        } finally {
+            // 防止线程串库(必须)
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+
+    public String loginForProxy(String username, String password, String code, String uuid) {
+        Long tenantId = null;
+        boolean captchaOnOff = configService.selectCaptchaOnOff();
+        if (captchaOnOff) {
+            validateCaptcha(username, code, uuid, tenantId);
+        }
+
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+
+        try {
+            Authentication authentication = null;
+            try {
+                authentication = authenticationManager
+                        .authenticate(new UsernamePasswordAuthenticationToken(username, password));
+            } catch (Exception e) {
+                if (e instanceof BadCredentialsException) {
+                    AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username,
+                            Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
+                    throw new UserPasswordNotMatchException();
+                } else {
+                    AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username,
+                            Constants.LOGIN_FAIL, e.getMessage()));
+                    throw new ServiceException(e.getMessage());
+                }
+            }
+
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username,
+                    Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
+
+            LoginUser loginUser = (LoginUser) authentication.getPrincipal();
+
+            if (loginUser.getUser().getLoginDate() == null) {
+                redisCache.setCacheObject("firstLogin:admin:" + loginUser.getUser().getUserId(),
+                        true, 5, TimeUnit.MINUTES);
+            }
+
+            recordLoginInfo(loginUser.getUser());
+
+            return tokenService.createToken(loginUser);
+        } finally {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+
+    /**
+     * 校验验证码
+     *
+     * @param username 用户名
+     * @param code 验证码
+     * @param uuid 唯一标识
+     * @return 结果
+     */
+    public void validateCaptcha(String username, String code, String uuid, Long tenantId)
+    {
+        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
+        String captcha = redisCache.getCacheObject(verifyKey);
+        redisCache.deleteObject(verifyKey);
+        if (captcha == null)
+        {
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
+            throw new CaptchaExpireException();
+        }
+        if (!code.equalsIgnoreCase(captcha))
+        {
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
+            throw new CaptchaException();
+        }
+    }
+
+    /**
+     * 记录登录信息
+     */
+    public void recordLoginInfo(SysUser user)
+    {
+        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+        String loginIp = user.getLoginIp();
+        if (StringUtils.isEmpty(loginIp)) {
+            user.setLoginIp(ipAddr);
+        } else {
+            List<String> ipList = new ArrayList<>(Arrays.asList(loginIp.split(",")));
+            if (!ipList.contains(ipAddr)) {
+                ipList.add(ipAddr);
+                user.setLoginIp(String.join(",", ipList));
+            }
+        }
+        user.setLoginDate(DateUtils.getNowDate());
+        userService.updateUserProfile(user);
+    }
+
+    @Resource
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public boolean checkIsNeedCheck(String username, String password, String code, String uuid, String tenantCode)
+    {
+        Long tenantId = resolveTenantIdQuiet(tenantCode);
+        boolean captchaOnOff = configService.selectCaptchaOnOff();
+        if (captchaOnOff)
+        {
+            String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
+            String captcha = redisCache.getCacheObject(verifyKey);
+            redisCache.deleteObject(verifyKey);
+
+            if (captcha == null)
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
+                throw new CaptchaExpireException();
+            }
+
+            if (!code.equalsIgnoreCase(captcha))
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
+                throw new CaptchaException();
+            }
+        }
+
+        TenantInfo tenantInfo = null;
+
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+
+        tenantInfo = userService.getTenantInfo(tenantCode);
+        if (BeanUtil.isEmpty(tenantInfo))
+        {
+            throw new ServiceException("企业不存在");
+        }
+        if (!tenantInfo.getStatus().equals(1))
+        {
+            throw new ServiceException("企业已禁用");
+        }
+
+        tenantId = tenantInfo.getId();
+        tenantDataSourceManager.switchTenant(tenantInfo);
+
+        try
+        {
+            Authentication authentication = null;
+            try {
+                authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
+            } catch (Exception e)
+            {
+                if (e instanceof BadCredentialsException) {
+                    AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
+                    throw new UserPasswordNotMatchException();
+                } else {
+                    AsyncManager.me().execute(AsyncFactory.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, e.getMessage()));
+                    throw new ServiceException(e.getMessage());
+                }
+            }
+
+            LoginUser loginUser = (LoginUser) authentication.getPrincipal();
+
+            // 查询当前登录用户信息(在当前数据源下)
+            SysUser sysUser = userService.selectUserById(loginUser.getUserId());
+
+            Long[] userIds = new Long[]{236L, 246L, 247L, 253L, 119L};
+            for (Long userId : userIds)
+            {
+                if (userId.equals(sysUser.getUserId()))
+                {
+                    return false;
+                }
+            }
+
+            // 判断是否开启了扫码配置
+            if (ObjectUtil.isEmpty(isNeedScan) || !isNeedScan)
+            {
+                return false;
+            }
+
+            // true → 需要短信验证码
+            // false → 直接登录
+            return needCheck(sysUser);
+        }
+        finally
+        {
+            // 防止线程串库
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    public boolean needCheck(SysUser sysUser) {
+
+
+        // 1. 校验 IP
+        if (!checkIp(sysUser)) {
+            // IP 不一致
+            return true;
+        }
+
+        // 2. 校验是否首次登录
+        if (checkIsFirstLogin(sysUser)) {
+            return true;
+        }
+
+        // 3. 校验上次登录时间是否在五天前
+        if (checkIsLoginTime(sysUser)) {
+            return true;
+        }
+
+        // 4. 检查是否在设置的某一天
+//        if (checkIsSpecialDay(new Date())) {
+//            return true;
+//        }
+        if (haveUnionId(sysUser)){
+            return true;
+        }
+
+        return false;
+    }
+    public boolean haveUnionId( SysUser sysUser){
+        if (StringUtils.isEmpty(sysUser.getUnionId())){
+            return true;
+        }
+        return false;
+    }
+    public boolean checkIp(SysUser sysUser){
+        // 获取当前 IP
+        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+        // 获取已记录的登录 IP
+        String lastLoginIp = sysUser.getLoginIp();
+
+        if (StringUtils.isNotEmpty(lastLoginIp)) {
+            List<String> ipList = Arrays.asList(lastLoginIp.split(","));
+            return ipList.contains(ipAddr);
+        }
+        return false;
+    }
+    //检查是否第一次登录
+    public boolean checkIsFirstLogin(SysUser sysUser){
+        // 获取上次登录 IP
+        String lastLoginIp = sysUser.getLoginIp();
+        if (StringUtils.isEmpty(lastLoginIp)||sysUser.getLoginDate()==null){
+            return true;
+        }
+        return false;
+    }
+    public boolean checkIsLoginTime(SysUser sysUser) {
+        // 获取上次登录时间
+        Date loginDate = sysUser.getLoginDate();
+        if (loginDate == null) {
+            // 没有登录记录,直接返回 true(需要处理)
+            return true;
+        }
+
+        // 当前时间
+        Date now = new Date();
+
+        // 计算两个时间的毫秒差
+        long diff = now.getTime() - loginDate.getTime();
+
+        // 5天 = 5 * 24 * 60 * 60 * 1000 毫秒
+        long fiveDays = 5L * 24 * 60 * 60 * 1000;
+
+        return diff >= fiveDays;
+    }
+
+    /**
+     * 获取微信登录二维码参数
+     * @param account 当前登录用户名
+     * @return 二维码参数
+     */
+    public Map<String, String> getWechatQrCode(String account) throws Exception {
+        // 生成 loginTicket
+        String ticket = UUID.randomUUID().toString();
+        redisCache.setCacheObject("login:ticket:" + ticket, account, 60, TimeUnit.SECONDS);
+
+        return wechatLoginService.getQrCode(ticket,appId,secret,redirectUri); // 返回二维码参数
+    }
+
+    /**
+     * 微信扫码回调
+     */
+    public void handleCallback(String code, String ticket) {
+        String url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=" + appId
+                + "&secret=" + secret
+                + "&code=" + code
+                + "&grant_type=authorization_code";
+
+        JSONObject json = JSON.parseObject(cn.hutool.http.HttpUtil.get(url));
+        String unionid = json.getString("unionid");
+        if (unionid == null) throw new ServiceException("微信授权失败");
+
+        String username = redisCache.getCacheObject("login:ticket:" + ticket);
+        if (username == null) throw new ServiceException("ticket无效或过期");
+        SysUser sysUser = userService.selectUserByUserName(username);
+        if (sysUser == null) throw new ServiceException("用户不存在");
+        if (sysUser.getUnionId() == null || sysUser.getUnionId().isEmpty()) {
+            // 如果用户没有绑定 unionid,则绑定当前扫码用户的 unionid
+            sysUser.setUnionId(unionid);
+            userService.updateUserProfile(sysUser);
+        } else if (!sysUser.getUnionId().equals(unionid)) {
+            // 如果用户已绑定 unionid,但与扫码用户不一致,则拒绝登录
+            redisCache.setCacheObject("wechat:scan:" + ticket, "error:账号与绑定用户不匹配", 30, TimeUnit.SECONDS);
+            return;
+        }
+
+        redisCache.setCacheObject("wechat:scan:" + ticket, "ok", 30, TimeUnit.SECONDS);
+    }
+
+    private Long resolveTenantIdQuiet(String tenantCode)
+    {
+        if (StringUtils.isBlank(tenantCode))
+        {
+            return null;
+        }
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        TenantInfo tenantInfo = userService.getTenantInfo(tenantCode);
+        return BeanUtil.isEmpty(tenantInfo) ? null : tenantInfo.getId();
+    }
+}

+ 67 - 0
fs-agent/src/main/java/com/fs/framework/web/service/SysPermissionService.java

@@ -0,0 +1,67 @@
+package com.fs.framework.web.service;
+
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.system.service.ISysMenuService;
+import com.fs.system.service.ISysRoleService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 用户权限处理
+ *
+
+ */
+@Component
+public class SysPermissionService
+{
+    @Autowired
+    private ISysRoleService roleService;
+
+    @Autowired
+    private ISysMenuService menuService;
+
+    /**
+     * 获取角色数据权限
+     *
+     * @param user 用户信息
+     * @return 角色权限信息
+     */
+    public Set<String> getRolePermission(SysUser user)
+    {
+        Set<String> roles = new HashSet<String>();
+        // 管理员拥有所有权限
+        if (user.isAdmin())
+        {
+            roles.add("admin");
+        }
+        else
+        {
+            roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
+        }
+        return roles;
+    }
+
+    /**
+     * 获取菜单数据权限
+     *
+     * @param user 用户信息
+     * @return 菜单权限信息
+     */
+    public Set<String> getMenuPermission(SysUser user)
+    {
+        Set<String> perms = new HashSet<String>();
+        // 管理员拥有所有权限
+        if (user.isAdmin())
+        {
+            perms.add("*:*:*");
+        }
+        else
+        {
+            perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
+        }
+        return perms;
+    }
+}

+ 115 - 0
fs-agent/src/main/java/com/fs/framework/web/service/SysRegisterService.java

@@ -0,0 +1,115 @@
+package com.fs.framework.web.service;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.constant.UserConstants;
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.common.core.domain.model.RegisterBody;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.user.CaptchaException;
+import com.fs.common.exception.user.CaptchaExpireException;
+import com.fs.common.utils.MessageUtils;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.system.service.ISysConfigService;
+import com.fs.system.service.ISysUserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 注册校验方法
+ *
+
+ */
+@Component
+public class SysRegisterService
+{
+    @Autowired
+    private ISysUserService userService;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 注册
+     */
+    public String register(RegisterBody registerBody)
+    {
+        String msg = "", username = registerBody.getUsername(), password = registerBody.getPassword();
+
+        boolean captchaOnOff = configService.selectCaptchaOnOff();
+        // 验证码开关
+        if (captchaOnOff)
+        {
+            validateCaptcha(username, registerBody.getCode(), registerBody.getUuid());
+        }
+
+        if (StringUtils.isEmpty(username))
+        {
+            msg = "用户名不能为空";
+        }
+        else if (StringUtils.isEmpty(password))
+        {
+            msg = "用户密码不能为空";
+        }
+        else if (username.length() < UserConstants.USERNAME_MIN_LENGTH
+                || username.length() > UserConstants.USERNAME_MAX_LENGTH)
+        {
+            msg = "账户长度必须在2到20个字符之间";
+        }
+        else if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
+                || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
+        {
+            msg = "密码长度必须在5到20个字符之间";
+        }
+        else if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username)))
+        {
+            msg = "保存用户'" + username + "'失败,注册账号已存在";
+        }
+        else
+        {
+            SysUser sysUser = new SysUser();
+            sysUser.setUserName(username);
+            sysUser.setNickName(username);
+            sysUser.setPassword(SecurityUtils.encryptPassword(registerBody.getPassword()));
+            boolean regFlag = userService.registerUser(sysUser);
+            if (!regFlag)
+            {
+                msg = "注册失败,请联系系统管理人员";
+            }
+            else
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.REGISTER,
+                        MessageUtils.message("user.register.success")));
+            }
+        }
+        return msg;
+    }
+
+    /**
+     * 校验验证码
+     *
+     * @param username 用户名
+     * @param code 验证码
+     * @param uuid 唯一标识
+     * @return 结果
+     */
+    public void validateCaptcha(String username, String code, String uuid)
+    {
+        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
+        String captcha = redisCache.getCacheObject(verifyKey);
+        redisCache.deleteObject(verifyKey);
+        if (captcha == null)
+        {
+            throw new CaptchaExpireException();
+        }
+        if (!code.equalsIgnoreCase(captcha))
+        {
+            throw new CaptchaException();
+        }
+    }
+}

+ 311 - 0
fs-agent/src/main/java/com/fs/framework/web/service/TokenService.java

@@ -0,0 +1,311 @@
+package com.fs.framework.web.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.AddressUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.uuid.IdUtils;
+import eu.bitwalker.useragentutils.UserAgent;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * token验证处理
+ *
+
+ */
+@Component("frameworkWebTokenService")
+public class TokenService
+{
+    // 令牌自定义标识
+    @Value("${token.header}")
+    private String header;
+
+    // 令牌秘钥
+    @Value("${token.secret}")
+    private String secret;
+
+    // 令牌有效期(默认30分钟)
+    @Value("${token.expireTime}")
+    private int expireTime;
+
+    protected static final long MILLIS_SECOND = 1000;
+
+    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
+
+    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private RedisTemplate<Object, Object> redisTemplate;
+
+    /**
+     * 获取用户身份信息
+     *
+     * @return 用户信息
+     */
+    public LoginUser getLoginUser(HttpServletRequest request)
+    {
+        // 获取请求携带的令牌
+        String token = getToken(request);
+        if (StringUtils.isNotEmpty(token))
+        {
+            try
+            {
+                Claims claims = parseToken(token);
+                // 优先使用 login_user_key(平台管理端登录)
+                String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
+                if (StringUtils.isNotEmpty(uuid))
+                {
+                    String userKey = getTokenKey(uuid);
+                    LoginUser user = redisCache.getCacheObject(userKey);
+                    if (user != null)
+                    {
+                        return user;
+                    }
+                }
+                // 兼容租户端token:回退到 company_login_user_key
+                String companyUuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+                if (StringUtils.isNotEmpty(companyUuid))
+                {
+                    return convertCompanyLoginUser(claims, companyUuid);
+                }
+            }
+            catch (Exception e)
+            {
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 从Redis读取company端LoginUser并转换为admin端LoginUser,使company token在fs-admin中可用。
+     */
+    private LoginUser convertCompanyLoginUser(Claims claims, String companyUuid)
+    {
+        try
+        {
+            Long tenantId = claims.get("tenantId") != null ? ((Number) claims.get("tenantId")).longValue() : null;
+            String companyUserKey;
+            if (tenantId != null)
+            {
+                companyUserKey = "tenantid:" + tenantId + ":company_login_tokens:" + companyUuid;
+            }
+            else
+            {
+                companyUserKey = Constants.COMPANY_LOGIN_TOKEN_KEY + companyUuid;
+            }
+            Object cached = redisTemplate.opsForValue().get(companyUserKey);
+            if (cached == null)
+            {
+                return null;
+            }
+            String json = JSON.toJSONString(cached);
+            JSONObject companyObj = JSON.parseObject(json);
+
+            LoginUser loginUser = new LoginUser();
+            loginUser.setToken(companyObj.getString("token"));
+            loginUser.setLoginTime(companyObj.getLong("loginTime"));
+            loginUser.setExpireTime(companyObj.getLong("expireTime"));
+            loginUser.setIpaddr(companyObj.getString("ipaddr"));
+            loginUser.setLoginLocation(companyObj.getString("loginLocation"));
+            loginUser.setBrowser(companyObj.getString("browser"));
+            loginUser.setOs(companyObj.getString("os"));
+            loginUser.setTenantId(companyObj.getLong("tenantId"));
+
+            // 权限 - 租户管理员拥有租户相关权限
+            Set<String> permissions = new HashSet<>();
+            permissions.add("*:*:*");
+            loginUser.setPermissions(permissions);
+
+            // 构造SysUser从CompanyUser字段
+            JSONObject userObj = companyObj.getJSONObject("user");
+            if (userObj != null)
+            {
+                SysUser sysUser = new SysUser();
+                sysUser.setUserId(userObj.getLong("userId"));
+                sysUser.setUserName(userObj.getString("userName"));
+                sysUser.setNickName(userObj.getString("nickName"));
+                sysUser.setEmail(userObj.getString("email"));
+                sysUser.setPhonenumber(userObj.getString("phonenumber"));
+                sysUser.setSex(userObj.getString("sex"));
+                sysUser.setAvatar(userObj.getString("avatar"));
+                sysUser.setStatus(userObj.getString("status"));
+                loginUser.setUser(sysUser);
+            }
+
+            return loginUser;
+        }
+        catch (Exception e)
+        {
+            return null;
+        }
+    }
+
+    /**
+     * 设置用户身份信息
+     */
+    public void setLoginUser(LoginUser loginUser)
+    {
+        if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
+        {
+            refreshToken(loginUser);
+        }
+    }
+
+    /**
+     * 删除用户身份信息
+     */
+    public void delLoginUser(String token)
+    {
+        if (StringUtils.isNotEmpty(token))
+        {
+            String userKey = getTokenKey(token);
+            redisCache.deleteObject(userKey);
+        }
+    }
+
+    /**
+     * 创建令牌
+     *
+     * @param loginUser 用户信息
+     * @return 令牌
+     */
+    public String createToken(LoginUser loginUser)
+    {
+        String token = IdUtils.fastUUID();
+        loginUser.setToken(token);
+        setUserAgent(loginUser);
+        refreshToken(loginUser);
+
+        Map<String, Object> claims = new HashMap<>();
+        claims.put(Constants.LOGIN_USER_KEY, token);
+        return createToken(claims);
+    }
+
+    /**
+     * 验证令牌有效期,相差不足20分钟,自动刷新缓存
+     *
+     * @param loginUser
+     * @return 令牌
+     */
+    public void verifyToken(LoginUser loginUser)
+    {
+        long expireTime = loginUser.getExpireTime();
+        long currentTime = System.currentTimeMillis();
+        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
+        {
+            refreshToken(loginUser);
+        }
+    }
+
+    /**
+     * 刷新令牌有效期
+     *
+     * @param loginUser 登录信息
+     */
+    public void refreshToken(LoginUser loginUser)
+    {
+        loginUser.setLoginTime(System.currentTimeMillis());
+        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
+        // 根据uuid将loginUser缓存
+        String userKey = getTokenKey(loginUser.getToken());
+        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
+    }
+
+    /**
+     * 设置用户代理信息
+     *
+     * @param loginUser 登录信息
+     */
+    public void setUserAgent(LoginUser loginUser)
+    {
+        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        loginUser.setIpaddr(ip);
+        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
+        loginUser.setBrowser(userAgent.getBrowser().getName());
+        loginUser.setOs(userAgent.getOperatingSystem().getName());
+    }
+
+    /**
+     * 从数据声明生成令牌
+     *
+     * @param claims 数据声明
+     * @return 令牌
+     */
+    private String createToken(Map<String, Object> claims)
+    {
+        String token = Jwts.builder()
+                .setClaims(claims)
+                .signWith(SignatureAlgorithm.HS512, secret).compact();
+        return token;
+    }
+
+    /**
+     * 从令牌中获取数据声明
+     *
+     * @param token 令牌
+     * @return 数据声明
+     */
+    private Claims parseToken(String token)
+    {
+        return Jwts.parser()
+                .setSigningKey(secret)
+                .parseClaimsJws(token)
+                .getBody();
+    }
+
+    /**
+     * 从令牌中获取用户名
+     *
+     * @param token 令牌
+     * @return 用户名
+     */
+    public String getUsernameFromToken(String token)
+    {
+        Claims claims = parseToken(token);
+        return claims.getSubject();
+    }
+
+    /**
+     * 获取请求token
+     *
+     * @param request
+     * @return token
+     */
+    private String getToken(HttpServletRequest request)
+    {
+        String token = request.getHeader(header);
+        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
+        {
+            token = token.replace(Constants.TOKEN_PREFIX, "");
+        }
+        return token;
+    }
+
+    private String getTokenKey(String uuid)
+    {
+        return Constants.LOGIN_TOKEN_KEY + uuid;
+    }
+}

+ 57 - 0
fs-agent/src/main/java/com/fs/framework/web/service/UserDetailsServiceImpl.java

@@ -0,0 +1,57 @@
+package com.fs.framework.web.service;
+
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.proxy.domain.Proxy;
+import com.fs.proxy.service.ProxyService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@Service
+public class UserDetailsServiceImpl implements UserDetailsService
+{
+    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
+
+    @Autowired
+    private ProxyService proxyService;
+
+    @Override
+    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
+    {
+        Proxy proxy = proxyService.selectProxyByName(username);
+        if (StringUtils.isNull(proxy))
+        {
+            log.info("代理账号:{} 不存在.", username);
+            throw new ServiceException("代理账号不存在");
+        }
+        if (proxy.getStatus() != null && proxy.getStatus() != 1)
+        {
+            log.info("代理账号:{} 已被禁用.", username);
+            throw new ServiceException("代理账号已禁用");
+        }
+
+        SysUser proxyUser = new SysUser();
+        proxyUser.setUserId(proxy.getProxyId());
+        proxyUser.setUserName(proxy.getProxyName());
+        proxyUser.setNickName(proxy.getProxyName());
+        proxyUser.setPassword(proxy.getPassword());
+        proxyUser.setStatus("0");
+
+        Set<String> perms = new HashSet<>();
+        perms.add("*:*:*");
+
+        LoginUser loginUser = new LoginUser(proxy.getProxyId(), null, proxyUser, perms);
+        loginUser.setProxyId(proxy.getProxyId());
+        return loginUser;
+    }
+}

+ 11 - 37
fs-agent/src/main/java/com/fs/proxy/controller/ProxyLoginController.java

@@ -2,15 +2,10 @@ package com.fs.proxy.controller;
 
 import com.fs.common.constant.Constants;
 import com.fs.common.core.domain.AjaxResult;
-import com.fs.common.core.domain.entity.SysUser;
 import com.fs.common.core.domain.model.LoginBody;
-import com.fs.common.core.domain.model.LoginUser;
-import com.fs.common.utils.SecurityUtils;
 import com.fs.framework.web.service.SysLoginService;
-import com.fs.framework.web.service.TokenService;
 import com.fs.proxy.domain.Proxy;
 import com.fs.proxy.service.ProxyService;
-import com.fs.system.service.ISysUserService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.context.annotation.Profile;
@@ -23,46 +18,25 @@ public class ProxyLoginController {
     @Autowired
     private SysLoginService loginService;
 
-    @Autowired
-    private TokenService tokenService;
-
     @Autowired
     private ProxyService proxyService;
 
-    @Autowired
-    private ISysUserService userService;
-
     @PostMapping("/login")
     public AjaxResult login(@RequestBody LoginBody loginBody) {
-        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(),
-                loginBody.getCode(), loginBody.getUuid(), loginBody.getTenantCode());
-
         Proxy proxy = proxyService.selectProxyByName(loginBody.getUsername());
         if (proxy == null) {
-            SysUser user = userService.selectUserByUserName(loginBody.getUsername());
-            if (user != null) {
-                proxy = proxyService.selectProxyById(user.getUserId());
-            }
+            return AjaxResult.error("该代理账号不存在");
         }
-        if (proxy != null) {
-            if (proxy.getStatus() != null && proxy.getStatus() != 1) {
-                return AjaxResult.error("该代理账号已被禁用");
-            }
-            try {
-                LoginUser loginUser = SecurityUtils.getLoginUser();
-                if (loginUser != null) {
-                    loginUser.setProxyId(proxy.getProxyId());
-                    tokenService.refreshToken(loginUser);
-                }
-            } catch (Exception ignored) {
-                // 无状态JWT模式,登录时SecurityContext未设置,忽略
-            }
-            AjaxResult ajax = AjaxResult.success();
-            ajax.put(Constants.TOKEN, token);
-            ajax.put("proxyInfo", proxy);
-            return ajax;
-        } else {
-            return AjaxResult.error("该账号不是代理账号");
+        if (proxy.getStatus() != null && proxy.getStatus() != 1) {
+            return AjaxResult.error("该代理账号已被禁用");
         }
+
+        String token = loginService.loginForProxy(loginBody.getUsername(), loginBody.getPassword(),
+                loginBody.getCode(), loginBody.getUuid());
+
+        AjaxResult ajax = AjaxResult.success();
+        ajax.put(Constants.TOKEN, token);
+        ajax.put("proxyInfo", proxy);
+        return ajax;
     }
 }

+ 22 - 0
fs-framework/src/main/java/com/fs/framework/web/service/TokenService.java

@@ -287,6 +287,28 @@ public class TokenService
         return claims.getSubject();
     }
 
+    public LoginUser getLoginUserFromToken(String jwtToken)
+    {
+        if (StringUtils.isEmpty(jwtToken))
+        {
+            return null;
+        }
+        try
+        {
+            Claims claims = parseToken(jwtToken);
+            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
+            if (StringUtils.isNotEmpty(uuid))
+            {
+                String tokenKey = getTokenKey(uuid);
+                return redisCache.getCacheObject(tokenKey);
+            }
+        }
+        catch (Exception e)
+        {
+        }
+        return null;
+    }
+
     /**
      * 获取请求token
      *