Просмотр исходного кода

服务号判断加微人数加静默授权

吴树波 3 дней назад
Родитель
Сommit
07c1f38e6a
76 измененных файлов с 5213 добавлено и 0 удалено
  1. 6 0
      fs-mp-app/Dockerfile
  2. 197 0
      fs-mp-app/deploy/index.html
  3. 164 0
      fs-mp-app/pom.xml
  4. 14 0
      fs-mp-app/src/main/java/com/fs/FSServletInitializer.java
  5. 41 0
      fs-mp-app/src/main/java/com/fs/FsUserAppApplication.java
  6. 12 0
      fs-mp-app/src/main/java/com/fs/app/annotation/Login.java
  7. 15 0
      fs-mp-app/src/main/java/com/fs/app/annotation/LoginUser.java
  8. 18 0
      fs-mp-app/src/main/java/com/fs/app/annotation/UserOperationLog.java
  9. 31 0
      fs-mp-app/src/main/java/com/fs/app/config/WebMvcConfig.java
  10. 51 0
      fs-mp-app/src/main/java/com/fs/app/exception/FSException.java
  11. 102 0
      fs-mp-app/src/main/java/com/fs/app/exception/FSExceptionHandler.java
  12. 68 0
      fs-mp-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  13. 31 0
      fs-mp-app/src/main/java/com/fs/app/resolver/LoginUserHandlerMethodArgumentResolver.java
  14. 88 0
      fs-mp-app/src/main/java/com/fs/app/utils/JwtUtils.java
  15. 182 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  16. 73 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  17. 245 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/LogAspect.java
  18. 102 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  19. 342 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java
  20. 58 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java
  21. 113 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java
  22. 13 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java
  23. 24 0
      fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java
  24. 31 0
      fs-mp-app/src/main/java/com/fs/framework/config/ApplicationConfig.java
  25. 85 0
      fs-mp-app/src/main/java/com/fs/framework/config/CaptchaConfig.java
  26. 99 0
      fs-mp-app/src/main/java/com/fs/framework/config/DataSourceConfig.java
  27. 123 0
      fs-mp-app/src/main/java/com/fs/framework/config/DruidConfig.java
  28. 72 0
      fs-mp-app/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  29. 59 0
      fs-mp-app/src/main/java/com/fs/framework/config/FilterConfig.java
  30. 76 0
      fs-mp-app/src/main/java/com/fs/framework/config/KaptchaTextCreator.java
  31. 151 0
      fs-mp-app/src/main/java/com/fs/framework/config/MyBatisConfig.java
  32. 179 0
      fs-mp-app/src/main/java/com/fs/framework/config/RedisConfig.java
  33. 67 0
      fs-mp-app/src/main/java/com/fs/framework/config/ResourcesConfig.java
  34. 43 0
      fs-mp-app/src/main/java/com/fs/framework/config/SecurityConfig.java
  35. 33 0
      fs-mp-app/src/main/java/com/fs/framework/config/ServerConfig.java
  36. 125 0
      fs-mp-app/src/main/java/com/fs/framework/config/SwaggerConfig.java
  37. 65 0
      fs-mp-app/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  38. 77 0
      fs-mp-app/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  39. 27 0
      fs-mp-app/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  40. 44 0
      fs-mp-app/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  41. 56 0
      fs-mp-app/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  42. 126 0
      fs-mp-app/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  43. 56 0
      fs-mp-app/src/main/java/com/fs/framework/manager/AsyncManager.java
  44. 40 0
      fs-mp-app/src/main/java/com/fs/framework/manager/ShutdownManager.java
  45. 103 0
      fs-mp-app/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java
  46. 50 0
      fs-mp-app/src/main/java/com/fs/framework/security/SecurityUtils.java
  47. 23 0
      fs-mp-app/src/main/java/com/fs/mp/config/MpSilentAuthProperties.java
  48. 60 0
      fs-mp-app/src/main/java/com/fs/mp/config/MpWxMpConfiguration.java
  49. 27 0
      fs-mp-app/src/main/java/com/fs/mp/config/MpWxProperties.java
  50. 217 0
      fs-mp-app/src/main/java/com/fs/mp/controller/MpSilentAuthController.java
  51. 19 0
      fs-mp-app/src/main/java/com/fs/mp/param/MpSilentResolveParam.java
  52. 16 0
      fs-mp-app/src/main/java/com/fs/mp/param/MpSilentUserInfoParam.java
  53. 16 0
      fs-mp-app/src/main/java/com/fs/mp/service/MpSilentAuthService.java
  54. 90 0
      fs-mp-app/src/main/java/com/fs/mp/service/impl/MpSilentAuthServiceImpl.java
  55. 31 0
      fs-mp-app/src/main/java/com/fs/mp/vo/MpSilentAuthResult.java
  56. 1 0
      fs-mp-app/src/main/resources/META-INF/spring-devtools.properties
  57. 2 0
      fs-mp-app/src/main/resources/banner.txt
  58. BIN
      fs-mp-app/src/main/resources/fx-old.jpg
  59. BIN
      fs-mp-app/src/main/resources/fx.jpg
  60. 37 0
      fs-mp-app/src/main/resources/i18n/messages.properties
  61. 93 0
      fs-mp-app/src/main/resources/logback.xml
  62. 20 0
      fs-mp-app/src/main/resources/mybatis/mybatis-config.xml
  63. 0 0
      fs-mp-app/src/main/resources/qr.jpg
  64. BIN
      fs-mp-app/src/main/resources/simsunb.ttf
  65. 197 0
      fs-mp-app/src/main/resources/static/index.html
  66. 21 0
      fs-mp-app/src/main/resources/templates/commonAgreement.html
  67. 21 0
      fs-mp-app/src/main/resources/templates/healthCustomerService.html
  68. 21 0
      fs-mp-app/src/main/resources/templates/privacyPolicy.html
  69. 21 0
      fs-mp-app/src/main/resources/templates/userAgreement.html
  70. 21 0
      fs-mp-app/src/main/resources/templates/userRemoveService.html
  71. 21 0
      fs-mp-app/src/main/resources/templates/vipAutomaticService.html
  72. 21 0
      fs-mp-app/src/main/resources/templates/vipService.html
  73. 326 0
      fs-mp-app/src/test/java/com/fs/app/course/RedPacketBalanceZeroTest.java
  74. 105 0
      fs-mp-app/src/test/java/com/fs/test/StockDeductTest.java
  75. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  76. 6 0
      pom.xml

+ 6 - 0
fs-mp-app/Dockerfile

@@ -0,0 +1,6 @@
+FROM openjdk:8-jre
+# java版本,最好使用openjdk,而不是类似于Java:1.8
+COPY ./target/fs-user-app.jar fs-user-app.jar
+# 向外暴露的接口,最好与项目yml文件中的端口一致
+ENTRYPOINT ["java","-jar","fs-user-app.jar"]
+# 执行启动命令java -jar

+ 197 - 0
fs-mp-app/deploy/index.html

@@ -0,0 +1,197 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+  <title>微信授权</title>
+  <style>
+    body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f5f5; color: #333; }
+    .wrap { max-width: 480px; margin: 80px auto; padding: 24px; text-align: center; }
+    .tip { font-size: 15px; line-height: 1.6; color: #666; }
+    .err { color: #333; margin-top: 12px; word-break: break-all; }
+    .fail { color: #c00; }
+  </style>
+</head>
+<body>
+  <div class="wrap">
+    <p class="tip" id="tip">正在授权,请稍候…</p>
+    <p class="err" id="err"></p>
+  </div>
+  <script>
+    /**
+     * 部署说明:
+     * 1) 同源部署:API_BASE 留空
+     * 2) Nginx 静态 + 独立 API:API_BASE 填后端地址
+     * 3) 公众号后台「网页授权域名」= 本页域名
+     * 4) 已有 openId 时也会调 /mp/silent/userInfo 刷新拉黑/加销售信息
+     */
+    var API_BASE = 'https://wx.jy.cc/apis';
+    var APP_ID = 'wxe704b34112659eb1';
+    var OPENID_KEY = 'mp_silent_openid';
+
+    function $(id) { return document.getElementById(id); }
+    function qs(name) {
+      var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
+      return m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : '';
+    }
+    function isWeChat() {
+      return /MicroMessenger/i.test(navigator.userAgent || '');
+    }
+    function currentPageUrlWithoutQuery() {
+      return location.origin + location.pathname;
+    }
+    function apiUrl(path) {
+      var base = (API_BASE || '').replace(/\/$/, '');
+      return base + path;
+    }
+    function normalizePath(pathname) {
+      if (!pathname || pathname === '/') return '/';
+      return pathname.replace(/\/$/, '');
+    }
+    function isSameAuthPage(url) {
+      if (!url) return true;
+      try {
+        var u = new URL(url, location.href);
+        return u.origin === location.origin
+            && normalizePath(u.pathname) === normalizePath(location.pathname);
+      } catch (e) {
+        return false;
+      }
+    }
+    function clearCodeFromUrl() {
+      if (window.history && history.replaceState) {
+        history.replaceState(null, '', location.pathname + (location.hash || ''));
+      }
+    }
+    function showError(msg) {
+      $('tip').textContent = '授权失败';
+      $('err').className = 'err fail';
+      $('err').textContent = msg || '未知错误';
+    }
+    function finishOk(openId, jumpUrl, tip, blacklisted) {
+      clearCodeFromUrl();
+      if (openId) {
+        try { sessionStorage.setItem(OPENID_KEY, openId); } catch (e) {}
+      }
+      var url = (jumpUrl || '').replace(/^\s+|\s+$/g, '');
+      if (url && !isSameAuthPage(url)) {
+        $('tip').textContent = '授权成功,正在跳转…';
+        $('err').textContent = '';
+        location.replace(url);
+        return;
+      }
+      $('tip').textContent = tip || '授权成功';
+      $('err').className = blacklisted ? 'err fail' : 'err';
+      $('err').textContent = '';
+    }
+    function applyBizRes(res) {
+      if (!res || res.code !== 200 || !res.openId) {
+        showError((res && res.msg) || '获取用户信息失败');
+        return;
+      }
+      finishOk(res.openId, res.url || '', res.tip || '', !!res.blacklisted);
+    }
+    /** 已有 openId:每次进入都调后端刷新业务信息 */
+    function loadUserInfo(openId, state) {
+      $('tip').textContent = '正在获取信息…';
+      postJson(apiUrl('/mp/silent/userInfo'), {
+        openId: openId,
+        state: state || undefined
+      }).then(applyBizRes).catch(function (e) {
+        showError('网络异常:' + (e && e.message ? e.message : e));
+      });
+    }
+    function getJson(url) {
+      return fetch(url, { method: 'GET', credentials: 'omit' }).then(function (res) {
+        return res.json();
+      });
+    }
+    function postJson(url, body) {
+      return fetch(url, {
+        method: 'POST',
+        credentials: 'omit',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body || {})
+      }).then(function (res) {
+        return res.json();
+      });
+    }
+
+    function start() {
+      if (!isWeChat()) {
+        showError('请在微信中打开本页面');
+        return;
+      }
+
+      var appId = qs('appId') || APP_ID;
+      var state = qs('state') || '';
+      var code = qs('code');
+
+      if (!code) {
+        var cachedOpenId = '';
+        try { cachedOpenId = sessionStorage.getItem(OPENID_KEY) || ''; } catch (e) {}
+        // 已有 openId 且非强制重新授权:仍调接口拿拉黑/加销售信息
+        if (cachedOpenId && qs('force') !== '1') {
+          loadUserInfo(cachedOpenId, state);
+          return;
+        }
+
+        var redirectUri = currentPageUrlWithoutQuery();
+        var authQuery = 'redirectUri=' + encodeURIComponent(redirectUri);
+        if (appId) authQuery += '&appId=' + encodeURIComponent(appId);
+        if (state) authQuery += '&state=' + encodeURIComponent(state);
+
+        getJson(apiUrl('/mp/silent/authUrl?' + authQuery)).then(function (res) {
+          if (!res || res.code !== 200 || !res.oauthUrl) {
+            showError((res && res.msg) || '获取授权链接失败');
+            return;
+          }
+          location.replace(res.oauthUrl);
+        }).catch(function (e) {
+          showError('网络异常:' + (e && e.message ? e.message : e));
+        });
+        return;
+      }
+
+      // 同一 code 只换一次票;已换过则用 openId 再拉业务信息
+      var codeLockKey = 'mp_silent_code_' + code;
+      try {
+        if (sessionStorage.getItem(codeLockKey) === 'done') {
+          var doneOpenId = '';
+          try { doneOpenId = sessionStorage.getItem(OPENID_KEY) || ''; } catch (e) {}
+          if (doneOpenId) {
+            loadUserInfo(doneOpenId, qs('state') || state);
+          } else {
+            showError('授权状态异常,请刷新重试');
+          }
+          return;
+        }
+        if (sessionStorage.getItem(codeLockKey) === 'pending') {
+          return;
+        }
+        sessionStorage.setItem(codeLockKey, 'pending');
+      } catch (e) {}
+
+      var resolveState = qs('state') || state;
+      postJson(apiUrl('/mp/silent/resolve'), {
+        code: code,
+        appId: appId || undefined,
+        state: resolveState || undefined
+      }).then(function (res) {
+        if (!res || res.code !== 200 || !res.openId) {
+          try { sessionStorage.removeItem(codeLockKey); } catch (e) {}
+          showError((res && res.msg) || '获取 openId 失败');
+          return;
+        }
+        try { sessionStorage.setItem(codeLockKey, 'done'); } catch (e) {}
+        applyBizRes(res);
+      }).catch(function (e) {
+        try { sessionStorage.removeItem(codeLockKey); } catch (e2) {}
+        showError('网络异常:' + (e && e.message ? e.message : e));
+      });
+    }
+
+    start();
+  </script>
+</body>
+</html>

+ 164 - 0
fs-mp-app/pom.xml

@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>fs</artifactId>
+        <groupId>com.fs</groupId>
+        <version>1.1.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>fs-mp-app</artifactId>
+    <description>
+        用户公众号
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
+        <!-- spring-boot-devtools -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-devtools</artifactId>
+            <optional>true</optional> <!-- 表示依赖不会传递 -->
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-cache</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <!-- swagger2-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+
+        <!-- swagger2-UI-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.xiaoymin</groupId>
+            <artifactId>swagger-bootstrap-ui</artifactId>
+            <version>1.9.3</version>
+        </dependency>
+
+
+        <!-- Mysql驱动包 -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+
+        <!-- SpringBoot Web容器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- SpringBoot 拦截器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <!-- 阿里数据库连接池 -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-thymeleaf</artifactId>
+        </dependency>
+
+        <!-- 验证码 -->
+        <dependency>
+            <groupId>com.github.penggle</groupId>
+            <artifactId>kaptcha</artifactId>
+            <exclusions>
+                <exclusion>
+                    <artifactId>javax.servlet-api</artifactId>
+                    <groupId>javax.servlet</groupId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <!-- 获取系统信息 -->
+        <dependency>
+            <groupId>com.github.oshi</groupId>
+            <artifactId>oshi-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.rocketmq</groupId>
+            <artifactId>rocketmq-spring-boot-starter</artifactId>
+            <version>2.2.3</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <fork>true</fork> <!-- 如果没有该配置,devtools不会生效 -->
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+                <version>3.1.0</version>
+                <configuration>
+                    <failOnMissingWebXml>false</failOnMissingWebXml>
+                    <warName>${project.artifactId}</warName>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <compilerArgs>
+                        <arg>-parameters</arg>
+                    </compilerArgs>
+                    <source>8</source>
+                    <target>8</target>
+                </configuration>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+
+</project>

+ 14 - 0
fs-mp-app/src/main/java/com/fs/FSServletInitializer.java

@@ -0,0 +1,14 @@
+package com.fs;
+
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+
+
+public class FSServletInitializer extends SpringBootServletInitializer
+{
+    @Override
+    protected SpringApplicationBuilder configure(SpringApplicationBuilder application)
+    {
+        return application.sources(FsUserAppApplication.class);
+    }
+}

+ 41 - 0
fs-mp-app/src/main/java/com/fs/FsUserAppApplication.java

@@ -0,0 +1,41 @@
+package com.fs;
+
+import com.fs.core.config.WxMpConfiguration;
+import com.github.xiaoymin.swaggerbootstrapui.annotations.EnableSwaggerBootstrapUI;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * 启动程序(精简为服务号静默授权)
+ * <p>
+ * 排除 fs-common 中可能存在的 RedisConfig(打包 jar 版本),以及 fs-service 的 WxMpConfiguration;
+ * 改用本模块 framework Redis 与 mp-silent 公众号配置。
+ * </p>
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@ComponentScan(
+        basePackages = "com.fs",
+        excludeFilters = {
+                @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.fs\\.common\\.config\\.RedisConfig"),
+                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WxMpConfiguration.class)
+        }
+)
+@EnableTransactionManagement
+@EnableSwaggerBootstrapUI
+@EnableScheduling
+@EnableAsync
+@EnableCaching
+public class FsUserAppApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(FsUserAppApplication.class, args);
+        System.out.println("fs-mp-app 启动成功");
+    }
+}

+ 12 - 0
fs-mp-app/src/main/java/com/fs/app/annotation/Login.java

@@ -0,0 +1,12 @@
+package com.fs.app.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * app登录效验
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface Login {
+}

+ 15 - 0
fs-mp-app/src/main/java/com/fs/app/annotation/LoginUser.java

@@ -0,0 +1,15 @@
+package com.fs.app.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 登录用户信息
+ */
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LoginUser {
+
+}

+ 18 - 0
fs-mp-app/src/main/java/com/fs/app/annotation/UserOperationLog.java

@@ -0,0 +1,18 @@
+package com.fs.app.annotation;
+
+
+import com.fs.his.enums.FsUserOperationEnum;
+
+import java.lang.annotation.*;
+
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface UserOperationLog {
+    /** 操作类型,比如 "新增", "删除", "更新", "查询" */
+    FsUserOperationEnum operationType();
+
+    /** 操作详情(可选) */
+    String detail() default "";
+}

+ 31 - 0
fs-mp-app/src/main/java/com/fs/app/config/WebMvcConfig.java

@@ -0,0 +1,31 @@
+package com.fs.app.config;
+
+import com.fs.app.interceptor.AuthorizationInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * MVC 配置
+ * <p>
+ * 公众号静默授权(/mp/silent/**)不做登录 Token 校验,显式排除以免后续扩大拦截范围时误伤。
+ * </p>
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private AuthorizationInterceptor authorizationInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(authorizationInterceptor)
+                .addPathPatterns("/app/**", "/store/app/**")
+                .excludePathPatterns(
+                        "/mp/silent/**",
+                        "/",
+                        "/index.html"
+                );
+    }
+}

+ 51 - 0
fs-mp-app/src/main/java/com/fs/app/exception/FSException.java

@@ -0,0 +1,51 @@
+package com.fs.app.exception;
+
+/**
+ * 自定义异常
+ */
+public class FSException extends RuntimeException {
+	private static final long serialVersionUID = 1L;
+	
+    private String msg;
+    private int code = 500;
+    
+    public FSException(String msg) {
+		super(msg);
+		this.msg = msg;
+	}
+	
+	public FSException(String msg, Throwable e) {
+		super(msg, e);
+		this.msg = msg;
+	}
+	
+	public FSException(String msg, int code) {
+		super(msg);
+		this.msg = msg;
+		this.code = code;
+	}
+	
+	public FSException(String msg, int code, Throwable e) {
+		super(msg, e);
+		this.msg = msg;
+		this.code = code;
+	}
+
+	public String getMsg() {
+		return msg;
+	}
+
+	public void setMsg(String msg) {
+		this.msg = msg;
+	}
+
+	public int getCode() {
+		return code;
+	}
+
+	public void setCode(int code) {
+		this.code = code;
+	}
+	
+	
+}

+ 102 - 0
fs-mp-app/src/main/java/com/fs/app/exception/FSExceptionHandler.java

@@ -0,0 +1,102 @@
+package com.fs.app.exception;
+
+
+
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.validation.BindException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+import javax.servlet.http.HttpServletRequest;
+
+
+/**
+ * 异常处理器
+ */
+@RestControllerAdvice
+public class FSExceptionHandler {
+	private Logger logger = LoggerFactory.getLogger(getClass());
+
+	/**
+	 * 处理自定义异常
+	 */
+	@ExceptionHandler(FSException.class)
+	public R handleRRException(FSException e){
+		R r = new R();
+		r.put("code", e.getCode());
+		r.put("msg", e.getMessage());
+
+		return r;
+	}
+
+	@ExceptionHandler(NoHandlerFoundException.class)
+	public R handlerNoFoundException(Exception e) {
+		logger.error(e.getMessage(), e);
+		return R.error(404, "路径不存在,请检查路径是否正确");
+	}
+
+	@ExceptionHandler(DuplicateKeyException.class)
+	public R handleDuplicateKeyException(DuplicateKeyException e){
+		logger.error(e.getMessage(), e);
+		return R.error("数据库中已存在该记录");
+	}
+	@ExceptionHandler(MethodArgumentTypeMismatchException.class)
+	public R handleNumberFormatException(MethodArgumentTypeMismatchException e){
+		return R.error("数值类型错误");
+	}
+
+
+	@ExceptionHandler(AccessDeniedException.class)
+	public R handleAccessDeniedException(AccessDeniedException e){
+		logger.error(e.getMessage(), e);
+		return R.error("没有权限");
+	}
+
+	@ExceptionHandler(BindException.class)
+	public R bindExceptionHandler(BindException e) {
+		FieldError error = e.getFieldError();
+		String message = String.format("%s",  error.getDefaultMessage());
+		return R.error(message);
+	}
+
+	@ExceptionHandler(MethodArgumentNotValidException.class)
+	public R exceptionHandler(MethodArgumentNotValidException e) {
+		FieldError error = e.getBindingResult().getFieldError();
+		String message = String.format("%s",  error.getDefaultMessage());
+		return R.error(message);
+	}
+	@ExceptionHandler(CustomException.class)
+	public R handleException(CustomException e){
+		return R.error(e.getMessage());
+	}
+	@ExceptionHandler(Exception.class)
+	public R handleException(Exception e){
+		logger.error(e.getMessage(), e);
+		return R.error();
+	}
+
+	/**
+	 * 业务异常
+	 */
+	@ExceptionHandler(ServiceException.class)
+	public R handleServiceException(ServiceException e)
+	{
+		logger.error(e.getMessage(), e);
+		Integer code = e.getCode();
+		return StringUtils.isNotNull(code) ? R.error(code, e.getMessage()) : R.error(e.getMessage());
+	}
+
+}

+ 68 - 0
fs-mp-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -0,0 +1,68 @@
+package com.fs.app.interceptor;
+
+
+import com.fs.app.annotation.Login;
+import com.fs.app.exception.FSException;
+import com.fs.app.utils.JwtUtils;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import io.jsonwebtoken.Claims;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+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;
+
+/**
+ * 权限(Token)验证
+ */
+@Component
+public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
+    @Autowired
+    private JwtUtils jwtUtils;
+    @Autowired
+    RedisCache redisCache;
+    public static final String USER_KEY = "userId";
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        Login annotation;
+        if(handler instanceof HandlerMethod) {
+            annotation = ((HandlerMethod) handler).getMethodAnnotation(Login.class);
+        }else{
+            return true;
+        }
+
+        if(annotation == null){
+            return true;
+        }
+
+        //获取用户凭证
+        String token = request.getHeader(jwtUtils.getHeader());
+        if(StringUtils.isBlank(token)){
+            token = request.getParameter(jwtUtils.getHeader());
+        }
+
+        //凭证为空
+        if(StringUtils.isBlank(token)){
+            throw new FSException(jwtUtils.getHeader() + "不能为空", HttpStatus.UNAUTHORIZED.value());
+        }
+
+        Claims claims = jwtUtils.getClaimByToken(token);
+        if(claims == null || jwtUtils.isTokenExpired(claims.getExpiration())){
+            throw new FSException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
+        }
+        //查询用户的TOKEN是否和REDIS中的一样
+//        String redisToken=redisCache.getCacheObject("token:"+ Long.parseLong(claims.getSubject()));
+//        if(redisToken==null||!redisToken.equals(token)){
+//            throw new FSException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
+//        }
+        //设置userId到request里,后续根据userId,获取用户信息
+        request.setAttribute(USER_KEY, Long.parseLong(claims.getSubject()));
+
+        return true;
+    }
+}

+ 31 - 0
fs-mp-app/src/main/java/com/fs/app/resolver/LoginUserHandlerMethodArgumentResolver.java

@@ -0,0 +1,31 @@
+package com.fs.app.resolver;
+
+
+/**
+ * @LoginUser注解的方法参数,注入当前登录用户
+ */
+//@Component
+//public class LoginUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
+//    @Autowired
+//    private UserService userService;
+//
+//    @Override
+//    public boolean supportsParameter(MethodParameter parameter) {
+//        return parameter.getParameterType().isAssignableFrom(UserEntity.class) && parameter.hasParameterAnnotation(LoginUser.class);
+//    }
+//
+//    @Override
+//    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
+//                                  NativeWebRequest request, WebDataBinderFactory factory) throws Exception {
+//        //获取用户ID
+//        Object object = request.getAttribute(AuthorizationInterceptor.USER_KEY, RequestAttributes.SCOPE_REQUEST);
+//        if(object == null){
+//            return null;
+//        }
+//
+//        //获取用户信息
+//        UserEntity user = userService.getById((Long)object);
+//
+//        return user;
+//    }
+//}

+ 88 - 0
fs-mp-app/src/main/java/com/fs/app/utils/JwtUtils.java

@@ -0,0 +1,88 @@
+package com.fs.app.utils;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+/**
+ * jwt工具类
+
+ */
+@ConfigurationProperties(prefix = "fs.jwt")
+@Component
+public class JwtUtils {
+    private Logger logger = LoggerFactory.getLogger(getClass());
+
+
+    private String secret;
+    private long expire;
+    private String header;
+
+    /**
+     * 生成jwt token
+     */
+    public String generateToken(long userId) {
+        Date nowDate = new Date();
+        //过期时间
+        Date expireDate = new Date(nowDate.getTime() + expire * 1000);
+//        System.out.println("==============================="+secret);
+//        logger.info("secret:{}",secret);
+        return Jwts.builder()
+                .setHeaderParam("typ", "JWT")
+                .setSubject(userId+"")
+                .setIssuedAt(nowDate)
+                .setExpiration(expireDate)
+                .signWith(SignatureAlgorithm.HS512, secret)
+                .compact();
+    }
+
+    public Claims getClaimByToken(String token) {
+        try {
+            return Jwts.parser()
+                    .setSigningKey(secret)
+                    .parseClaimsJws(token)
+                    .getBody();
+        }catch (Exception e){
+            logger.error("验证令牌错误", e);
+            return null;
+        }
+    }
+
+    /**
+     * token是否过期
+     * @return  true:过期
+     */
+    public boolean isTokenExpired(Date expiration) {
+        return expiration.before(new Date());
+    }
+
+    public String getSecret() {
+        return secret;
+    }
+
+    public void setSecret(String secret) {
+        this.secret = secret;
+    }
+
+    public long getExpire() {
+        return expire;
+    }
+
+    public void setExpire(long expire) {
+        this.expire = expire;
+    }
+
+    public String getHeader() {
+        return header;
+    }
+
+    public void setHeader(String header) {
+        this.header = header;
+    }
+}

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

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

+ 73 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java

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

+ 245 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -0,0 +1,245 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.system.domain.SysOperLog;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * 操作日志记录处理
+ * 
+
+ */
+@Aspect
+@Component
+public class LogAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.Log)")
+    public void logPointCut()
+    {
+    }
+
+    /**
+     * 处理完请求后执行
+     *
+     * @param joinPoint 切点
+     */
+    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
+    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult)
+    {
+        handleLog(joinPoint, null, jsonResult);
+    }
+
+    /**
+     * 拦截异常操作
+     * 
+     * @param joinPoint 切点
+     * @param e 异常
+     */
+    @AfterThrowing(value = "logPointCut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e)
+    {
+        handleLog(joinPoint, e, null);
+    }
+
+    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult)
+    {
+        try
+        {
+            // 获得注解
+            Log controllerLog = getAnnotationLog(joinPoint);
+            if (controllerLog == null)
+            {
+                return;
+            }
+
+            // 获取当前的用户
+            LoginUser loginUser = SecurityUtils.getLoginUser();
+
+            // *========数据库日志=========*//
+            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);
+            // 保存数据库
+            AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
+        }
+        catch (Exception exp)
+        {
+            // 记录本地异常日志
+            log.error("==前置通知异常==");
+            log.error("异常信息:{}", exp.getMessage());
+            exp.printStackTrace();
+        }
+    }
+
+    /**
+     * 获取注解中对方法的描述信息 用于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;
+    }
+}

+ 102 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java

@@ -0,0 +1,102 @@
+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 msg = rateLimiter.msg();
+
+        String combineKey = getCombineKey(rateLimiter, point);
+        List<Object> keys = Collections.singletonList(combineKey);
+        try {
+            Long number = redisTemplate.execute(limitScript, keys, count, time);
+            if (number.intValue() > count) {
+                throw new ServiceException(msg);
+            }
+            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) {
+        StringBuilder stringBuffer = new StringBuilder(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();
+    }
+}

+ 342 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

@@ -0,0 +1,342 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.druid.support.json.JSONUtils;
+import com.alibaba.fastjson.JSON;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fs.app.annotation.UserOperationLog;
+import com.fs.app.utils.JwtUtils;
+import com.fs.common.core.domain.R;
+import com.fs.common.param.LoginMaWxParam;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.FsCourseQuestionBank;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.course.param.FsCourseQuestionAnswerUParam;
+import com.fs.course.param.FsCourseSendRewardUParam;
+import com.fs.course.param.newfs.FsUserCourseVideoLinkParam;
+import com.fs.course.vo.FsUserCourseVO;
+import com.fs.course.vo.FsUserCourseVideoH5DVO;
+import com.fs.course.vo.newfs.FsUserCourseVideoPageListVO;
+import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserOperationLog;
+import com.fs.his.enums.FsUserOperationEnum;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.mapper.FsUserOperationLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.*;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Aspect
+@Component
+public class UserOperationLogAspect {
+
+    @Autowired
+    private FsUserOperationLogMapper logMapper;
+    @Autowired
+    private FsUserMapper userMapper;
+    @Autowired
+    private FsUserCourseVideoMapper userCourseVideoMapper;
+    @Autowired
+    JwtUtils jwtUtils;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    private static final ThreadLocal<FsUserOperationLog> LOG_HOLDER = new ThreadLocal<>();
+
+    @Pointcut("@annotation(com.fs.app.annotation.UserOperationLog)")
+    public void logPointcut() {}
+
+
+    @AfterReturning(pointcut = "logPointcut()", returning = "result")
+    public void doAfterReturning(JoinPoint joinPoint, Object result) {
+        handleLog(joinPoint, null);
+    }
+
+    @AfterThrowing(pointcut = "logPointcut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
+        handleLog(joinPoint, e);
+    }
+
+    @AfterReturning(pointcut = "@annotation(userOpLog)", returning = "result")
+    public void afterReturning(JoinPoint joinPoint,UserOperationLog userOpLog, Object result) {
+        FsUserOperationLog operationLog = LOG_HOLDER.get();
+        if (operationLog == null || StringUtils.isBlank(operationLog.getOperationType())) return;
+
+        try {
+            if (operationLog.getUserId() == null){
+                FsUser fsUser = extractUserFromResult(result);
+                if (fsUser != null && operationLog.getUserId() == null) {
+                    operationLog.setUserId(fsUser.getUserId());
+                    StringBuilder details = getDetail(
+                            userOpLog,
+                            FsUserOperationEnum.toType(operationLog.getOperationType()),
+                            fsUser,
+                            joinPoint.getArgs());
+                    operationLog.setDetails(details.toString());
+                }
+            }
+
+
+            if (operationLog.getUserId() != null) {
+                if ("学习课程".equals(operationLog.getOperationType())){
+                    if (StringUtils.isBlank(operationLog.getDetails())){
+                        operationLog = extractCourseFromResult(result);
+
+                    }
+                }
+                logMapper.insertFsUserOperationLog(operationLog);
+            }
+        } catch (Exception ex) {
+            log.error("操作日志插入异常", ex);
+        } finally {
+            LOG_HOLDER.remove();
+        }
+    }
+
+    private void handleLog(JoinPoint joinPoint, Exception e) {
+        FsUserOperationLog operationLog = new FsUserOperationLog();
+        LOG_HOLDER.set(operationLog);
+        try {
+            //时间
+            operationLog.setCreateTime(DateUtils.getNowDate());
+            //操作类型
+            Method method = getMethod(joinPoint);
+            UserOperationLog annotation = method.getAnnotation(UserOperationLog.class);
+            if (annotation == null) return;
+            operationLog.setOperationType(annotation.operationType().getLabel());
+
+            //用户
+            Long userId =null;
+            try {
+                String appToken = ServletUtils.getRequest().getHeader("APPToken");
+                if (StringUtils.isNotBlank(appToken)){
+                    userId = Long.valueOf(jwtUtils.getClaimByToken(appToken).getSubject().toString());
+                }
+            } catch (Exception ie){
+                log.info("获取用户id失败");
+            }
+            if (userId == null) {
+                LOG_HOLDER.set(operationLog);
+                return;
+            }
+
+            FsUser fsUser = userMapper.selectFsUserByUserId(userId);
+            if (fsUser == null) {
+                LOG_HOLDER.set(operationLog);
+                return;
+            }
+            operationLog.setUserId(userId);
+
+
+            StringBuilder details = getDetail(annotation, annotation.operationType(), fsUser,joinPoint.getArgs());
+            operationLog.setDetails(details.toString());
+
+            if (e != null) {
+                details.append(",异常: ").append(e.getMessage());
+            }
+
+
+            LOG_HOLDER.set(operationLog);
+        } catch (Exception ex) {
+            log.error("记录操作日志异常", ex);
+        }
+    }
+
+    private StringBuilder getDetail(UserOperationLog annotation, FsUserOperationEnum opType, FsUser fsUser,Object[] args) {
+        StringBuilder details = new StringBuilder();
+        String nickName = fsUser.getNickName();
+        if (StringUtils.isBlank(nickName)){
+            nickName = fsUser.getNickname();
+        }
+        if (annotation.detail() == null || annotation.detail().isEmpty()) {
+            switch (opType.getValue()) {
+                case 1: // 小程序登录
+                    details.append(nickName)
+                            .append("在")
+                            .append(DateUtils.getTime())
+                            .append("登录了小程序");
+                    break;
+                case 2: // h5登录
+                    details.append(nickName)
+                            .append("在")
+                            .append(DateUtils.getTime())
+                            .append("登录了h5");
+                    break;
+                case 3: // 成为会员
+                    details.append(nickName)
+                            .append("在")
+                            .append(DateUtils.getTime())
+                            .append("注册成为会员");
+                    break;
+                case 4: // 判断是否成为会员
+                    break;
+                case 5: // 学习课程
+                    String courseInfo = extractCourseInfo(args);
+                    if (courseInfo != null) {
+                        details.append(nickName)
+                                .append("在")
+                                .append(DateUtils.getTime())
+                                .append("观看 ").append(courseInfo);
+                    }
+                    break;
+                case 6: // 答题
+                    String answerCourse = answerCourse(args);
+                    details.append(nickName)
+                            .append("在 ")
+                            .append(DateUtils.getTime())
+                            .append("\n")
+                            .append(answerCourse);
+                    break;
+                case 7: // 发送奖励
+                    String sendReward = sendReward(args);
+
+                    details.append(nickName)
+                            .append("在 ")
+                            .append(DateUtils.getTime())
+                            .append("领取 红包/积分 奖励")
+                            .append("\n")
+                            .append(sendReward);
+                    break;
+                default:
+                    details.append(opType.getLabel());
+                    break;
+            }
+        } else {
+            details.append(annotation.detail());
+        }
+        return details;
+    }
+
+    private Method getMethod(JoinPoint joinPoint) throws NoSuchMethodException {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        return joinPoint.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
+    }
+
+
+    private FsUser extractUserFromResult(Object result) {
+        if (result instanceof R) {
+            R r = (R) result;
+            Object user = r.get("user");
+            if (user instanceof FsUser) {
+                return (FsUser) user;
+            }
+        }
+        return null;
+    }
+    private FsUserOperationLog extractCourseFromResult(Object result) {
+        if (result instanceof R) {
+            R r = (R) result;
+            Object course = r.get("course");
+            if (course instanceof FsUserCourseVideoH5DVO) {
+                FsUserOperationLog operationLog = LOG_HOLDER.get();
+                FsUser fsUser = userMapper.selectFsUserByUserId(operationLog.getUserId());
+                FsUserCourseVideoH5DVO vo = (FsUserCourseVideoH5DVO) course;
+                operationLog.setParam(JSON.toJSONString(vo));
+                StringBuilder details = new StringBuilder();
+                String nickName = fsUser.getNickName();
+                if (StringUtils.isBlank(nickName)){
+                    nickName = fsUser.getNickname();
+                }
+                details.append(nickName)
+                        .append("在")
+                        .append(DateUtils.getTime())
+                        .append("观看 课程:").append(vo.getCourseName()).append(" 的 ").append(vo.getTitle()).append(" 小节");
+                operationLog.setDetails(details.toString());
+                return operationLog;
+            }
+        }
+        return null;
+    }
+    private String extractCourseInfo(Object[] args) {
+        if (args == null) return "未知课程";
+
+        for (Object arg : args) {
+            if (arg instanceof FsUserCourseVideoLinkParam) {
+                FsUserCourseVideoLinkParam param = (FsUserCourseVideoLinkParam) arg;
+                if (param.getVideoId() != null && param.getPeriodId() != null) {
+                    FsUserCourseVO vo = userCourseVideoMapper.selectFsUserCourseVideoVoByVideoId(param.getVideoId(), param.getPeriodId());
+                    if (vo != null){
+                        FsUserOperationLog operationLog = LOG_HOLDER.get();
+                        operationLog.setParam(JSON.toJSONString(vo));
+                        LOG_HOLDER.set(operationLog);
+                        return "课程:"+ vo.getCourseName() + " 的 " + vo.getTitle() + " 小节";
+                    }
+                }
+            } else {
+                return null; //自动发课 接口执行完生成
+            }
+        }
+        return "未知课程";
+    }
+
+    private String answerCourse(Object[] args) {
+        if (args == null) return "未知课程";
+        for (Object arg : args) {
+            if (arg instanceof FsCourseQuestionAnswerUParam) {
+                FsCourseQuestionAnswerUParam param = (FsCourseQuestionAnswerUParam) arg;
+                Long videoId = param.getVideoId();
+                Long periodId = param.getPeriodId();
+                Long courseId = param.getCourseId();
+                FsUserCourseVO vo = null;
+                if (param.getQwExternalId() != null){ //自动看课
+                    vo = userCourseVideoMapper.selectFsUserCourseVideoVoByVideoIdAndCourdeId(videoId,courseId);
+                } else if (videoId != null && param.getPeriodId() != null) {  //手动看课
+                    vo = userCourseVideoMapper.selectFsUserCourseVideoVoByVideoId(videoId, periodId);
+                }
+                if (vo != null){
+                    FsUserOperationLog operationLog = LOG_HOLDER.get();
+                    operationLog.setParam(JSON.toJSONString(vo));
+                    LOG_HOLDER.set(operationLog);
+                    StringBuilder details =new StringBuilder("课程:"+ vo.getCourseName() + " 的 " + vo.getTitle() + " 小节 问题为:");
+                    List<FsCourseQuestionBank> questions = param.getQuestions();
+                    if (questions != null && !questions.isEmpty()) {
+                        for (int i = 0; i < questions.size(); i++) {
+                            details.append("\n").append(i+1).append(".").append(questions.get(i).getTitle());
+                            details.append(" 提交答案为:").append(questions.get(i).getAnswer());
+                        }
+                    }
+                    return details.toString();
+                }
+            }
+        }
+        return "未知课程";
+    }
+
+    private String sendReward(Object[] args) {
+        if (args == null) return "未知课程";
+        for (Object arg : args) {
+            if (arg instanceof FsCourseSendRewardUParam) {
+                FsCourseSendRewardUParam param = (FsCourseSendRewardUParam) arg;
+                Long videoId = param.getVideoId();
+                Long periodId = param.getPeriodId();
+                Long courseId = param.getCourseId();
+                FsUserCourseVO vo = null;
+                if (param.getQwExternalId() != null){ //自动看课
+                    vo = userCourseVideoMapper.selectFsUserCourseVideoVoByVideoIdAndCourdeId(videoId,courseId);
+                } else if (videoId != null && param.getPeriodId() != null) {  //手动看课
+                    vo = userCourseVideoMapper.selectFsUserCourseVideoVoByVideoId(videoId, periodId);
+                }
+                if (vo != null){
+                    FsUserOperationLog operationLog = LOG_HOLDER.get();
+                    operationLog.setParam(JSON.toJSONString(vo));
+                    LOG_HOLDER.set(operationLog);
+                    StringBuilder details =new StringBuilder();
+                    details.append("课程:"+ vo.getCourseName() + " 的 " + vo.getTitle() + " 小节");
+                    return details.toString();
+                }
+            }
+        }
+        return "未知课程";
+    }
+
+}

+ 58 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLock.java

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

+ 113 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLockAspect.java

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

+ 13 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLockConstant.java

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

+ 24 - 0
fs-mp-app/src/main/java/com/fs/framework/aspectj/lock/DistributeLockException.java

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

+ 31 - 0
fs-mp-app/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());
+    }
+}

+ 85 - 0
fs-mp-app/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;
+    }
+}

+ 99 - 0
fs-mp-app/src/main/java/com/fs/framework/config/DataSourceConfig.java

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

+ 123 - 0
fs-mp-app/src/main/java/com/fs/framework/config/DruidConfig.java

@@ -0,0 +1,123 @@
+//package com.fs.framework.config;
+//
+//import java.io.IOException;
+//import java.util.HashMap;
+//import java.util.Map;
+//import javax.servlet.Filter;
+//import javax.servlet.FilterChain;
+//import javax.servlet.ServletException;
+//import javax.servlet.ServletRequest;
+//import javax.servlet.ServletResponse;
+//import javax.sql.DataSource;
+//
+//import org.springframework.beans.factory.annotation.Qualifier;
+//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+//import org.springframework.boot.context.properties.ConfigurationProperties;
+//import org.springframework.boot.web.servlet.FilterRegistrationBean;
+//import org.springframework.context.annotation.Bean;
+//import org.springframework.context.annotation.Configuration;
+//import org.springframework.context.annotation.Primary;
+//import com.alibaba.druid.pool.DruidDataSource;
+//import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
+//import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+//import com.alibaba.druid.util.Utils;
+//import com.fs.common.enums.DataSourceType;
+//import com.fs.common.utils.spring.SpringUtils;
+//import com.fs.framework.config.properties.DruidProperties;
+//import com.fs.framework.datasource.DynamicDataSource;
+//
+///**
+// * druid 配置多数据源
+// *
+//
+// */
+//@Configuration
+//public class DruidConfig
+//{
+//    @Bean
+//    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.master")
+//    public DataSource sopDataSource() {
+//        return new DruidDataSource();
+//    }
+//
+//    @Bean
+//    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.master")
+//    public DataSource masterDataSource() {
+//        return new DruidDataSource();
+//    }
+//
+//
+//
+//    @Bean
+//    @Primary
+//    public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("sopDataSource") DataSource sopDataSource) {
+//        Map<Object, Object> targetDataSources = new HashMap<>();
+//        targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
+//        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-mp-app/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-mp-app/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;
+    }
+
+}

+ 76 - 0
fs-mp-app/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();
+    }
+}

+ 151 - 0
fs-mp-app/src/main/java/com/fs/framework/config/MyBatisConfig.java

@@ -0,0 +1,151 @@
+package com.fs.framework.config;
+
+import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
+import com.fs.common.utils.StringUtils;
+import org.apache.ibatis.io.VFS;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+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();
+    }
+}

+ 179 - 0
fs-mp-app/src/main/java/com/fs/framework/config/RedisConfig.java

@@ -0,0 +1,179 @@
+package com.fs.framework.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
+import org.springframework.cache.annotation.CachingConfigurerSupport;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.GenericToStringSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+
+/**
+ * redis配置
+ *
+
+ */
+@Configuration
+@EnableCaching
+public class RedisConfig extends CachingConfigurerSupport
+{
+
+//    @Bean
+//    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
+//        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
+//                .entryTtl(Duration.ofSeconds(30))
+//                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+//                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
+//                .disableCachingNullValues();
+//
+//        return RedisCacheManager.builder(connectionFactory)
+//                .cacheDefaults(config)
+//                .build();
+//    }
+
+    @Bean
+    @SuppressWarnings(value = { "unchecked", "rawtypes" })
+    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
+    {
+        RedisTemplate<Object, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
+
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
+        serializer.setObjectMapper(mapper);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(serializer);
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(serializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    public RedisTemplate<String, Boolean> redisTemplateForBoolean(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Boolean> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(new GenericToStringSerializer<>(Boolean.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(Boolean.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+    @Bean
+    public RedisTemplate<String, BigDecimal> redisTemplateForBigDecimal(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, BigDecimal> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+
+        // 使用GenericToStringSerializer保证BigDecimal精度不丢失
+        template.setValueSerializer(new GenericToStringSerializer<>(BigDecimal.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(BigDecimal.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    public RedisTemplate<String, Integer> redisTemplateForInteger(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Integer> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+
+        // 使用GenericToStringSerializer保证BigDecimal精度不丢失
+        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(Integer.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+    @Bean
+    @SuppressWarnings(value = { "unchecked", "rawtypes" })
+    public RedisTemplate<String, Object> redisTemplateForObject(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
+
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
+        serializer.setObjectMapper(mapper);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(serializer);
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(serializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    public DefaultRedisScript<Long> limitScript()
+    {
+        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
+        redisScript.setScriptText(limitScriptText());
+        redisScript.setResultType(Long.class);
+        return redisScript;
+    }
+
+    /**
+     * 限流脚本
+     */
+    private String limitScriptText()
+    {
+        return "local key = KEYS[1]\n" +
+                "local count = tonumber(ARGV[1])\n" +
+                "local time = tonumber(ARGV[2])\n" +
+                "local current = redis.call('get', key);\n" +
+                "if current and tonumber(current) > count then\n" +
+                "    return current;\n" +
+                "end\n" +
+                "current = redis.call('incr', key)\n" +
+                "if tonumber(current) == 1 then\n" +
+                "    redis.call('expire', key, time)\n" +
+                "end\n" +
+                "return current;";
+    }
+}

+ 67 - 0
fs-mp-app/src/main/java/com/fs/framework/config/ResourcesConfig.java

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

+ 43 - 0
fs-mp-app/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,43 @@
+package com.fs.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.BeanIds;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.web.firewall.StrictHttpFirewall;
+
+/**
+ * Spring Security 配置:公众号静默授权与静态页匿名访问,不做登录校验
+ */
+@Configuration
+@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
+public class SecurityConfig extends WebSecurityConfigurerAdapter {
+
+    @Override
+    protected void configure(HttpSecurity http) throws Exception {
+        http.cors().and()
+                .authorizeRequests()
+                // 公众号静默授权:明确免登录
+                .antMatchers("/mp/silent/**", "/", "/index.html", "/static/**").permitAll()
+                .antMatchers("/**").permitAll()
+                .anyRequest().authenticated()
+                .and()
+                .csrf().disable();
+    }
+
+    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception {
+        return super.authenticationManagerBean();
+    }
+
+    @Bean
+    public StrictHttpFirewall httpFirewall() {
+        StrictHttpFirewall firewall = new StrictHttpFirewall();
+        firewall.setAllowUrlEncodedDoubleSlash(true);
+        return firewall;
+    }
+}

+ 33 - 0
fs-mp-app/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();
+    }
+}

+ 125 - 0
fs-mp-app/src/main/java/com/fs/framework/config/SwaggerConfig.java

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

+ 65 - 0
fs-mp-app/src/main/java/com/fs/framework/config/ThreadPoolConfig.java

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

+ 77 - 0
fs-mp-app/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-mp-app/src/main/java/com/fs/framework/datasource/DynamicDataSource.java

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

+ 44 - 0
fs-mp-app/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java

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

+ 56 - 0
fs-mp-app/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-mp-app/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-mp-app/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-mp-app/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);
+        }
+    }
+}

+ 103 - 0
fs-mp-app/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java

@@ -0,0 +1,103 @@
+package com.fs.framework.manager.factory;
+
+import com.fs.common.constant.Constants;
+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.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.TimerTask;
+
+/**
+ * 异步工厂(产生任务用)
+ * 
+
+ */
+public class AsyncFactory
+{
+    private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");
+
+    /**
+     * 记录登录信息
+     * 
+     * @param username 用户名
+     * @param status 状态
+     * @param message 消息
+     * @param args 列表
+     * @return 任务task
+     */
+    public static TimerTask recordLogininfor(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);
+                // 获取客户端操作系统
+                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);
+                // 日志状态
+                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);
+            }
+        };
+    }
+
+    /**
+     * 操作日志记录
+     * 
+     * @param operLog 操作日志信息
+     * @return 任务task
+     */
+    public static TimerTask recordOper(final SysOperLog operLog)
+    {
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                // 远程查询操作地点
+                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
+                SpringUtils.getBean(ISysOperLogService.class).insertOperlog(operLog);
+            }
+        };
+    }
+}

+ 50 - 0
fs-mp-app/src/main/java/com/fs/framework/security/SecurityUtils.java

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

+ 23 - 0
fs-mp-app/src/main/java/com/fs/mp/config/MpSilentAuthProperties.java

@@ -0,0 +1,23 @@
+package com.fs.mp.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 静默授权业务配置(前缀 mp-silent,与商户 profile 的 wx.mp 互不影响)
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "mp-silent")
+public class MpSilentAuthProperties {
+
+    /** 业务默认落地页(resolve 未定制时返回) */
+    private String defaultRedirectUrl = "";
+
+    /** 允许的 redirectUri 主机名白名单,空则不校验 */
+    private List<String> allowedRedirectHosts = new ArrayList<>();
+}

+ 60 - 0
fs-mp-app/src/main/java/com/fs/mp/config/MpWxMpConfiguration.java

@@ -0,0 +1,60 @@
+package com.fs.mp.config;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
+import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 本模块独立 WxMpService:只读 mp-silent.wx.*,不使用 fs-service 的 WxMpConfiguration。
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Configuration
+@EnableConfigurationProperties(MpWxProperties.class)
+public class MpWxMpConfiguration {
+
+    private final MpWxProperties properties;
+
+    @Bean
+    @Primary
+    public WxMpService wxMpService() {
+        List<MpWxProperties.MpConfig> configs = this.properties.getConfigs();
+        if (configs == null || configs.isEmpty()) {
+            throw new IllegalStateException("请在 yml 中配置 mp-silent.wx.configs(appId/secret),与商户 profile 的 wx.mp 无关");
+        }
+        log.info("[MpWx] 使用本模块独立配置初始化公众号,数量={}", configs.size());
+        for (MpWxProperties.MpConfig cfg : configs) {
+            log.info("[MpWx] 加载公众号 appId={}, secret={}", cfg.getAppId(), maskSecret(cfg.getSecret()));
+        }
+        WxMpService service = new WxMpServiceImpl();
+        service.setMultiConfigStorages(configs.stream().map(a -> {
+            WxMpDefaultConfigImpl storage = new WxMpDefaultConfigImpl();
+            storage.setAppId(a.getAppId());
+            storage.setSecret(a.getSecret());
+            storage.setToken(a.getToken());
+            storage.setAesKey(a.getAesKey());
+            return storage;
+        }).collect(Collectors.toMap(WxMpDefaultConfigImpl::getAppId, a -> a, (o, n) -> o)));
+        log.info("[MpWx] 本模块 WxMpService 初始化完成");
+        return service;
+    }
+
+    private String maskSecret(String secret) {
+        if (secret == null || secret.isEmpty()) {
+            return "(空)";
+        }
+        if (secret.length() <= 8) {
+            return "****";
+        }
+        return secret.substring(0, 4) + "****" + secret.substring(secret.length() - 4);
+    }
+}

+ 27 - 0
fs-mp-app/src/main/java/com/fs/mp/config/MpWxProperties.java

@@ -0,0 +1,27 @@
+package com.fs.mp.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 本模块独立的服务号配置(前缀 mp-silent.wx,与 fs-service / 商户 profile 的 wx.mp 互不影响)
+ */
+@Data
+@ConfigurationProperties(prefix = "mp-silent.wx")
+public class MpWxProperties {
+
+    private boolean useRedis;
+
+    private List<MpConfig> configs = new ArrayList<>();
+
+    @Data
+    public static class MpConfig {
+        private String appId;
+        private String secret;
+        private String token;
+        private String aesKey;
+    }
+}

+ 217 - 0
fs-mp-app/src/main/java/com/fs/mp/controller/MpSilentAuthController.java

@@ -0,0 +1,217 @@
+package com.fs.mp.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.domain.R;
+import com.fs.mp.config.MpSilentAuthProperties;
+import com.fs.mp.param.MpSilentResolveParam;
+import com.fs.mp.param.MpSilentUserInfoParam;
+import com.fs.mp.service.MpSilentAuthService;
+import com.fs.mp.vo.MpSilentAuthResult;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.api.WxConsts;
+import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.mp.api.WxMpService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 服务号 H5 静默授权(snsapi_base)
+ * <p>本 Controller 全部接口匿名访问,不做登录 / Token 权限校验。</p>
+ */
+@Slf4j
+@RestController
+@RequestMapping("/mp/silent")
+public class MpSilentAuthController {
+
+    @Autowired
+    private WxMpService wxMpService;
+
+    @Autowired
+    private MpSilentAuthService mpSilentAuthService;
+
+    @Autowired
+    private MpSilentAuthProperties silentAuthProperties;
+
+    /**
+     * 生成静默授权 URL
+     */
+    @GetMapping("/authUrl")
+    public R authUrl(@RequestParam String redirectUri,
+                     @RequestParam(required = false) String appId,
+                     @RequestParam(required = false) String state) {
+        log.info("[authUrl] 收到请求 redirectUri={}, appId={}, state={}", redirectUri, appId, state);
+        if (StringUtils.isBlank(redirectUri)) {
+            log.warn("[authUrl] redirectUri 为空,拒绝");
+            return R.error("redirectUri 不能为空");
+        }
+        if (!isAllowedRedirectUri(redirectUri)) {
+            log.warn("[authUrl] redirectUri 不在白名单 redirectUri={}, allowedHosts={}",
+                    redirectUri, silentAuthProperties.getAllowedRedirectHosts());
+            return R.error("redirectUri 域名不在白名单中");
+        }
+        try {
+            WxMpService service = switchMp(appId);
+            String oauthUrl = service.getOAuth2Service().buildAuthorizationUrl(
+                    redirectUri,
+                    WxConsts.OAuth2Scope.SNSAPI_BASE,
+                    StringUtils.defaultString(state, "")
+            );
+            log.info("[authUrl] 生成授权链接成功 scope=snsapi_base, oauthUrl={}", oauthUrl);
+            Map<String, Object> data = new HashMap<>(2);
+            data.put("oauthUrl", oauthUrl);
+            return R.ok(data);
+        } catch (IllegalArgumentException e) {
+            log.warn("[authUrl] 参数错误: {}", e.getMessage());
+            return R.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("[authUrl] 生成静默授权链接失败 redirectUri={}, appId={}", redirectUri, appId, e);
+            return R.error("生成授权链接失败");
+        }
+    }
+
+    /**
+     * code 换 openId,再走业务扩展点返回最终 url
+     */
+    @PostMapping("/resolve")
+    public R resolve(@RequestBody MpSilentResolveParam param) {
+        log.info("[resolve] 收到请求 code={}, appId={}, state={}",
+                maskCode(param == null ? null : param.getCode()),
+                param == null ? null : param.getAppId(),
+                param == null ? null : param.getState());
+        if (param == null || StringUtils.isBlank(param.getCode())) {
+            log.warn("[resolve] code 为空,拒绝");
+            return R.error("code 不能为空");
+        }
+        try {
+            WxMpService service = switchMp(param.getAppId());
+            log.info("[resolve] 开始用 code 换取 accessToken, appId={}", param.getAppId());
+            WxOAuth2AccessToken accessToken = service.getOAuth2Service().getAccessToken(param.getCode());
+            log.info("获取公众号用户信息:{}", JSON.toJSONString(accessToken));
+            String openId = accessToken.getOpenId();
+            log.info("[resolve] 换票成功 openId={}, unionId={}, expiresIn={}",
+                    openId,
+                    accessToken.getUnionId(),
+                    accessToken.getExpiresIn());
+            if (StringUtils.isBlank(openId)) {
+                log.warn("[resolve] accessToken 中 openId 为空");
+                return R.error("未获取到 openId");
+            }
+            log.info("[resolve] 进入业务扩展点 MpSilentAuthService.resolve");
+            MpSilentAuthResult biz = mpSilentAuthService.resolve(openId, param.getState());
+            return R.ok(buildBizData(openId, biz));
+        } catch (WxErrorException e) {
+            log.error("[resolve] 微信接口失败 code={}, errCode={}, errMsg={}",
+                    maskCode(param.getCode()),
+                    e.getError() != null ? e.getError().getErrorCode() : null,
+                    e.getError() != null ? e.getError().getErrorMsg() : e.getMessage(),
+                    e);
+            return R.error("微信授权失败:" + e.getError().getErrorMsg());
+        } catch (IllegalArgumentException e) {
+            log.warn("[resolve] 参数错误: {}", e.getMessage());
+            return R.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("[resolve] 处理失败 appId={}, state={}", param.getAppId(), param.getState(), e);
+            return R.error("处理失败");
+        }
+    }
+
+    /**
+     * 已有 openId(本地缓存、二次进入)时,直接查拉黑/加销售等信息,无需再授权
+     */
+    @PostMapping("/userInfo")
+    public R userInfo(@RequestBody MpSilentUserInfoParam param) {
+        log.info("[userInfo] 收到请求 openId={}, state={}",
+                param == null ? null : param.getOpenId(),
+                param == null ? null : param.getState());
+        if (param == null || StringUtils.isBlank(param.getOpenId())) {
+            return R.error("openId 不能为空");
+        }
+        try {
+            MpSilentAuthResult biz = mpSilentAuthService.resolve(param.getOpenId(), param.getState());
+            return R.ok(buildBizData(param.getOpenId(), biz));
+        } catch (Exception e) {
+            log.error("[userInfo] 处理失败 openId={}", param.getOpenId(), e);
+            return R.error("处理失败");
+        }
+    }
+
+    private Map<String, Object> buildBizData(String openId, MpSilentAuthResult biz) {
+        log.info("[biz] openId={}, userFound={}, blacklisted={}, addSalesCount={}, tip={}, url={}",
+                openId,
+                biz.isUserFound(),
+                biz.isBlacklisted(),
+                biz.getAddSalesCount(),
+                biz.getTip(),
+                biz.getUrl());
+        Map<String, Object> data = new HashMap<>(8);
+        data.put("openId", openId);
+        data.put("url", biz.getUrl() == null ? "" : biz.getUrl());
+        data.put("userFound", biz.isUserFound());
+        data.put("userId", biz.getUserId());
+        data.put("blacklisted", biz.isBlacklisted());
+        data.put("manageRemark", biz.getManageRemark());
+        data.put("addSalesCount", biz.getAddSalesCount());
+        data.put("tip", biz.getTip());
+        return data;
+    }
+
+    private WxMpService switchMp(String appId) {
+        if (StringUtils.isNotBlank(appId)) {
+            log.info("[switchMp] 切换公众号 appId={}", appId);
+            if (!wxMpService.switchover(appId)) {
+                log.warn("[switchMp] 未找到公众号配置 appId={}", appId);
+                throw new IllegalArgumentException("未找到公众号配置 appId=" + appId);
+            }
+            WxMpService switched = wxMpService.switchoverTo(appId);
+            log.info("[switchMp] 切换成功 appId={}", appId);
+            return switched;
+        }
+        log.info("[switchMp] 未传 appId,使用默认公众号配置");
+        return wxMpService;
+    }
+
+    private boolean isAllowedRedirectUri(String redirectUri) {
+        List<String> hosts = silentAuthProperties.getAllowedRedirectHosts();
+        if (hosts == null || hosts.isEmpty()) {
+            log.info("[authUrl] 白名单为空,跳过 redirectUri 校验 redirectUri={}", redirectUri);
+            return true;
+        }
+        try {
+            URI uri = URI.create(redirectUri);
+            String host = uri.getHost();
+            if (StringUtils.isBlank(host)) {
+                log.warn("[authUrl] 无法解析 redirectUri host, redirectUri={}", redirectUri);
+                return false;
+            }
+            for (String allowed : hosts) {
+                if (host.equalsIgnoreCase(allowed) || host.endsWith("." + allowed)) {
+                    log.info("[authUrl] redirectUri 白名单校验通过 host={}, allowed={}", host, allowed);
+                    return true;
+                }
+            }
+            log.warn("[authUrl] redirectUri 白名单校验失败 host={}, allowedHosts={}", host, hosts);
+            return false;
+        } catch (Exception e) {
+            log.warn("[authUrl] 解析 redirectUri 异常 redirectUri={}", redirectUri, e);
+            return false;
+        }
+    }
+
+    /** code 较长,日志里只保留前后几位便于排查 */
+    private String maskCode(String code) {
+        if (StringUtils.isBlank(code)) {
+            return code;
+        }
+        if (code.length() <= 8) {
+            return "****";
+        }
+        return code.substring(0, 4) + "****" + code.substring(code.length() - 4);
+    }
+}

+ 19 - 0
fs-mp-app/src/main/java/com/fs/mp/param/MpSilentResolveParam.java

@@ -0,0 +1,19 @@
+package com.fs.mp.param;
+
+import lombok.Data;
+
+/**
+ * code 换 openId 并解析跳转 URL
+ */
+@Data
+public class MpSilentResolveParam {
+
+    /** 微信回调 code */
+    private String code;
+
+    /** 多公众号时指定 appId */
+    private String appId;
+
+    /** 业务透传参数 */
+    private String state;
+}

+ 16 - 0
fs-mp-app/src/main/java/com/fs/mp/param/MpSilentUserInfoParam.java

@@ -0,0 +1,16 @@
+package com.fs.mp.param;
+
+import lombok.Data;
+
+/**
+ * 已有 openId 时直接查询用户业务信息(无需再走微信授权)
+ */
+@Data
+public class MpSilentUserInfoParam {
+
+    /** 服务号 openId */
+    private String openId;
+
+    /** 业务透传参数 */
+    private String state;
+}

+ 16 - 0
fs-mp-app/src/main/java/com/fs/mp/service/MpSilentAuthService.java

@@ -0,0 +1,16 @@
+package com.fs.mp.service;
+
+import com.fs.mp.vo.MpSilentAuthResult;
+
+/**
+ * 静默授权拿到 openId 后的业务扩展点。
+ */
+public interface MpSilentAuthService {
+
+    /**
+     * @param openId 服务号 openId
+     * @param state  前端透传业务参数
+     * @return 业务结果(含 tip、拉黑、加销售数、跳转 url)
+     */
+    MpSilentAuthResult resolve(String openId, String state);
+}

+ 90 - 0
fs-mp-app/src/main/java/com/fs/mp/service/impl/MpSilentAuthServiceImpl.java

@@ -0,0 +1,90 @@
+package com.fs.mp.service.impl;
+
+import com.fs.his.domain.FsUser;
+import com.fs.his.service.IFsUserService;
+import com.fs.mp.service.MpSilentAuthService;
+import com.fs.mp.vo.MpSilentAuthResult;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 静默授权业务:
+ * openId → fs_user(mp_open_id, is_del=0) → 拉黑判断 → 统计已加销售数。
+ * 跳转 url 暂固定为空,由前端根据 tip 展示。
+ */
+@Slf4j
+@Service
+public class MpSilentAuthServiceImpl implements MpSilentAuthService {
+
+    /** 管理状态:已被总部拉黑 */
+    private static final int MANAGE_STATUS_BLACKLISTED = 2;
+
+    @Autowired
+    private IFsUserService fsUserService;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Override
+    public MpSilentAuthResult resolve(String openId, String state) {
+        MpSilentAuthResult result = new MpSilentAuthResult();
+        // 现阶段先不跳转
+        result.setUrl("");
+
+        log.info("[MpSilentAuthService] 开始处理 openId={}, state={}", openId, state);
+        if (StringUtils.isBlank(openId)) {
+            result.setTip("openId 为空");
+            return result;
+        }
+
+        FsUser query = new FsUser();
+        query.setMpOpenId(openId);
+        query.setIsDel(0);
+        List<FsUser> users = fsUserService.selectFsUserList(query);
+        if (users == null || users.isEmpty()) {
+            log.info("[MpSilentAuthService] 未找到用户 openId={}", openId);
+            result.setUserFound(false);
+            result.setTip("未找到用户");
+            return result;
+        }
+
+        // 多个默认取 userId 最小的第一条(稳定)
+        FsUser user = users.stream()
+                .sorted(Comparator.comparing(FsUser::getUserId, Comparator.nullsLast(Long::compareTo)))
+                .findFirst()
+                .orElse(users.get(0));
+        result.setUserFound(true);
+        result.setUserId(user.getUserId());
+        result.setManageRemark(user.getManageRemark());
+
+        Integer manageStatus = user.getManageStatus();
+        boolean blacklisted = manageStatus != null && manageStatus == MANAGE_STATUS_BLACKLISTED;
+        result.setBlacklisted(blacklisted);
+        if (blacklisted) {
+            String tip = "用户被拉黑";
+            if (StringUtils.isNotBlank(user.getManageRemark())) {
+                tip = tip + ":" + user.getManageRemark();
+            }
+            result.setTip(tip);
+            log.info("[MpSilentAuthService] 用户已被拉黑 userId={}, manageRemark={}",
+                    user.getUserId(), user.getManageRemark());
+            return result;
+        }
+
+        // 统计仍在好友关系中的外部联系人(status=0)数量 = 添加销售数
+        List<QwExternalContact> contacts =
+                qwExternalContactMapper.selectQwExternalContactListVOByfsUserIdAll(user.getUserId());
+        int salesCount = contacts == null ? 0 : contacts.size();
+        result.setAddSalesCount(salesCount);
+        result.setTip("添加销售:" + salesCount + "个");
+        log.info("[MpSilentAuthService] 用户正常 userId={}, addSalesCount={}", user.getUserId(), salesCount);
+        return result;
+    }
+}

+ 31 - 0
fs-mp-app/src/main/java/com/fs/mp/vo/MpSilentAuthResult.java

@@ -0,0 +1,31 @@
+package com.fs.mp.vo;
+
+import lombok.Data;
+
+/**
+ * 静默授权业务处理结果
+ */
+@Data
+public class MpSilentAuthResult {
+
+    /** 前端跳转地址;空则不跳转 */
+    private String url = "";
+
+    /** 是否查到 fs_user */
+    private boolean userFound;
+
+    /** 用户 ID */
+    private Long userId;
+
+    /** 是否被总部拉黑(manage_status=2) */
+    private boolean blacklisted;
+
+    /** 管理备注 */
+    private String manageRemark;
+
+    /** 已添加销售(企微外部联系人)数量 */
+    private int addSalesCount;
+
+    /** 前端展示文案 */
+    private String tip = "";
+}

+ 1 - 0
fs-mp-app/src/main/resources/META-INF/spring-devtools.properties

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

+ 2 - 0
fs-mp-app/src/main/resources/banner.txt

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

BIN
fs-mp-app/src/main/resources/fx-old.jpg


BIN
fs-mp-app/src/main/resources/fx.jpg


+ 37 - 0
fs-mp-app/src/main/resources/i18n/messages.properties

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

+ 93 - 0
fs-mp-app/src/main/resources/logback.xml

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

+ 20 - 0
fs-mp-app/src/main/resources/mybatis/mybatis-config.xml

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

+ 0 - 0
fs-mp-app/src/main/resources/qr.jpg


BIN
fs-mp-app/src/main/resources/simsunb.ttf


+ 197 - 0
fs-mp-app/src/main/resources/static/index.html

@@ -0,0 +1,197 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+  <title>微信授权</title>
+  <style>
+    body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f5f5; color: #333; }
+    .wrap { max-width: 480px; margin: 80px auto; padding: 24px; text-align: center; }
+    .tip { font-size: 15px; line-height: 1.6; color: #666; }
+    .err { color: #333; margin-top: 12px; word-break: break-all; }
+    .fail { color: #c00; }
+  </style>
+</head>
+<body>
+  <div class="wrap">
+    <p class="tip" id="tip">正在授权,请稍候…</p>
+    <p class="err" id="err"></p>
+  </div>
+  <script>
+    /**
+     * 部署说明:
+     * 1) 同源部署:API_BASE 留空
+     * 2) Nginx 静态 + 独立 API:API_BASE 填后端地址
+     * 3) 公众号后台「网页授权域名」= 本页域名
+     * 4) 已有 openId 时也会调 /mp/silent/userInfo 刷新拉黑/加销售信息
+     */
+    var API_BASE = 'https://wx.jy.cc/apis';
+    var APP_ID = 'wxe704b34112659eb1';
+    var OPENID_KEY = 'mp_silent_openid';
+
+    function $(id) { return document.getElementById(id); }
+    function qs(name) {
+      var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
+      return m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : '';
+    }
+    function isWeChat() {
+      return /MicroMessenger/i.test(navigator.userAgent || '');
+    }
+    function currentPageUrlWithoutQuery() {
+      return location.origin + location.pathname;
+    }
+    function apiUrl(path) {
+      var base = (API_BASE || '').replace(/\/$/, '');
+      return base + path;
+    }
+    function normalizePath(pathname) {
+      if (!pathname || pathname === '/') return '/';
+      return pathname.replace(/\/$/, '');
+    }
+    function isSameAuthPage(url) {
+      if (!url) return true;
+      try {
+        var u = new URL(url, location.href);
+        return u.origin === location.origin
+            && normalizePath(u.pathname) === normalizePath(location.pathname);
+      } catch (e) {
+        return false;
+      }
+    }
+    function clearCodeFromUrl() {
+      if (window.history && history.replaceState) {
+        history.replaceState(null, '', location.pathname + (location.hash || ''));
+      }
+    }
+    function showError(msg) {
+      $('tip').textContent = '授权失败';
+      $('err').className = 'err fail';
+      $('err').textContent = msg || '未知错误';
+    }
+    function finishOk(openId, jumpUrl, tip, blacklisted) {
+      clearCodeFromUrl();
+      if (openId) {
+        try { sessionStorage.setItem(OPENID_KEY, openId); } catch (e) {}
+      }
+      var url = (jumpUrl || '').replace(/^\s+|\s+$/g, '');
+      if (url && !isSameAuthPage(url)) {
+        $('tip').textContent = '授权成功,正在跳转…';
+        $('err').textContent = '';
+        location.replace(url);
+        return;
+      }
+      $('tip').textContent = tip || '授权成功';
+      $('err').className = blacklisted ? 'err fail' : 'err';
+      $('err').textContent = '';
+    }
+    function applyBizRes(res) {
+      if (!res || res.code !== 200 || !res.openId) {
+        showError((res && res.msg) || '获取用户信息失败');
+        return;
+      }
+      finishOk(res.openId, res.url || '', res.tip || '', !!res.blacklisted);
+    }
+    /** 已有 openId:每次进入都调后端刷新业务信息 */
+    function loadUserInfo(openId, state) {
+      $('tip').textContent = '正在获取信息…';
+      postJson(apiUrl('/mp/silent/userInfo'), {
+        openId: openId,
+        state: state || undefined
+      }).then(applyBizRes).catch(function (e) {
+        showError('网络异常:' + (e && e.message ? e.message : e));
+      });
+    }
+    function getJson(url) {
+      return fetch(url, { method: 'GET', credentials: 'omit' }).then(function (res) {
+        return res.json();
+      });
+    }
+    function postJson(url, body) {
+      return fetch(url, {
+        method: 'POST',
+        credentials: 'omit',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body || {})
+      }).then(function (res) {
+        return res.json();
+      });
+    }
+
+    function start() {
+      if (!isWeChat()) {
+        showError('请在微信中打开本页面');
+        return;
+      }
+
+      var appId = qs('appId') || APP_ID;
+      var state = qs('state') || '';
+      var code = qs('code');
+
+      if (!code) {
+        var cachedOpenId = '';
+        try { cachedOpenId = sessionStorage.getItem(OPENID_KEY) || ''; } catch (e) {}
+        // 已有 openId 且非强制重新授权:仍调接口拿拉黑/加销售信息
+        if (cachedOpenId && qs('force') !== '1') {
+          loadUserInfo(cachedOpenId, state);
+          return;
+        }
+
+        var redirectUri = currentPageUrlWithoutQuery();
+        var authQuery = 'redirectUri=' + encodeURIComponent(redirectUri);
+        if (appId) authQuery += '&appId=' + encodeURIComponent(appId);
+        if (state) authQuery += '&state=' + encodeURIComponent(state);
+
+        getJson(apiUrl('/mp/silent/authUrl?' + authQuery)).then(function (res) {
+          if (!res || res.code !== 200 || !res.oauthUrl) {
+            showError((res && res.msg) || '获取授权链接失败');
+            return;
+          }
+          location.replace(res.oauthUrl);
+        }).catch(function (e) {
+          showError('网络异常:' + (e && e.message ? e.message : e));
+        });
+        return;
+      }
+
+      // 同一 code 只换一次票;已换过则用 openId 再拉业务信息
+      var codeLockKey = 'mp_silent_code_' + code;
+      try {
+        if (sessionStorage.getItem(codeLockKey) === 'done') {
+          var doneOpenId = '';
+          try { doneOpenId = sessionStorage.getItem(OPENID_KEY) || ''; } catch (e) {}
+          if (doneOpenId) {
+            loadUserInfo(doneOpenId, qs('state') || state);
+          } else {
+            showError('授权状态异常,请刷新重试');
+          }
+          return;
+        }
+        if (sessionStorage.getItem(codeLockKey) === 'pending') {
+          return;
+        }
+        sessionStorage.setItem(codeLockKey, 'pending');
+      } catch (e) {}
+
+      var resolveState = qs('state') || state;
+      postJson(apiUrl('/mp/silent/resolve'), {
+        code: code,
+        appId: appId || undefined,
+        state: resolveState || undefined
+      }).then(function (res) {
+        if (!res || res.code !== 200 || !res.openId) {
+          try { sessionStorage.removeItem(codeLockKey); } catch (e) {}
+          showError((res && res.msg) || '获取 openId 失败');
+          return;
+        }
+        try { sessionStorage.setItem(codeLockKey, 'done'); } catch (e) {}
+        applyBizRes(res);
+      }).catch(function (e) {
+        try { sessionStorage.removeItem(codeLockKey); } catch (e2) {}
+        showError('网络异常:' + (e && e.message ? e.message : e));
+      });
+    }
+
+    start();
+  </script>
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/commonAgreement.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title  th:text="${agreementTitle}"></title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${agreementBody}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/healthCustomerService.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title >健康客服协议</title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${healthCustomerService}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/privacyPolicy.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title >隐私政策</title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${privacyPolicy}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/userAgreement.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title >用户协议</title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${userAgreement}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/userRemoveService.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title >用户注销协议</title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${userRemoveService}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/vipAutomaticService.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title >会员自动续费服务协议</title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${vipAutomaticService}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 21 - 0
fs-mp-app/src/main/resources/templates/vipService.html

@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+    <title >会员服务协议</title>
+    <link  rel="stylesheet"/>
+    <style>
+        body{
+            background-color:#F8F8F8;
+        }
+    </style>
+<body>
+<div th:utext="${vipService}" ></div>
+<script th:inline="javascript">
+
+</script>
+
+</body>
+</html>

+ 326 - 0
fs-mp-app/src/test/java/com/fs/app/course/RedPacketBalanceZeroTest.java

@@ -0,0 +1,326 @@
+package com.fs.app.course;
+
+import com.fs.FsUserAppApplication;
+import com.fs.common.constant.FsConstants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.core.domain.R;
+import com.fs.course.mapper.FsCourseRedPacketLogMapper;
+import com.fs.course.param.FsCourseSendRewardUParam;
+import com.fs.course.service.IFsUserCourseVideoService;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import java.math.BigDecimal;
+
+import static org.junit.Assert.*;
+
+/**
+ * 红包余额为0测试类
+ * 测试场景:公司红包余额为0时,不能发送红包
+ *
+ * @author test
+ * @date 2025-12-30
+ */
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = FsUserAppApplication.class)
+public class RedPacketBalanceZeroTest {
+
+    @Autowired
+    private IFsUserCourseVideoService courseVideoService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private FsCourseRedPacketLogMapper redPacketLogMapper;
+
+    // ==================== 测试数据配置 ====================
+    private static final Long TEST_COMPANY_ID = 300L;        // 测试公司ID(请根据实际调整)
+    private static final Long TEST_COURSE_ID = 104L;       // 测试课程ID
+    private static final Long TEST_VIDEO_ID = 3877L;         // 测试视频ID
+    private static final Long TEST_USER_ID = 459809L;        // 测试用户ID
+    private static final Long TEST_COMPANY_USER_ID = 9587L; // 测试公司用户ID
+    private static final Long TEST_PERIOD_ID = 705L;         // 测试期数ID
+
+    private String companyMoneyKey;
+    private String originalBalance; // 保存原始余额,用于恢复
+
+    @Before
+    public void setUp() {
+        System.out.println("\n" + repeat("=", 60));
+        System.out.println("    开始测试:红包余额为0不能发放红包");
+        System.out.println(repeat("=", 60));
+
+        // 初始化Redis Key
+        companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + TEST_COMPANY_ID;
+
+        // 保存原始余额
+        originalBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("📌 测试准备:保存原始余额 = " + originalBalance);
+    }
+
+    /**
+     * 核心测试用例:余额为0时不能发送红包
+     * 验证点:
+     * 1. 设置余额为0后,发送红包应返回错误
+     * 2. 错误信息应包含"余额不足"
+     * 3. Redis余额应保持为0,未被扣减
+     * 4. 数据库不应生成成功的红包记录
+     */
+    @Test
+    public void testSendRedPacketWhenBalanceIsZero() {
+        System.out.println("\n🧪 测试用例:余额为0时不能发送红包");
+        System.out.println(repeat("-", 60));
+
+        // ==================== 步骤1:设置Redis余额为0 ====================
+        System.out.println("\n📍 步骤1:设置Redis余额为0");
+        redisCache.setCacheObject(companyMoneyKey, "0");
+
+        String currentBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("   Redis Key: " + companyMoneyKey);
+        System.out.println("   当前余额: " + currentBalance);
+        assertEquals("初始余额应为0", "0", currentBalance);
+        System.out.println("   ✅ 余额设置成功");
+
+        // ==================== 步骤2:构造请求参数 ====================
+        System.out.println("\n📍 步骤2:构造发送红包请求参数");
+        FsCourseSendRewardUParam param = new FsCourseSendRewardUParam();
+        param.setCourseId(TEST_COURSE_ID);
+        param.setVideoId(TEST_VIDEO_ID);
+        param.setUserId(TEST_USER_ID);
+        param.setCompanyId(TEST_COMPANY_ID);
+        param.setCompanyUserId(TEST_COMPANY_USER_ID);
+        param.setPeriodId(TEST_PERIOD_ID);
+        param.setQwUserId("test_qw_user");
+        param.setAppId("test_app_id");
+        param.setQwExternalId(1L);
+
+        System.out.println("   参数信息:");
+        System.out.println("   - 公司ID: " + param.getCompanyId());
+        System.out.println("   - 课程ID: " + param.getCourseId());
+        System.out.println("   - 视频ID: " + param.getVideoId());
+        System.out.println("   - 用户ID: " + param.getUserId());
+        System.out.println("   ✅ 参数构造完成");
+
+        // ==================== 步骤3:调用发送红包接口 ====================
+        System.out.println("\n📍 步骤3:调用发送红包接口");
+        R result = null;
+        try {
+            result = courseVideoService.sendReward(param);
+        } catch (Exception e) {
+            System.out.println("   ⚠️  接口调用异常: " + e.getMessage());
+        }
+
+        // ==================== 步骤4:验证返回结果 ====================
+        System.out.println("\n📍 步骤4:验证返回结果");
+
+        assertNotNull("返回结果不应为空", result);
+        System.out.println("   返回码: " + result.get("code"));
+        System.out.println("   返回消息: " + result.get("msg"));
+
+        // 验证返回错误
+        Integer code = (Integer) result.get("code");
+        assertTrue("应返回错误状态码 (500或400)",
+            code == 500 || code == 400);
+
+        // 验证错误消息包含"余额不足"
+        String msg = String.valueOf(result.get("msg"));
+        assertTrue("错误信息应包含'余额不足'",
+            msg != null && msg.contains("余额不足"));
+
+        System.out.println("   ✅ 返回错误提示正确");
+
+        // ==================== 步骤5:验证Redis余额未被扣减 ====================
+        System.out.println("\n📍 步骤5:验证Redis余额未被扣减");
+
+        String finalBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("   最终余额: " + finalBalance);
+
+        assertEquals("余额应保持为0,未被扣减", "0", finalBalance);
+        System.out.println("   ✅ 余额保持为0,未被扣减");
+
+        // ==================== 步骤6:验证数据库无成功记录 ====================
+        System.out.println("\n📍 步骤6:验证数据库日志");
+        System.out.println("   提示:请手动查询数据库验证");
+        System.out.println("   SQL: SELECT * FROM fs_course_red_packet_log");
+        System.out.println("        WHERE company_id = " + TEST_COMPANY_ID);
+        System.out.println("        ORDER BY create_time DESC LIMIT 5;");
+
+        // ==================== 测试结论 ====================
+        System.out.println("\n" + repeat("=", 60));
+        System.out.println("    ✅ 测试通过:余额为0时成功阻止红包发放");
+        System.out.println(repeat("=", 60));
+    }
+
+    /**
+     * 测试用例2:余额不足时不能发送红包
+     */
+    @Test
+    public void testSendRedPacketWhenBalanceNotEnough() {
+        System.out.println("\n🧪 测试用例:余额不足时不能发送红包");
+        System.out.println(repeat("-", 60));
+
+        // 设置余额为3元(假设红包金额为5元)
+        System.out.println("\n📍 步骤1:设置Redis余额为3元");
+        redisCache.setCacheObject(companyMoneyKey, "3");
+
+        String currentBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("   当前余额: " + currentBalance);
+
+        // 构造请求
+        System.out.println("\n📍 步骤2:调用发送红包接口");
+        FsCourseSendRewardUParam param = new FsCourseSendRewardUParam();
+        param.setCourseId(TEST_COURSE_ID);
+        param.setVideoId(TEST_VIDEO_ID);
+        param.setUserId(TEST_USER_ID);
+        param.setCompanyId(TEST_COMPANY_ID);
+        param.setCompanyUserId(TEST_COMPANY_USER_ID);
+        param.setPeriodId(TEST_PERIOD_ID);
+        param.setQwUserId("test_qw_user");
+        param.setAppId("test_app_id");
+        param.setQwExternalId(1L);
+
+        R result = courseVideoService.sendReward(param);
+
+        // 验证结果
+        System.out.println("\n📍 步骤3:验证返回结果");
+        System.out.println("   返回码: " + result.get("code"));
+        System.out.println("   返回消息: " + result.get("msg"));
+
+        Integer code = (Integer) result.get("code");
+        assertTrue("应返回错误状态码", code == 500 || code == 400);
+
+        // 验证余额未变化
+        String finalBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("   最终余额: " + finalBalance);
+
+        System.out.println("\n   ✅ 余额不足时成功阻止红包发放");
+    }
+
+    /**
+     * 测试用例3:余额充足时能正常发送红包(可选,需要真实支付配置)
+     */
+    @Test
+    public void testSendRedPacketWhenBalanceEnough() {
+        System.out.println("\n🧪 测试用例:余额充足时能正常发送红包");
+        System.out.println(repeat("-", 60));
+        System.out.println("⚠️  注意:此测试需要真实的支付配置和红包接口");
+        System.out.println("   建议在测试环境执行,避免产生真实交易");
+
+        // 设置充足余额
+        System.out.println("\n📍 步骤1:设置Redis余额为100元");
+        redisCache.setCacheObject(companyMoneyKey, "100");
+
+        String currentBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("   当前余额: " + currentBalance);
+
+        // 构造请求
+        FsCourseSendRewardUParam param = new FsCourseSendRewardUParam();
+        param.setCourseId(TEST_COURSE_ID);
+        param.setVideoId(TEST_VIDEO_ID);
+        param.setUserId(TEST_USER_ID);
+        param.setCompanyId(TEST_COMPANY_ID);
+        param.setCompanyUserId(TEST_COMPANY_USER_ID);
+        param.setPeriodId(TEST_PERIOD_ID);
+        param.setQwUserId("test_qw_user");
+        param.setAppId("test_app_id");
+        param.setQwExternalId(1L);
+
+        System.out.println("\n📍 步骤2:调用发送红包接口(跳过真实发送)");
+        System.out.println("   提示:如需完整测试,请配置测试环境的支付接口");
+
+        // 注:此处可根据实际情况决定是否执行
+        // R result = courseVideoService.sendReward(param);
+    }
+
+    /**
+     * 测试用例4:并发场景测试(可选)
+     */
+    @Test
+    public void testConcurrentSendRedPacketWithZeroBalance() {
+        System.out.println("\n🧪 测试用例:并发场景下余额为0的保护");
+        System.out.println(repeat("-", 60));
+
+        // 设置余额为0
+        redisCache.setCacheObject(companyMoneyKey, "0");
+        System.out.println("   初始余额: 0");
+
+        // 模拟3个并发请求
+        int threadCount = 3;
+        System.out.println("\n📍 模拟" + threadCount + "个并发请求");
+
+        for (int i = 1; i <= threadCount; i++) {
+            System.out.println("\n   请求" + i + ":");
+
+            FsCourseSendRewardUParam param = new FsCourseSendRewardUParam();
+            param.setCourseId(TEST_COURSE_ID);
+            param.setVideoId(TEST_VIDEO_ID);
+            param.setUserId(TEST_USER_ID + i); // 不同用户
+            param.setCompanyId(TEST_COMPANY_ID);
+            param.setCompanyUserId(TEST_COMPANY_USER_ID + i);
+            param.setPeriodId(TEST_PERIOD_ID);
+            param.setQwUserId("test_qw_user_" + i);
+            param.setAppId("test_app_id");
+            param.setQwExternalId(1L);
+
+            R result = courseVideoService.sendReward(param);
+            System.out.println("   - 返回码: " + result.get("code"));
+            System.out.println("   - 消息: " + result.get("msg"));
+        }
+
+        // 验证余额仍为0
+        String finalBalance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("\n   最终余额: " + finalBalance);
+        assertEquals("并发请求后余额应仍为0", "0", finalBalance);
+
+        System.out.println("   ✅ 并发场景下余额保护正常");
+    }
+
+    @After
+    public void tearDown() {
+        // 恢复原始余额
+        if (originalBalance != null) {
+            System.out.println("\n🔄 测试清理:恢复原始余额 = " + originalBalance);
+            redisCache.setCacheObject(companyMoneyKey, originalBalance);
+        } else {
+            System.out.println("\n🔄 测试清理:删除测试数据");
+            redisCache.deleteObject(companyMoneyKey);
+        }
+
+        System.out.println("✅ 测试清理完成\n");
+    }
+
+    /**
+     * 工具方法:查看当前Redis余额
+     */
+    private void printCurrentBalance() {
+        String balance = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("💰 当前Redis余额: " + balance);
+    }
+
+    /**
+     * 工具方法:验证余额
+     */
+    private void assertBalance(String expected, String message) {
+        String actual = redisCache.getCacheObject(companyMoneyKey);
+        System.out.println("   预期余额: " + expected);
+        System.out.println("   实际余额: " + actual);
+        assertEquals(message, expected, actual);
+    }
+
+    /**
+     * 工具方法:替代String.repeat()方法 (JDK 8兼容)
+     */
+    private String repeat(String str, int count) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < count; i++) {
+            sb.append(str);
+        }
+        return sb.toString();
+    }
+}

+ 105 - 0
fs-mp-app/src/test/java/com/fs/test/StockDeductTest.java

@@ -0,0 +1,105 @@
+package com.fs.test;
+
+import com.fs.FsUserAppApplication;
+import com.fs.common.constant.RedisConstant;
+import com.fs.common.core.redis.service.StockDeductService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.*;
+
+/**
+ * 50万高并发库存扣减测试
+ */
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = FsUserAppApplication.class)
+@RequiredArgsConstructor
+@Slf4j
+public class StockDeductTest {
+
+    @Autowired
+    private StockDeductService stockDeductService;
+
+    // 商品ID
+    private static final Long PRODUCT_ID = 1001L;
+    // 初始库存(模拟5万库存,应对50万并发扣减)
+    private static final Integer INIT_STOCK = 5000;
+    // 总请求数(50万)
+    private static final int TOTAL_REQUESTS = 50000;
+
+    /**
+     * 模拟50万高并发库存扣减
+     */
+    @Test
+    public void testHighConcurrencyDeduct() throws InterruptedException, ExecutionException {
+        stockDeductService.initStock(PRODUCT_ID, 1L, INIT_STOCK);
+        // Java 8 ExecutorService 线程池(固定线程池,适配高并发)
+        ExecutorService executorService = createHighConcurrencyPool();
+
+        // 存储所有异步任务结果
+        List<CompletableFuture<Boolean>> futureList = new ArrayList<>();
+
+        // 提交50万请求
+        for (int i = 0; i < TOTAL_REQUESTS; i++) {
+//            futureList.add(stockDeductService.deductStockAsync(PRODUCT_ID, 1L, (long) i));
+        }
+
+        // 等待所有任务完成(Java 8 CompletableFuture 批量处理)
+        CompletableFuture<Void> allFutures = CompletableFuture.allOf(
+                futureList.toArray(new CompletableFuture[0])
+        );
+        allFutures.get();
+
+        // 统计结果
+        long successCount = futureList.stream()
+                .map(future -> {
+                    try {
+                        return future.get();
+                    } catch (Exception e) {
+                        return false;
+                    }
+                })
+                .filter(Boolean::booleanValue)
+                .count();
+
+        // 打印结果
+        System.out.println("======================================");
+        System.out.println("50万高并发库存扣减测试完成");
+        System.out.println("成功扣减次数:" + successCount);
+        System.out.println("失败扣减次数:" + (TOTAL_REQUESTS - successCount));
+        System.out.println("最终剩余库存:" + stockDeductService.redisTemplate.opsForValue().get(RedisConstant.STOCK_KEY_PREFIX + PRODUCT_ID));
+        System.out.println("======================================");
+
+        // 关闭线程池
+        executorService.shutdown();
+        executorService.awaitTermination(1, TimeUnit.MINUTES);
+    }
+
+
+
+    private static ExecutorService createHighConcurrencyPool() {
+        int corePoolSize = Runtime.getRuntime().availableProcessors() * 2; // CPU核心数*2
+        int maximumPoolSize = 200; // 最大线程数,根据服务器配置调整
+        long keepAliveTime = 60L;
+        // 用SynchronousQueue,直接提交任务,避免队列积压
+        BlockingQueue<Runnable> workQueue = new SynchronousQueue<>();
+        // 拒绝策略:丢弃最老的任务,避免OOM
+        RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardOldestPolicy();
+
+        return new ThreadPoolExecutor(
+                corePoolSize,
+                maximumPoolSize,
+                keepAliveTime,
+                TimeUnit.SECONDS,
+                workQueue,
+                new ThreadPoolExecutor.CallerRunsPolicy() // 兜底:主线程执行,避免任务丢失
+        );
+    }
+}

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -540,6 +540,9 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
     @Select("select * from qw_external_contact where fs_user_id = #{fsUserId} and status = 0 ")
     List<QwExternalContact> selectQwExternalContactListVOByfsUserId(Long fsUserId);
 
+    @Select("select * from qw_external_contact where fs_user_id = #{fsUserId}")
+    List<QwExternalContact> selectQwExternalContactListVOByfsUserIdAll(Long fsUserId);
+
     @Update("update qw_external_contact set comment_status = #{commentStatus} where fs_user_id = #{fsUserId}")
     int updateQwExternalContactByFsUserId(@Param("commentStatus") Integer commentStatus, @Param("fsUserId")Long fsUserId);
 

+ 6 - 0
pom.xml

@@ -254,6 +254,11 @@
                 <artifactId>fs-websocket</artifactId>
                 <version>${fs.version}</version>
             </dependency>
+            <dependency>
+                <groupId>com.fs</groupId>
+                <artifactId>fs-mp-app</artifactId>
+                <version>${fs.version}</version>
+            </dependency>
 
             <dependency>
                 <groupId>com.github.javen205</groupId>
@@ -297,6 +302,7 @@
         <module>fs-websocket</module>
         <module>fs-ad-new-api</module>
         <module>fs-live-mq</module>
+        <module>fs-mp-app</module>
     </modules>
     <packaging>pom</packaging>
     <build>