Procházet zdrojové kódy

测试文件调整

liuchengsen před 1 měsícem
rodič
revize
b078c3909c
17 změnil soubory, kde provedl 674 přidání a 97 odebrání
  1. 2 2
      zhijiayun-gateway/src/main/java/com/xuekairui/gateway/config/GatewayProperties.java
  2. 20 11
      zhijiayun-gateway/src/main/java/com/xuekairui/gateway/filter/RateLimitFilter.java
  3. 6 3
      zhijiayun-gateway/src/main/resources/application-prod.yml
  4. 20 49
      zhijiayun-gateway/src/main/resources/application.yml
  5. 19 0
      zhijiayun-gateway/src/main/resources/db/migration-v15.sql
  6. 2 0
      zhijiayun-gateway/src/main/resources/db/schema.sql
  7. 1 0
      zhijiayun-gateway/src/test/resources/test-schema.sql
  8. 189 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java
  9. 3 1
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/OperationAuditLogController.java
  10. 2 2
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/BusinessLicenseReviewResponse.java
  11. 3 3
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/BusinessLicense.java
  12. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/OperationAuditLog.java
  13. 48 0
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperationSource.java
  14. 6 0
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperationType.java
  15. 1 1
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/BusinessLicenseMapper.java
  16. 302 25
      zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java
  17. 47 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

+ 2 - 2
zhijiayun-gateway/src/main/java/com/xuekairui/gateway/config/GatewayProperties.java

@@ -46,8 +46,8 @@ public class GatewayProperties {
 
     @Data
     public static class RateLimitRule {
-        /** 路径前缀 */
-        private String pathPrefix;
+        /** 路径前缀列表(同一限流参数的多条路径可合并为一个规则) */
+        private List<String> pathPrefixes;
         /** 窗口期最大请求数 */
         private int limit;
         /** 窗口期(秒) */

+ 20 - 11
zhijiayun-gateway/src/main/java/com/xuekairui/gateway/filter/RateLimitFilter.java

@@ -61,10 +61,10 @@ public class RateLimitFilter extends OncePerRequestFilter {
         String clientIp = ValidateUtil.getClientIp(request);
 
         // 匹配限流规则
-        GatewayProperties.RateLimitRule matchedRule = matchRule(path);
-        int limit = matchedRule != null ? matchedRule.getLimit() : properties.getRateLimit().getDefaultLimit();
-        int window = matchedRule != null ? matchedRule.getWindow() : properties.getRateLimit().getDefaultWindow();
-        String ruleKey = matchedRule != null ? matchedRule.getPathPrefix() : "default";
+        MatchedRule matched = matchRule(path);
+        int limit = matched != null ? matched.rule().getLimit() : properties.getRateLimit().getDefaultLimit();
+        int window = matched != null ? matched.rule().getWindow() : properties.getRateLimit().getDefaultWindow();
+        String ruleKey = matched != null ? matched.matchedPrefix() : "default";
 
         // 构造 Redis Key: rate:limit:{ruleKey}:{clientIp}
         String redisKey = RATE_LIMIT_PREFIX + ruleKey + ":" + clientIp;
@@ -92,21 +92,30 @@ public class RateLimitFilter extends OncePerRequestFilter {
     }
 
     /**
-     * 匹配限流规则(取最精确匹配,即最长前缀)
+     * 匹配限流规则(遍历所有规则的 pathPrefixes,取最精确匹配,即最长前缀)
      */
-    private GatewayProperties.RateLimitRule matchRule(String path) {
-        GatewayProperties.RateLimitRule bestMatch = null;
+    private MatchedRule matchRule(String path) {
+        GatewayProperties.RateLimitRule bestRule = null;
+        String bestPrefix = null;
         int bestLength = 0;
 
         for (GatewayProperties.RateLimitRule rule : properties.getRateLimit().getRules()) {
-            if (path.startsWith(rule.getPathPrefix()) && rule.getPathPrefix().length() > bestLength) {
-                bestMatch = rule;
-                bestLength = rule.getPathPrefix().length();
+            if (rule.getPathPrefixes() == null) {
+                continue;
+            }
+            for (String prefix : rule.getPathPrefixes()) {
+                if (path.startsWith(prefix) && prefix.length() > bestLength) {
+                    bestRule = rule;
+                    bestPrefix = prefix;
+                    bestLength = prefix.length();
+                }
             }
         }
-        return bestMatch;
+        return bestRule != null ? new MatchedRule(bestRule, bestPrefix) : null;
     }
 
+    private record MatchedRule(GatewayProperties.RateLimitRule rule, String matchedPrefix) {}
+
     /**
      * 写入限流响应
      */

+ 6 - 3
zhijiayun-gateway/src/main/resources/application-prod.yml

@@ -99,13 +99,16 @@ gateway:
     default-limit: 60
     default-window: 60
     rules:
-      - path-prefix: /api/auth/sms
+      - path-prefixes:
+          - /api/auth/sms
         limit: 5
         window: 60
-      - path-prefix: /api/auth/login
+      - path-prefixes:
+          - /api/auth/login
         limit: 10
         window: 60
-      - path-prefix: /api/crawler
+      - path-prefixes:
+          - /api/crawler
         limit: 30
         window: 60
 

+ 20 - 49
zhijiayun-gateway/src/main/resources/application.yml

@@ -173,58 +173,29 @@ gateway:
     default-window: 60
     rules:
       # 短信验证码:防短信轰炸,10次/分钟
-      - path-prefix: /api/auth/sms
+      - path-prefixes:
+          - /api/auth/sms
         limit: 10
         window: 60
-      # 登录接口:防暴力破解,20次/分钟
-      - path-prefix: /api/auth/login
-        limit: 20
-        window: 60
-      # 第三方换取Token(X-Api-Key鉴权):防暴力试探,30次/分钟
-      - path-prefix: /api/auth/external
-        limit: 30
-        window: 60
-      # 微信登录回调:防刷,20次/分钟
-      - path-prefix: /api/auth/wechat
-        limit: 20
-        window: 60
-      # Token刷新/注册:防刷,30次/分钟
-      - path-prefix: /api/auth/refresh
-        limit: 30
-        window: 60
-      - path-prefix: /api/auth/register
+      # 登录/Token/注册/邀请:防刷,30次/分钟
+      - path-prefixes:
+          - /api/auth/login
+          - /api/auth/wechat
+          - /api/auth/external
+          - /api/auth/refresh
+          - /api/auth/register
+          - /api/invite/click
         limit: 30
         window: 60
-      # 邀请点击追踪(公开POST写操作,最易被刷):30次/分钟
-      - path-prefix: /api/invite/click
-        limit: 30
-        window: 60
-      # 邀请落地页/解析链接(公开GET):60次/分钟
-      - path-prefix: /api/invite/page
-        limit: 60
-        window: 60
-      - path-prefix: /api/invite/resolve-link
-        limit: 60
-        window: 60
-      # 支付回调(微信/支付宝服务器调用):放宽至60次/分钟,避免正常回调被限
-      - path-prefix: /api/payment/callback
-        limit: 60
-        window: 60
-      - path-prefix: /api/payment/anxin/notify
-        limit: 60
-        window: 60
-      - path-prefix: /api/payment/agreement/notify
-        limit: 60
-        window: 60
-      # 支付宝开放能力回调
-      - path-prefix: /api/payment/alipay
-        limit: 60
-        window: 60
-      # 爬虫查询:60次/分钟
-      - path-prefix: /api/crawler
-        limit: 60
-        window: 60
-      # 搜索建议(公开):60次/分钟
-      - path-prefix: /api/search/suggestions
+      # 公开接口/支付回调/爬虫/搜索:60次/分钟
+      - path-prefixes:
+          - /api/invite/page
+          - /api/invite/resolve-link
+          - /api/payment/callback
+          - /api/payment/anxin/notify
+          - /api/payment/agreement/notify
+          - /api/payment/alipay
+          - /api/crawler
+          - /api/search/suggestions
         limit: 60
         window: 60

+ 19 - 0
zhijiayun-gateway/src/main/resources/db/migration-v15.sql

@@ -0,0 +1,19 @@
+-- ============================================
+-- 迁移 v15:操作审计日志增加操作来源字段
+-- 说明:
+--   1. t_operation_audit_log 新增 operation_source 字段,记录操作的发起来源
+--   2. 来源取值:ADMIN_CREATE-管理员新增 / USER_SUBMIT-用户自主提交 / EXTERNAL-外部系统
+--   3. 用于运营后台筛选查询和数据追溯,区分管理员操作、用户操作与外部系统同步
+-- ============================================
+
+-- operation_source
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_operation_audit_log' AND COLUMN_NAME = 'operation_source');
+SET @sql = IF(@col_exists = 0, "ALTER TABLE `t_operation_audit_log` ADD COLUMN `operation_source` VARCHAR(20) NULL DEFAULT NULL COMMENT '操作来源:ADMIN_CREATE-管理员新增 USER_SUBMIT-用户自主提交 EXTERNAL-外部系统' AFTER `operation_type`", 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- operation_source 索引(便于按来源筛选查询)
+SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_operation_audit_log' AND INDEX_NAME = 'idx_operation_source');
+SET @sql = IF(@idx_exists = 0, "CREATE INDEX `idx_operation_source` ON `t_operation_audit_log`(`operation_source`)", 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 2 - 0
zhijiayun-gateway/src/main/resources/db/schema.sql

@@ -492,6 +492,7 @@ CREATE TABLE IF NOT EXISTS `t_operation_audit_log` (
     `user_agent`            VARCHAR(500)  DEFAULT NULL COMMENT '操作设备信息',
     `module`                VARCHAR(50)   DEFAULT NULL COMMENT '操作模块:USER/LEVEL/PLATFORM/LICENSE/INVITE/COUPON/SYSTEM',
     `operation_type`        VARCHAR(50)   DEFAULT NULL COMMENT '操作类型代码',
+    `operation_source`      VARCHAR(20)   DEFAULT NULL COMMENT '操作来源:ADMIN_CREATE-管理员新增 USER_SUBMIT-用户自主提交 EXTERNAL-外部系统',
     `description`           VARCHAR(200)  DEFAULT NULL COMMENT '操作描述',
     `target_type`           VARCHAR(50)   DEFAULT NULL COMMENT '目标对象类型',
     `target_id`             BIGINT        DEFAULT NULL COMMENT '目标对象ID',
@@ -513,6 +514,7 @@ CREATE TABLE IF NOT EXISTS `t_operation_audit_log` (
     KEY `idx_operation_time` (`operation_time`),
     KEY `idx_module` (`module`),
     KEY `idx_operation_type` (`operation_type`),
+    KEY `idx_operation_source` (`operation_source`),
     KEY `idx_target` (`target_type`, `target_id`),
     KEY `idx_is_sensitive` (`is_sensitive`),
     KEY `idx_result` (`result`)

+ 1 - 0
zhijiayun-gateway/src/test/resources/test-schema.sql

@@ -485,6 +485,7 @@ CREATE TABLE IF NOT EXISTS t_operation_audit_log (
     user_agent       VARCHAR(512)  DEFAULT NULL,
     module           VARCHAR(32)   DEFAULT NULL,
     operation_type   VARCHAR(64)   DEFAULT NULL,
+    operation_source VARCHAR(20)   DEFAULT NULL,
     description      VARCHAR(256)  DEFAULT NULL,
     target_type      VARCHAR(32)   DEFAULT NULL,
     target_id        BIGINT        DEFAULT NULL,

+ 189 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java

@@ -12,7 +12,9 @@ import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -43,6 +45,40 @@ public class AdminLicenseController {
         return Result.success(businessLicenseService.listByReviewStatus(reviewStatus, keyword));
     }
 
+    /**
+     * 管理员新增入驻信息(代客户填写,创建后状态为待审核,需再操作一次审核确认)
+     * <p>
+     * 必须选择一个未提交过入驻信息的客户,必填基础信息,
+     * 必须上传营业执照、药品经营许可、(二类/三类医疗器械二选一)。
+     *
+     * POST /api/admin/license
+     */
+    @PostMapping
+    public Result<Void> createByAdmin(
+            @Valid @RequestBody AdminCreateRequest request,
+            HttpServletRequest httpRequest) {
+        Long reviewerId = (Long) httpRequest.getAttribute("userId");
+        businessLicenseService.createByAdmin(request.toSaveRequest(), reviewerId);
+        return Result.success();
+    }
+
+    /**
+     * 管理员编辑入驻信息(可修改客户信息、替换资质图片,保存后即时生效)
+     * <p>
+     * 可编辑全部入驻数据(含已审核),必填项约束同样生效。
+     *
+     * PUT /api/admin/license/{licenseId}
+     */
+    @PutMapping("/{licenseId}")
+    public Result<Void> editByAdmin(
+            @PathVariable Long licenseId,
+            @Valid @RequestBody AdminEditRequest request,
+            HttpServletRequest httpRequest) {
+        Long reviewerId = (Long) httpRequest.getAttribute("userId");
+        businessLicenseService.editByAdmin(licenseId, request.toSaveRequest(), reviewerId);
+        return Result.success();
+    }
+
     @PostMapping("/review")
     public Result<Void> reviewLicense(
             @Valid @RequestBody ReviewRequest request,
@@ -69,4 +105,157 @@ public class AdminLicenseController {
 
         private String rejectReason;
     }
+
+    /**
+     * 管理员新增入驻信息请求
+     */
+    @Data
+    public static class AdminCreateRequest {
+
+        /** 客户用户ID(必须选择未提交过入驻信息的客户) */
+        @NotNull(message = "必须选择一个客户")
+        private Long userId;
+
+        /** 店铺名称 */
+        @NotBlank(message = "店铺名称不能为空")
+        private String storeName;
+
+        /** 终端类型:SINGLE-单店, CHAIN-连锁, CLINIC-诊所, COMMUNITY_HEALTH-社康 */
+        @NotBlank(message = "终端类型不能为空")
+        private String terminalType;
+
+        /** 省 */
+        @NotBlank(message = "所在省不能为空")
+        private String province;
+
+        /** 市 */
+        @NotBlank(message = "所在市不能为空")
+        private String city;
+
+        /** 区 */
+        @NotBlank(message = "所在区不能为空")
+        private String district;
+
+        /** 店铺详细地址 */
+        @NotBlank(message = "店铺地址不能为空")
+        private String storeAddress;
+
+        /** 联系人姓名 */
+        @NotBlank(message = "联系人姓名不能为空")
+        private String contactPerson;
+
+        /** 联系电话 */
+        @NotBlank(message = "联系电话不能为空")
+        private String contactPhone;
+
+        /** 营业执照图片URL */
+        @NotBlank(message = "营业执照图片不能为空")
+        private String businessLicenseUrl;
+
+        /** 药品经营许可证图片URL */
+        @NotBlank(message = "药品经营许可证不能为空")
+        private String drugLicenseUrl;
+
+        /** 统一社会信用代码 */
+        private String creditCode;
+
+        /** 二类医疗器械备案图片URL */
+        private String medicalDeviceClass2Url;
+
+        /** 三类医疗器械备案图片URL */
+        private String medicalDeviceClass3Url;
+
+        public BusinessLicenseService.BusinessLicenseSaveRequest toSaveRequest() {
+            return BusinessLicenseService.BusinessLicenseSaveRequest.builder()
+                    .userId(userId)
+                    .storeName(storeName)
+                    .terminalType(terminalType)
+                    .province(province)
+                    .city(city)
+                    .district(district)
+                    .storeAddress(storeAddress)
+                    .contactPerson(contactPerson)
+                    .contactPhone(contactPhone)
+                    .businessLicenseUrl(businessLicenseUrl)
+                    .drugLicenseUrl(drugLicenseUrl)
+                    .creditCode(creditCode)
+                    .medicalDeviceClass2Url(medicalDeviceClass2Url)
+                    .medicalDeviceClass3Url(medicalDeviceClass3Url)
+                    .build();
+        }
+    }
+
+    /**
+     * 管理员编辑入驻信息请求(不含 userId,编辑时客户绑定关系不可更改)
+     */
+    @Data
+    public static class AdminEditRequest {
+
+        /** 店铺名称 */
+        @NotBlank(message = "店铺名称不能为空")
+        private String storeName;
+
+        /** 终端类型:SINGLE-单店, CHAIN-连锁, CLINIC-诊所, COMMUNITY_HEALTH-社康 */
+        @NotBlank(message = "终端类型不能为空")
+        private String terminalType;
+
+        /** 省 */
+        @NotBlank(message = "所在省不能为空")
+        private String province;
+
+        /** 市 */
+        @NotBlank(message = "所在市不能为空")
+        private String city;
+
+        /** 区 */
+        @NotBlank(message = "所在区不能为空")
+        private String district;
+
+        /** 店铺详细地址 */
+        @NotBlank(message = "店铺地址不能为空")
+        private String storeAddress;
+
+        /** 联系人姓名 */
+        @NotBlank(message = "联系人姓名不能为空")
+        private String contactPerson;
+
+        /** 联系电话 */
+        @NotBlank(message = "联系电话不能为空")
+        private String contactPhone;
+
+        /** 营业执照图片URL */
+        @NotBlank(message = "营业执照图片不能为空")
+        private String businessLicenseUrl;
+
+        /** 药品经营许可证图片URL */
+        @NotBlank(message = "药品经营许可证不能为空")
+        private String drugLicenseUrl;
+
+        /** 统一社会信用代码 */
+        private String creditCode;
+
+        /** 二类医疗器械备案图片URL */
+        private String medicalDeviceClass2Url;
+
+        /** 三类医疗器械备案图片URL */
+        private String medicalDeviceClass3Url;
+
+        public BusinessLicenseService.BusinessLicenseSaveRequest toSaveRequest() {
+            return BusinessLicenseService.BusinessLicenseSaveRequest.builder()
+                    .storeName(storeName)
+                    .terminalType(terminalType)
+                    .province(province)
+                    .city(city)
+                    .district(district)
+                    .storeAddress(storeAddress)
+                    .contactPerson(contactPerson)
+                    .contactPhone(contactPhone)
+                    .businessLicenseUrl(businessLicenseUrl)
+                    .drugLicenseUrl(drugLicenseUrl)
+                    .creditCode(creditCode)
+                    .medicalDeviceClass2Url(medicalDeviceClass2Url)
+                    .medicalDeviceClass3Url(medicalDeviceClass3Url)
+                    .build();
+        }
+    }
 }

+ 3 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/controller/OperationAuditLogController.java

@@ -30,10 +30,12 @@ public class OperationAuditLogController {
             @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
             @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
             @RequestParam(required = false) Boolean isSensitive,
+            @RequestParam(required = false) String operationSource,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int size) {
         return Result.success(auditLogService.queryAuditLogs(
-                module, operationType, operatorId, startTime, endTime, isSensitive, page, size));
+                module, operationType, operatorId, startTime, endTime, isSensitive,
+                operationSource, page, size));
     }
 
     @GetMapping("/sensitive")

+ 2 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/dto/BusinessLicenseReviewResponse.java

@@ -75,8 +75,8 @@ public class BusinessLicenseReviewResponse {
     /** 外部系统记录ID */
     private String externalId;
 
-    /** 第三方同步状态:NOT_SYNCED-未同步 SYNCED-已同步 SYNC_FAILED-同步失败 */
-    private String syncStatus;
+    /** 第三方同步状态:0-未同步 1-同步成功 2-同步失败 */
+    private Integer status;
 
     /** 是否展示认证标识 */
     private Boolean showVerifiedBadge;

+ 3 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/entity/BusinessLicense.java

@@ -134,9 +134,9 @@ public class BusinessLicense {
     @TableField("external_id")
     private String externalId;
 
-    /** 第三方同步状态:NOT_SYNCED-未同步 SYNCED-已同步 SYNC_FAILED-同步失败 */
-    @TableField("sync_status")
-    private String syncStatus;
+    /** 第三方同步状态:0-未同步 1-同步成功 2-同步失败 */
+    @TableField("status")
+    private Integer status;
     
     /** 备注 */
     @TableField("remark")

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/OperationAuditLog.java

@@ -50,6 +50,9 @@ public class OperationAuditLog {
     /** 操作类型(见OperationType枚举) */
     private String operationType;
 
+    /** 操作来源:ADMIN_CREATE-管理员新增 USER_SUBMIT-用户自主提交 EXTERNAL-外部系统(见 OperationSource 枚举) */
+    private String operationSource;
+
     /** 操作描述(中文描述,便于阅读) */
     private String description;
 

+ 48 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperationSource.java

@@ -0,0 +1,48 @@
+package com.xuekairui.user.enums;
+
+import lombok.Getter;
+
+/**
+ * 操作来源枚举
+ * <p>
+ * 用于审计日志区分操作的发起来源,便于筛选查询和数据追溯。
+ *
+ * @author ProPrice Team
+ * @since 2026-07-10
+ */
+@Getter
+public enum OperationSource {
+
+    /** 管理员新增(后台运营人员主动创建) */
+    ADMIN_CREATE("ADMIN_CREATE", "管理员新增"),
+    /** 用户自主提交(客户端用户自行操作) */
+    USER_SUBMIT("USER_SUBMIT", "用户自主提交"),
+    /** 外部系统(第三方系统同步触发) */
+    EXTERNAL("EXTERNAL", "外部系统");
+
+    /** 来源代码(存入数据库的值) */
+    private final String code;
+
+    /** 来源描述 */
+    private final String description;
+
+    OperationSource(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    /**
+     * 根据代码查找枚举
+     */
+    public static OperationSource fromCode(String code) {
+        if (code == null) {
+            return null;
+        }
+        for (OperationSource source : values()) {
+            if (source.getCode().equals(code)) {
+                return source;
+            }
+        }
+        return null;
+    }
+}

+ 6 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperationType.java

@@ -62,6 +62,12 @@ public enum OperationType {
     LICENSE_APPROVE("LICENSE", "APPROVE", "LICENSE", "审核入驻信息(通过)", true),
     /** 审核入驻信息(驳回) */
     LICENSE_REJECT("LICENSE", "REJECT", "LICENSE", "审核入驻信息(驳回)", true),
+    /** 管理员新增入驻信息(跳过审核,直接通过) */
+    LICENSE_CREATE("LICENSE", "CREATE", "LICENSE", "管理员新增入驻信息", true),
+    /** 管理员编辑入驻信息 */
+    LICENSE_EDIT("LICENSE", "EDIT", "LICENSE", "管理员编辑入驻信息", true),
+    /** 管理员重新驳回已通过的入驻信息 */
+    LICENSE_RE_REJECT("LICENSE", "RE_REJECT", "LICENSE", "管理员重新驳回入驻信息", true),
     /** 删除入驻信息 */
     LICENSE_DELETE("LICENSE", "DELETE", "LICENSE", "删除入驻信息", true),
 

+ 1 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/BusinessLicenseMapper.java

@@ -27,7 +27,7 @@ public interface BusinessLicenseMapper extends BaseMapper<BusinessLicense> {
             "bl.store_address, bl.contact_person, bl.contact_phone, bl.credit_code, " +
             "bl.license_image_url, bl.drug_license_url, bl.medical_device_class2_url, bl.medical_device_class3_url, " +
             "bl.review_status, bl.reject_reason, bl.sync_source, bl.show_verified_badge, " +
-            "bl.external_id, bl.sync_status, " +
+            "bl.external_id, bl.status, " +
             "bl.reviewer_id, bl.review_time, bl.create_time, bl.update_time, " +
             "bl.user_id AS submitterUserId, " +
             "u.phone AS submitterPhone, u.nickname AS submitterNickname, u.user_name AS submitterUserName " +

+ 302 - 25
zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java

@@ -9,6 +9,7 @@ import com.xuekairui.user.entity.BusinessLicense;
 import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.enums.OperationSource;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
@@ -131,6 +132,7 @@ public class BusinessLicenseService {
     @Transactional
     public void saveOrUpdate(Long userId, BusinessLicenseSaveRequest request) {
         BusinessLicense existing = getByUserId(userId);
+        final BusinessLicense targetLicense;
         if (existing == null) {
             BusinessLicense license = BusinessLicense.builder()
                     .userId(userId)
@@ -151,6 +153,7 @@ public class BusinessLicenseService {
                     .showVerifiedBadge(false)
                     .build();
             businessLicenseMapper.insert(license);
+            targetLicense = license;
             log.info("用户{}提交入驻信息:id={}", userId, license.getId());
         } else {
             existing.setStoreName(request.getStoreName());
@@ -174,8 +177,238 @@ public class BusinessLicenseService {
             }
             existing.setReviewStatus(LicenseStatus.PENDING.name());
             businessLicenseMapper.updateById(existing);
+            targetLicense = existing;
             log.info("用户{}更新入驻信息:id={}", userId, existing.getId());
         }
+
+        // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程
+        User submitter = userMapper.selectById(userId);
+        if (submitter != null) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    syncShopResourceToThirdParty(submitter, targetLicense);
+                }
+            });
+        }
+    }
+
+    // ======================== 运营侧:管理员新增入驻 ========================
+
+    /**
+     * 管理员后台新增入驻信息(代客户填写)
+     * <p>
+     * 业务规则:
+     * <ul>
+     *   <li>必须选择一个未提交过入驻信息的客户(t_business_license 中无该 userId 记录)</li>
+     *   <li>必填基础信息 + 营业执照 + 药品经营许可;二类/三类医疗器械至少上传一个</li>
+     *   <li>管理员手动创建的记录状态为 PENDING,需再操作一次审核确认才转为 APPROVED</li>
+     *   <li>审核确认通过后赠送 30 天高级会员并同步第三方(由 approve 方法处理)</li>
+     * </ul>
+     *
+     * @param request    入驻信息(含 userId)
+     * @param reviewerId 操作管理员 ID
+     */
+    @Transactional
+    public void createByAdmin(BusinessLicenseSaveRequest request, Long reviewerId) {
+        Long userId = request.getUserId();
+        if (userId == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "必须选择一个客户");
+        }
+
+        // 校验客户存在
+        User submitter = userMapper.selectById(userId);
+        if (submitter == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "客户(ID=" + userId + ")不存在");
+        }
+
+        // 校验该客户未提交过入驻信息
+        BusinessLicense existing = getByUserId(userId);
+        if (existing != null) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR,
+                    "该客户已提交过入驻信息,无法重复新增,请使用编辑功能");
+        }
+
+        // 校验资质必填项
+        validateRequiredQualifications(request);
+
+        // 构建入驻记录,状态为 PENDING,需管理员再操作一次审核确认
+        BusinessLicense license = BusinessLicense.builder()
+                .userId(userId)
+                .storeName(request.getStoreName())
+                .terminalType(request.getTerminalType())
+                .province(request.getProvince())
+                .city(request.getCity())
+                .district(request.getDistrict())
+                .storeAddress(request.getStoreAddress())
+                .contactPerson(request.getContactPerson())
+                .contactPhone(request.getContactPhone())
+                .licenseImageUrl(request.getBusinessLicenseUrl())
+                .drugLicenseUrl(request.getDrugLicenseUrl())
+                .creditCode(request.getCreditCode())
+                .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url())
+                .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url())
+                .reviewStatus(LicenseStatus.PENDING.name())
+                .showVerifiedBadge(false)
+                .syncSource("MANUAL")
+                .status(0)
+                .build();
+        businessLicenseMapper.insert(license);
+        log.info("管理员{}新增入驻信息(待审核确认): licenseId={}, userId={}",
+                reviewerId, license.getId(), userId);
+
+        // 审计日志:管理员新增,记录变更后快照
+        String userName = submitter.getNickname() != null
+                ? submitter.getNickname() : String.valueOf(userId);
+        String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店";
+        auditLogService.logSuccess(
+                reviewerId != null ? reviewerId : 0L,
+                OperatorRole.ADMIN,
+                OperationType.LICENSE_CREATE,
+                "LICENSE:" + license.getId(),
+                license.getId(),
+                null,
+                Map.of("userId", userId, "userName", userName, "storeName", storeName,
+                        "reviewStatus", LicenseStatus.PENDING.name(), "syncSource", "MANUAL"),
+                String.format("管理员新增客户「%s」(药店:%s)的入驻信息,待审核确认", userName, storeName),
+                OperationSource.ADMIN_CREATE);
+    }
+
+    /**
+     * 校验资质必填项
+     * <p>
+     * 营业执照、药品经营许可必填;二类/三类医疗器械至少上传一个。
+     */
+    private void validateRequiredQualifications(BusinessLicenseSaveRequest request) {
+        if (isBlank(request.getBusinessLicenseUrl())) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "营业执照图片不能为空");
+        }
+        if (isBlank(request.getDrugLicenseUrl())) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "药品经营许可证不能为空");
+        }
+        boolean hasClass2 = !isBlank(request.getMedicalDeviceClass2Url());
+        boolean hasClass3 = !isBlank(request.getMedicalDeviceClass3Url());
+        if (!hasClass2 && !hasClass3) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR,
+                    "必须上传二类或三类医疗器械资质(二选一)");
+        }
+    }
+
+    private static boolean isBlank(String s) {
+        return s == null || s.isBlank();
+    }
+
+    // ======================== 运营侧:管理员编辑入驻 ========================
+
+    /**
+     * 管理员编辑入驻信息(可修改客户信息、替换资质图片,保存后即时生效)
+     * <p>
+     * 业务规则:
+     * <ul>
+     *   <li>可编辑全部入驻数据(含已审核 APPROVED),不受 PENDING 限制</li>
+     *   <li>必填项约束同样生效:营业执照、药品经营许可必填,二类/三类至少一个</li>
+     *   <li>资质图片覆盖原内容(非空即覆盖,空值保留原值)</li>
+     *   <li>记录变更前/变更后快照,便于追溯</li>
+     *   <li>事务提交后同步到第三方系统,失败不阻塞主流程</li>
+     * </ul>
+     *
+     * @param licenseId   入驻信息 ID
+     * @param request     编辑后的入驻信息
+     * @param reviewerId  操作管理员 ID
+     */
+    @Transactional
+    public void editByAdmin(Long licenseId, BusinessLicenseSaveRequest request, Long reviewerId) {
+        BusinessLicense license = getById(licenseId);
+        if (license == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "入驻信息记录不存在");
+        }
+
+        // 校验资质必填项
+        validateRequiredQualifications(request);
+
+        // 变更前快照
+        Map<String, Object> beforeData = buildSnapshot(license);
+
+        Long userId = license.getUserId();
+        User submitter = userMapper.selectById(userId);
+        if (submitter == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "客户(ID=" + userId + ")不存在");
+        }
+
+        // 覆盖更新所有字段(资质图片非空即覆盖)
+        license.setStoreName(request.getStoreName());
+        license.setTerminalType(request.getTerminalType());
+        license.setProvince(request.getProvince());
+        license.setCity(request.getCity());
+        license.setDistrict(request.getDistrict());
+        license.setStoreAddress(request.getStoreAddress());
+        license.setContactPerson(request.getContactPerson());
+        license.setContactPhone(request.getContactPhone());
+        license.setLicenseImageUrl(request.getBusinessLicenseUrl());
+        license.setDrugLicenseUrl(request.getDrugLicenseUrl());
+        if (request.getCreditCode() != null) {
+            license.setCreditCode(request.getCreditCode());
+        }
+        if (request.getMedicalDeviceClass2Url() != null) {
+            license.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url());
+        }
+        if (request.getMedicalDeviceClass3Url() != null) {
+            license.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url());
+        }
+        license.setReviewerId(reviewerId);
+        license.setReviewTime(LocalDateTime.now());
+        businessLicenseMapper.updateById(license);
+        log.info("管理员{}编辑入驻信息: licenseId={}, userId={}", reviewerId, licenseId, userId);
+
+        // 变更后快照
+        Map<String, Object> afterData = buildSnapshot(license);
+
+        // 事务提交后同步到第三方系统
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                syncShopResourceToThirdParty(submitter, license);
+            }
+        });
+
+        // 审计日志:管理员编辑,记录变更前/后快照
+        String userName = submitter.getNickname() != null
+                ? submitter.getNickname() : String.valueOf(userId);
+        String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店";
+        auditLogService.logSuccess(
+                reviewerId != null ? reviewerId : 0L,
+                OperatorRole.ADMIN,
+                OperationType.LICENSE_EDIT,
+                "LICENSE:" + license.getId(),
+                license.getId(),
+                beforeData,
+                afterData,
+                String.format("管理员编辑客户「%s」(药店:%s)的入驻信息", userName, storeName),
+                OperationSource.ADMIN_CREATE);
+    }
+
+    /**
+     * 构建入驻信息快照(用于审计日志的变更前/后对比)
+     */
+    private Map<String, Object> buildSnapshot(BusinessLicense license) {
+        Map<String, Object> snapshot = new HashMap<>();
+        snapshot.put("userId", license.getUserId());
+        snapshot.put("storeName", license.getStoreName());
+        snapshot.put("terminalType", license.getTerminalType());
+        snapshot.put("province", license.getProvince());
+        snapshot.put("city", license.getCity());
+        snapshot.put("district", license.getDistrict());
+        snapshot.put("storeAddress", license.getStoreAddress());
+        snapshot.put("contactPerson", license.getContactPerson());
+        snapshot.put("contactPhone", license.getContactPhone());
+        snapshot.put("creditCode", license.getCreditCode());
+        snapshot.put("businessLicenseUrl", license.getLicenseImageUrl());
+        snapshot.put("drugLicenseUrl", license.getDrugLicenseUrl());
+        snapshot.put("medicalDeviceClass2Url", license.getMedicalDeviceClass2Url());
+        snapshot.put("medicalDeviceClass3Url", license.getMedicalDeviceClass3Url());
+        snapshot.put("reviewStatus", license.getReviewStatus());
+        snapshot.put("showVerifiedBadge", license.getShowVerifiedBadge());
+        return snapshot;
     }
 
     // ======================== 运营侧:审核操作 ========================
@@ -225,12 +458,18 @@ public class BusinessLicenseService {
                     } catch (Exception e) {
                         log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e);
                     }
-                    // 同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程
-                    syncShopResourceAfterApproved(submitter, license);
                 }
             });
         }
 
+        // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                syncShopResourceToThirdParty(submitter, license);
+            }
+        });
+
         // 审计日志:审核通过,记录客户信息
         String userName = submitter.getNickname() != null
                 ? submitter.getNickname() : String.valueOf(userId);
@@ -243,60 +482,78 @@ public class BusinessLicenseService {
                 license.getId(),
                 null,
                 Map.of("userId", userId, "userName", userName, "storeName", storeName),
-                String.format("审核通过客户「%s」(药店:%s)的入驻申请", userName, storeName));
+                String.format("审核通过客户「%s」(药店:%s)的入驻申请", userName, storeName),
+                OperationSource.ADMIN_CREATE);
         log.info("入驻审核通过: licenseId={}, userId={}, userName={}, reviewerId={}",
                 licenseId, userId, userName, reviewerId);
     }
 
     /**
-     * 审核通过后同步客户入驻信息到第三方系统(智价云药店版)
+     * 同步客户入驻信息到第三方系统(智价云药店版)
      * <p>
      * 将客户手机号 + 店铺信息推送到第三方接口,第三方返回该客户的访问令牌。
-     * 同步成功后将第三方 user_id 回写到入驻记录的 externalId 字段,并标记 sync_status=SYNCED。
-     * 第三方不可用或同步失败时标记 sync_status=SYNC_FAILED,不影响本地审核流程,
-     * 后续可通过 sync_status 筛选失败记录进行补推。
+     * 在入驻新增、审核驳回、审核通过三个环节均触发同步,确保第三方实时获取入驻状态变更。
+     * 同步成功后将第三方 user_id 回写到入驻记录的 externalId 字段,并标记 status=1。
+     * 第三方不可用或同步失败时标记 status=2,不影响本地主流程,
+     * 后续可通过 status 筛选失败记录进行补推。
      */
-    private void syncShopResourceAfterApproved(User submitter, BusinessLicense license) {
+    private void syncShopResourceToThirdParty(User submitter, BusinessLicense license) {
         try {
             ShopResourceSyncService.SyncResult result =
                     shopResourceSyncService.syncShopResource(submitter, license);
             if (result != null && result.getUserId() != null) {
                 license.setExternalId(result.getUserId());
-                license.setSyncStatus("SYNCED");
+                license.setStatus(1);
                 businessLicenseMapper.updateById(license);
                 log.info("同步客户入驻信息到第三方成功: licenseId={}, externalUserId={}, newUser={}",
                         license.getId(), result.getUserId(), result.getNewUser());
             } else {
-                license.setSyncStatus("SYNC_FAILED");
+                license.setStatus(2);
                 businessLicenseMapper.updateById(license);
-                log.warn("同步客户入驻信息到第三方未成功(第三方未返回user_id),标记为SYNC_FAILED: licenseId={}",
+                log.warn("同步客户入驻信息到第三方未成功(第三方未返回user_id),标记为2: licenseId={}",
                         license.getId());
             }
         } catch (Exception e) {
-            license.setSyncStatus("SYNC_FAILED");
+            license.setStatus(2);
             businessLicenseMapper.updateById(license);
-            log.warn("同步客户入驻信息到第三方失败,标记为SYNC_FAILED,不影响主流程: licenseId={}, error={}",
+            log.warn("同步客户入驻信息到第三方失败,标记为2,不影响主流程: licenseId={}, error={}",
                     license.getId(), e.getMessage());
         }
     }
 
     /**
      * 审核驳回
+     * <p>
+     * 业务规则:
+     * <ul>
+     *   <li>只能对 PENDING(新提交/未审核通过)的记录驳回,APPROVED 不允许驳回</li>
+     *   <li>驳回原因必填</li>
+     *   <li>驳回后状态为 REJECTED,客户端/后台客户详情同步移除该条入驻展示</li>
+     *   <li>用户可重新上传提交(saveOrUpdate 会将状态改回 PENDING)</li>
+     *   <li>记录变更前/后快照,便于追溯</li>
+     *   <li>事务提交后同步到第三方系统,失败不阻塞主流程</li>
+     * </ul>
      *
      * @param licenseId    入驻信息 ID
      * @param reviewerId   审核人 ID
-     * @param rejectReason 驳回原因
+     * @param rejectReason 驳回原因(必填)
      */
     @Transactional
     public void reject(Long licenseId, Long reviewerId, String rejectReason) {
+        // 驳回原因必填
+        if (rejectReason == null || rejectReason.isBlank()) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "驳回原因不能为空");
+        }
+
         BusinessLicense license = validateReviewable(licenseId);
 
-        license.setReviewStatus(LicenseStatus.REJECTED.name());
-        license.setReviewerId(reviewerId);
-        license.setReviewTime(LocalDateTime.now());
-        license.setShowVerifiedBadge(false);
-        license.setRejectReason(rejectReason);
-        businessLicenseMapper.updateById(license);
+        // 审核通过后不允许驳回
+        if (LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) {
+            throw new BusinessException(ErrorCode.BAD_REQUEST, "该入驻信息已审核通过,不允许驳回");
+        }
+
+        // 变更前快照
+        Map<String, Object> beforeData = buildSnapshot(license);
 
         // 提交用户必须存在
         Long userId = license.getUserId();
@@ -305,7 +562,17 @@ public class BusinessLicenseService {
             throw new BusinessException(ErrorCode.NOT_FOUND, "提交用户(ID=" + userId + ")不存在,无法审核");
         }
 
-        // 审计日志:审核驳回,记录客户信息
+        license.setReviewStatus(LicenseStatus.REJECTED.name());
+        license.setReviewerId(reviewerId);
+        license.setReviewTime(LocalDateTime.now());
+        license.setShowVerifiedBadge(false);
+        license.setRejectReason(rejectReason);
+        businessLicenseMapper.updateById(license);
+
+        // 变更后快照
+        Map<String, Object> afterData = buildSnapshot(license);
+
+        // 审计日志:审核驳回,记录变更前/后快照
         String userName = submitter.getNickname() != null
                 ? submitter.getNickname() : String.valueOf(userId);
         String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店";
@@ -315,13 +582,21 @@ public class BusinessLicenseService {
                 OperationType.LICENSE_REJECT,
                 "LICENSE:" + license.getId(),
                 license.getId(),
-                null,
-                Map.of("userId", userId, "userName", userName, "storeName", storeName, "rejectReason",
-                        rejectReason != null ? rejectReason : ""),
+                beforeData,
+                afterData,
                 String.format("驳回客户「%s」(药店:%s)的入驻申请,原因:%s",
-                        userName, storeName, rejectReason != null ? rejectReason : "未填写"));
+                        userName, storeName, rejectReason),
+                OperationSource.ADMIN_CREATE);
         log.info("入驻审核驳回: licenseId={}, userId={}, userName={}, reviewerId={}, reason={}",
                 licenseId, userId, userName, reviewerId, rejectReason);
+
+        // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),失败不阻塞主流程
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                syncShopResourceToThirdParty(submitter, license);
+            }
+        });
     }
 
     /**
@@ -348,6 +623,8 @@ public class BusinessLicenseService {
     @lombok.NoArgsConstructor
     @lombok.AllArgsConstructor
     public static class BusinessLicenseSaveRequest {
+        /** 客户用户ID(管理员新增时必填,用户自主提交时由 Service 注入) */
+        private Long userId;
         private String storeName;
         private String terminalType;
         private String province;

+ 47 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.user.entity.OperationAuditLog;
 import com.xuekairui.user.enums.AuditResult;
+import com.xuekairui.user.enums.OperationSource;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.OperationAuditLogMapper;
@@ -109,6 +110,41 @@ public class OperationAuditLogService {
         fillRequestInfo(log);
         logOperation(log);
     }
+
+    /**
+     * 便捷方法:记录成功操作(带数据快照 + 操作来源)
+     */
+    public void logSuccess(Long operatorId, OperatorRole operatorRole,
+                           OperationType operationType, String targetIdentifier, Long targetId,
+                           Object beforeData, Object afterData, String remark,
+                           OperationSource operationSource) {
+        AuditLogBuilder builder = AuditLogBuilder.builder()
+                .operatorId(operatorId)
+                .operatorName(operatorRole.getDescription())
+                .operatorRole(operatorRole.getCode())
+                .operationType(operationType.getCode())
+                .operationSource(operationSource != null ? operationSource.getCode() : null)
+                .module(operationType.getModule())
+                .description(operationType.getDescription())
+                .targetIdentifier(targetIdentifier)
+                .targetId(targetId)
+                .targetType(operationType.getTargetType())
+                .result(AuditResult.SUCCESS.getCode())
+                .isSensitive(operationType.isSensitive())
+                .remark(remark);
+
+        if (beforeData != null) {
+            builder.beforeData(toJson(beforeData));
+        }
+        if (afterData != null) {
+            builder.afterData(toJson(afterData));
+        }
+
+        OperationAuditLog log = builder.build();
+        fillRequestInfo(log);
+        logOperation(log);
+    }
+
     /**
      * 便捷方法:记录成功操作(带数据快照)
      */
@@ -191,6 +227,7 @@ public class OperationAuditLogService {
     public Page<OperationAuditLog> queryAuditLogs(String module, String operationType, 
                                                    Long operatorId, LocalDateTime startTime, 
                                                    LocalDateTime endTime, Boolean isSensitive,
+                                                   String operationSource,
                                                    int pageNum, int pageSize) {
         Page<OperationAuditLog> page = new Page<>(pageNum, pageSize);
         
@@ -214,6 +251,9 @@ public class OperationAuditLogService {
         if (isSensitive != null) {
             wrapper.eq(OperationAuditLog::getIsSensitive, isSensitive);
         }
+        if (operationSource != null && !operationSource.isEmpty()) {
+            wrapper.eq(OperationAuditLog::getOperationSource, operationSource);
+        }
         
         wrapper.orderByDesc(OperationAuditLog::getOperationTime);
         
@@ -279,6 +319,7 @@ public class OperationAuditLogService {
         private LocalDateTime operationTime;
         private String module;
         private String operationType;
+        private String operationSource;
         private String description;
         private String targetType;
         private Long targetId;
@@ -328,6 +369,11 @@ public class OperationAuditLogService {
             return this;
         }
 
+        public AuditLogBuilder operationSource(String operationSource) {
+            this.operationSource = operationSource;
+            return this;
+        }
+
         public AuditLogBuilder description(String description) {
             this.description = description;
             return this;
@@ -379,6 +425,7 @@ public class OperationAuditLogService {
             log.setOperationTime(this.operationTime != null ? this.operationTime : LocalDateTime.now());
             log.setModule(this.module);
             log.setOperationType(this.operationType);
+            log.setOperationSource(this.operationSource);
             log.setDescription(this.description);
             log.setTargetType(this.targetType);
             log.setTargetId(this.targetId);