فهرست منبع

界面信息更新

liuchengsen 1 ماه پیش
والد
کامیت
91affcfdfa
21فایلهای تغییر یافته به همراه1505 افزوده شده و 189 حذف شده
  1. 8 0
      deploy/.env.example
  2. 83 14
      docs/第三方登录接口文档.md
  3. 4 0
      zhijiayun-gateway/src/main/resources/application.yml
  4. 38 0
      zhijiayun-gateway/src/main/resources/db/migration-v13.sql
  5. 22 10
      zhijiayun-gateway/src/test/resources/test-schema.sql
  6. 215 2
      zhijiayun-test/src/main/resources/static/activity-test.html
  7. 2 1
      zhijiayun-user/src/main/java/com/xuekairui/user/config/MyBatisPlusMetaHandler.java
  8. 17 89
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java
  9. 116 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminSystemConfigController.java
  10. 5 11
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java
  11. 22 54
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/BusinessLicenseController.java
  12. 103 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/BusinessLicenseReviewResponse.java
  13. 20 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java
  14. 1 1
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/BusinessLicense.java
  15. 81 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/SystemConfig.java
  16. 68 0
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/LicenseStatus.java
  17. 27 2
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/BusinessLicenseMapper.java
  18. 29 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SystemConfigMapper.java
  19. 103 5
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  20. 264 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java
  21. 277 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/SystemConfigService.java

+ 8 - 0
deploy/.env.example

@@ -35,6 +35,14 @@ WECHAT_REDIRECT_URI=https://your-domain.com/api/auth/wechat/callback
 # ---- 邀请链接基础地址(生产域名) ----
 INVITE_BASE_URL=https://your-domain.com
 
+# ---- 外部系统 API Key(第三方登录) ----
+# 注意:API Key 已改为通过运营后台的全局系统配置管理,无需环境变量
+# 运营端路径:/api/admin/system-config(configType=EXTERNAL_API,需要管理员权限)
+# 系统启动时会自动从 t_system_config 表加载默认 Key
+# 生产环境请务必通过运营端修改默认 Key 并配置 IP 白名单
+# 如需覆盖默认值(启动前),可设置以下环境变量(建议用运营端管理):
+# EXTERNAL_MINIAPP_API_KEY=请替换为强随机字符串
+
 # ---- 支付宝电脑网站支付(公钥模式优先) ----
 ALIPAY_APP_ID=请填入支付宝应用ID
 ALIPAY_NOTIFY_URL=https://priceapi.kailin.com.cn/api/payment/callback/alipay

+ 83 - 14
docs/第三方登录接口文档.md

@@ -1,8 +1,8 @@
 # 第三方登录接口文档
 
-> 版本:V1.1  
-> 更新日期:2026-07-07  
-> 变更说明:扩展请求参数,支持传入用户名、药店名称、地址、联系人等用户信息
+> 版本:V1.2  
+> 更新日期:2026-07-09  
+> 变更说明:新增入驻信息字段(店铺名称、终端类型、资质图片URL等),同步写入 t_business_license 表
 
 ---
 
@@ -13,6 +13,7 @@
 **核心特性:**
 - 按手机号自动查找或注册用户
 - 支持第三方传入用户名、药店信息等,自动保存到用户表
+- 支持第三方传入入驻信息(资质图片URL等),同步写入 `t_business_license`,免去额外调用入驻接口
 - 不递增 `tokenVersion`,不踢出 PC 端已有会话
 - 新用户自动初始化 PLUS 会员
 
@@ -63,11 +64,11 @@ X-Api-Key: {我方提供的API Key}
 | `phone` | String | 是 | 用户手机号 |
 | `inviteCode` | String | 否 | 邀请码(仅新用户注册时生效) |
 
-#### 用户信息字段(第三方传入,自动保存)
+#### 用户信息字段(第三方传入,自动保存到用户表
 
 | 参数名 | 类型 | 必填 | 说明 | 对应我方字段 |
 |--------|------|------|------|--------------|
-| `username` | String | 否 | 用户名(保存到 nickname 字段,优先级高于 nickname) | `nickname` |
+| `username` | String | 否 | 用户名(保存到 nickname 和 userName 字段,优先级高于 nickname) | `nickname` / `userName` |
 | `nickname` | String | 否 | 昵称(不传则使用 username,再不传则按手机号生成) | `nickname` |
 | `pharmacyName` | String | 否 | 药店名称 | `pharmacy_name` |
 | `province` | String | 否 | 省 | `province` |
@@ -76,8 +77,25 @@ X-Api-Key: {我方提供的API Key}
 | `pharmacyAddress` | String | 否 | 药店详细地址 | `pharmacy_address` |
 | `contactPerson` | String | 否 | 联系人 | `contact_person` |
 | `contactPhone` | String | 否 | 联系电话 | `contact_phone` |
-| `businessLicenseNo` | String | 否 | 营业执照号 | `business_license_no` |
+| `businessLicenseNo` | String | 否 | 营业执照号(统一社会信用代码) | `business_license_no` |
 
+#### 入驻信息字段(对应 /api/business-license/upload,均为可选,传入后同步写入 t_business_license)
+
+| 参数名 | 类型 | 必填 | 说明 | 对应我方字段 |
+|--------|------|------|------|--------------|
+| `storeName` | String | 否 | 店铺名称(不传则使用 pharmacyName) | `store_name` |
+| `terminalType` | String | 否 | 终端类型:`1`-单店 / `2`-连锁 / `3`-诊所、社康等 | `terminal_type` |
+| `businessLicenseUrl` | String | 否 | 营业执照图片URL | `license_image_url` |
+| `drugLicenseUrl` | String | 否 | 药品经营许可证图片URL | `drug_license_url` |
+| `medicalDeviceClass2Url` | String | 否 | 二类医疗器械备案图片URL | `medical_device_class2_url` |
+| `medicalDeviceClass3Url` | String | 否 | 三类医疗器械备案图片URL | `medical_device_class3_url` |
+
+> 💡 **入驻信息同步规则**:
+> - 只要请求中包含任一入驻信息字段(storeName / terminalType / businessLicenseUrl / drugLicenseUrl / medicalDeviceClass2Url / medicalDeviceClass3Url),即自动写入 `t_business_license` 表
+> - 新用户自动创建执照记录(`review_status=PENDING`,`sync_source=MINIAPP_SYNC`)
+> - 已存在用户仅更新非空字段(不会清空已有数据),审核状态重置为待审核
+> - 省/市/区/地址/联系人/电话等字段会自动从用户信息字段中提取填入
+>
 > 💡 **字段更新规则**:
 > - 新用户会写入所有传入字段
 > - 已存在用户仅更新非空字段(不会清空已有数据)
@@ -96,13 +114,14 @@ X-Api-Key: {我方提供的API Key}
 }
 ```
 
-#### 完整请求(含用户信息)
+#### 完整请求(含用户信息 + 入驻信息
 
 ```json
 {
   "phone": "13800138000",
   "inviteCode": "ABC123",
   "username": "yaohuigou001",
+  "nickname": "药汇购001",
   "pharmacyName": "北京同仁堂大药房",
   "province": "北京市",
   "city": "北京市",
@@ -110,7 +129,13 @@ X-Api-Key: {我方提供的API Key}
   "pharmacyAddress": "建国路88号",
   "contactPerson": "张三",
   "contactPhone": "13800138000",
-  "businessLicenseNo": "91110105MA12345678"
+  "businessLicenseNo": "91110105MA12345678",
+  "storeName": "北京同仁堂大药房(朝阳分店)",
+  "terminalType": "1",
+  "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
+  "drugLicenseUrl": "https://oss.example.com/ypjy.jpg",
+  "medicalDeviceClass2Url": "https://oss.example.com/elqx.jpg",
+  "medicalDeviceClass3Url": "https://oss.example.com/slqx.jpg"
 }
 ```
 
@@ -251,6 +276,7 @@ Authorization: Bearer {currentAccessToken}
 | 6 | 每次刷新后旧refreshToken立即作废,请保存最新的 |
 | 7 | 用户信息字段(username、药店信息等)每次调用都会同步更新,建议传入最新数据 |
 | 8 | `username` 会保存到我方的 `nickname` 字段,不需要额外维护用户名表 |
+| 9 | 传入入驻信息字段后自动创建/更新入驻记录,无需再调用 `/api/business-license/upload` |
 
 ---
 
@@ -272,7 +298,11 @@ curl -X POST "https://your-domain.com/api/auth/external/miniapp-token" \
     "pharmacyAddress": "建国路88号",
     "contactPerson": "张三",
     "contactPhone": "13800138000",
-    "businessLicenseNo": "91110105MA12345678"
+    "businessLicenseNo": "91110105MA12345678",
+    "storeName": "北京同仁堂大药房(朝阳分店)",
+    "terminalType": "1",
+    "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
+    "drugLicenseUrl": "https://oss.example.com/ypjy.jpg"
   }'
 ```
 
@@ -284,7 +314,18 @@ OkHttpClient client = new OkHttpClient();
 String json = "{"
     + "\"phone\": \"13800138000\","
     + "\"username\": \"yaohuigou001\","
-    + "\"pharmacyName\": \"北京同仁堂大药房\""
+    + "\"pharmacyName\": \"北京同仁堂大药房\","
+    + "\"province\": \"北京市\","
+    + "\"city\": \"北京市\","
+    + "\"district\": \"朝阳区\","
+    + "\"pharmacyAddress\": \"建国路88号\","
+    + "\"contactPerson\": \"张三\","
+    + "\"contactPhone\": \"13800138000\","
+    + "\"businessLicenseNo\": \"91110105MA12345678\","
+    + "\"storeName\": \"北京同仁堂大药房(朝阳分店)\","
+    + "\"terminalType\": \"1\","
+    + "\"businessLicenseUrl\": \"https://oss.example.com/yyzz.jpg\","
+    + "\"drugLicenseUrl\": \"https://oss.example.com/ypjy.jpg\""
     + "}";
 
 RequestBody body = RequestBody.create(json, MediaType.parse("application/json"));
@@ -316,7 +357,15 @@ data = {
     "pharmacyName": "北京同仁堂大药房",
     "province": "北京市",
     "city": "北京市",
-    "district": "朝阳区"
+    "district": "朝阳区",
+    "pharmacyAddress": "建国路88号",
+    "contactPerson": "张三",
+    "contactPhone": "13800138000",
+    "businessLicenseNo": "91110105MA12345678",
+    "storeName": "北京同仁堂大药房(朝阳分店)",
+    "terminalType": "1",
+    "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
+    "drugLicenseUrl": "https://oss.example.com/ypjy.jpg"
 }
 
 response = requests.post(url, json=data, headers=headers)
@@ -329,12 +378,14 @@ print(response.json())
 
 第三方传入字段与我方数据库字段的对应关系:
 
+### 10.1 用户信息(写入 t_users)
+
 | 第三方传入字段 | 我方数据库字段 | 我方实体字段 | 说明 |
 |----------------|----------------|--------------|------|
 | `phone` | `phone` | `phone` | 手机号(必填) |
-| `username` | `nickname` | `nickname` | 用户名(保存到 nickname) |
+| `username` | `nickname` | `nickname` | 用户名(同时写入 nickname 和 userName) |
 | `nickname` | `nickname` | `nickname` | 昵称(优先级低于 username) |
-| `pharmacyName` | `pharmacy_name` | `pharmacyName` | 药店名称 |
+| `pharmacyName` | `pharmacy_name` | `pharmacyName` | 药店名称(也作为 storeName 兜底) |
 | `province` | `province` | `province` | 省 |
 | `city` | `city` | `city` | 市 |
 | `district` | `district` | `district` | 区 |
@@ -343,6 +394,23 @@ print(response.json())
 | `contactPhone` | `contact_phone` | `contactPhone` | 联系电话 |
 | `businessLicenseNo` | `business_license_no` | `businessLicenseNo` | 营业执照号 |
 
+### 10.2 入驻信息(写入 t_business_license,全部可选)
+
+| 第三方传入字段 | 我方数据库字段 | 我方实体字段 | 说明 |
+|----------------|----------------|--------------|------|
+| `storeName` | `store_name` | `storeName` | 店铺名称(不传则用 pharmacyName) |
+| `terminalType` | `terminal_type` | `terminalType` | 终端类型 |
+| `businessLicenseUrl` | `license_image_url` | `licenseImageUrl` | 营业执照图片URL |
+| `drugLicenseUrl` | `drug_license_url` | `drugLicenseUrl` | 药品经营许可证图片URL |
+| `medicalDeviceClass2Url` | `medical_device_class2_url` | `medicalDeviceClass2Url` | 二类医疗器械备案图片URL |
+| `medicalDeviceClass3Url` | `medical_device_class3_url` | `medicalDeviceClass3Url` | 三类医疗器械备案图片URL |
+
+> 💡 入驻记录保存时,以下字段由系统自动填入(无需第三方传入):
+> - `store_address` ← 从 `pharmacyAddress` 提取
+> - `credit_code` ← 从 `businessLicenseNo` 提取
+> - `sync_source` ← 固定为 `MINIAPP_SYNC`
+> - `review_status` ← 固定为 `PENDING`(待运营审核)
+
 ---
 
 ## 11. 联系方式
@@ -356,4 +424,5 @@ print(response.json())
 | 版本 | 日期 | 变更内容 |
 |------|------|----------|
 | V1.0 | 2026-07-07 | 初始版本:基础登录接口 |
-| V1.1 | 2026-07-07 | 扩展请求参数:支持传入用户名、药店信息等,自动保存到用户表 |
+| V1.1 | 2026-07-07 | 扩展请求参数:支持传入用户名、药店信息等,自动保存到用户表 |
+| V1.2 | 2026-07-09 | 新增入驻信息字段(storeName / terminalType / businessLicenseUrl / drugLicenseUrl / medicalDeviceClass2Url / medicalDeviceClass3Url),传入后同步写入 t_business_license,无需额外调用入驻接口 |

+ 4 - 0
zhijiayun-gateway/src/main/resources/application.yml

@@ -43,6 +43,10 @@ wechat:
     app-secret: ${WECHAT_MINIAPP_APP_SECRET:}
 
 # 外部系统(小程序后台)API 配置
+# 注意:API Key 鉴权已升级为数据库管理(t_system_config 表,configType=EXTERNAL_API,config_key=miniapp_external_api)
+# config_value JSON 中包含 apiKey 和 allowedIps 字段
+# 运营端管理路径:/api/admin/system-config(按 configType 筛选)
+# 以下 yml 配置仅用于数据库无匹配记录时的兜底,正式使用请通过运营端管理
 external-system:
   miniapp:
     base-url: ${EXTERNAL_MINIAPP_API_URL:}

+ 38 - 0
zhijiayun-gateway/src/main/resources/db/migration-v13.sql

@@ -0,0 +1,38 @@
+,-- ============================================
+-- 迁移 v13:全局系统配置表(替换 t_external_api_config)
+-- 说明:
+--   1. 新建 t_system_config 通用键值表,支持按 config_type 分组
+--   2. config_key 为语义化标识符(如 miniapp_external_api),用于定位配置项
+--   3. config_value 为 JSON,包含该配置项的所有参数(如 apiKey、allowedIps)
+--   4. 后续新增配置类型只需在运营端新增记录,无需代码改动
+-- ============================================
+
+-- 删除旧的专用表(上一轮临时创建的,v13 尚未上线,安全删除)
+DROP TABLE IF EXISTS `t_external_api_config`;
+
+-- 全局系统配置表
+CREATE TABLE IF NOT EXISTS `t_system_config` (
+    `id`            BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `config_key`    VARCHAR(256) NOT NULL COMMENT '配置键(语义化标识,如 miniapp_external_api,用于程序按 key 获取配置)',
+    `config_value`  TEXT         DEFAULT NULL COMMENT '配置值(JSON 格式,包含该配置项的所有参数,如 {\"apiKey\":\"xxx\",\"allowedIps\":\"...\"})',
+    `config_type`   VARCHAR(64)  NOT NULL DEFAULT 'GENERAL' COMMENT '配置类型/分组:EXTERNAL_API-外部API / GENERAL-通用',
+    `config_name`   VARCHAR(128) NOT NULL COMMENT '配置名称(中文描述,如"小程序后台外部API")',
+    `enabled`       TINYINT(1)   DEFAULT 1 COMMENT '是否启用:0-禁用 1-启用',
+    `remark`        VARCHAR(255) DEFAULT '' COMMENT '备注说明',
+    `create_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_config_key` (`config_key`),
+    INDEX `idx_config_type` (`config_type`),
+    INDEX `idx_enabled` (`enabled`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全局系统配置表(键值对模式,运营端可管理任意系统配置)';
+
+-- 初始数据:小程序外部 API 配置(config_key 为语义标识,apiKey 存在 config_value JSON 中)
+INSERT INTO `t_system_config` (`config_key`, `config_value`, `config_type`, `config_name`, `enabled`, `remark`) VALUES
+    ('miniapp_external_api',
+     '{"apiKey":"a3f8c21e67b94d0e5f1a6c8d3b9e2071","allowedIps":""}',
+     'EXTERNAL_API',
+     '小程序后台外部API配置',
+     1,
+     '系统默认初始化配置,请尽快通过运营端修改 API Key 并配置 IP 白名单')
+ON DUPLICATE KEY UPDATE id = id;

+ 22 - 10
zhijiayun-gateway/src/test/resources/test-schema.sql

@@ -98,18 +98,10 @@ CREATE TABLE IF NOT EXISTS t_membership_benefit (
     create_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
     update_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_level (level),
-    INDEX idx_status (status)
+    INDEX idx_status (status),
+    UNIQUE INDEX uk_level_type (level, benefit_type)
 );
 
--- 会员权益表唯一索引(允许 INSERT ON DUPLICATE KEY UPDATE)
--- 先清理重复数据,再创建唯一索引
-DELETE t1 FROM t_membership_benefit t1 INNER JOIN t_membership_benefit t2 WHERE t1.id > t2.id AND t1.level = t2.level AND t1.benefit_type = t2.benefit_type;
-SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_membership_benefit' AND INDEX_NAME = 'uk_level_type');
-SET @sql = IF(@idx_exists = 0, 'ALTER TABLE t_membership_benefit ADD UNIQUE INDEX uk_level_type (level, benefit_type)', 'SELECT 1');
-PREPARE stmt FROM @sql;
-EXECUTE stmt;
-DEALLOCATE PREPARE stmt;
-
 CREATE TABLE IF NOT EXISTS t_coupon (
     id              BIGINT AUTO_INCREMENT PRIMARY KEY,
     name            VARCHAR(64)   NOT NULL,
@@ -611,3 +603,23 @@ INSERT IGNORE INTO t_payment_plan (plan_code, plan_name, membership_level, price
     ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量· 1个月29.9(原价59.9)'),
     ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量· 1年299.9(原价599.9)'),
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待');
+
+-- 全局系统配置表
+CREATE TABLE IF NOT EXISTS t_system_config (
+    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
+    config_key    VARCHAR(256) NOT NULL,
+    config_value  TEXT         DEFAULT NULL,
+    config_type   VARCHAR(64)  NOT NULL DEFAULT 'GENERAL',
+    config_name   VARCHAR(128) NOT NULL,
+    enabled       TINYINT(1)   DEFAULT 1,
+    remark        VARCHAR(255) DEFAULT '',
+    create_time   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
+    update_time   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_config_key (config_key),
+    INDEX idx_config_type (config_type),
+    INDEX idx_enabled (enabled)
+);
+
+-- 测试用默认外部 API 配置(config_key 为语义标识,apiKey 存在 config_value JSON 中)
+INSERT IGNORE INTO t_system_config (config_key, config_value, config_type, config_name, enabled, remark) VALUES
+    ('miniapp_external_api', '{"apiKey":"a3f8c21e67b94d0e5f1a6c8d3b9e2071","allowedIps":""}', 'EXTERNAL_API', '测试配置', 1, '测试环境默认API Key');

+ 215 - 2
zhijiayun-test/src/main/resources/static/activity-test.html

@@ -192,6 +192,7 @@
         <div class="module-tab" data-module="activity-available">5.我可参加的活动</div>
         <div class="module-tab" data-module="activity-rewards-tab">6.各种活动奖励</div>
         <div class="module-tab" data-module="search-record">7.搜索记录</div>
+        <div class="module-tab" data-module="external-login">8.第三方登录</div>
     </div>
 
     <!-- 运营侧模块标签栏 -->
@@ -201,6 +202,7 @@
         <div class="module-tab" data-module="admin-activity-checkin">3.签到送会员管理</div>
         <div class="module-tab" data-module="admin-activity-lottery">4.比价抽奖管理</div>
         <div class="module-tab" data-module="admin-license">5.入驻信息审核</div>
+        <div class="module-tab" data-module="admin-system-config">6.系统配置管理</div>
     </div>
 
     <div id="panels"></div>
@@ -218,8 +220,8 @@ const SMS_CODE = () => document.getElementById('smsCode')?.value || '123456';
 
 let stats = { pass: 0, fail: 0, pending: 0, total: 0 };
 
-const USER_MODULES = ['checkin', 'activity-invite', 'activity-lottery', 'business-license', 'activity-available', 'activity-rewards-tab', 'search-record'];
-const ADMIN_MODULES = ['admin-checkin', 'admin-activity-invite', 'admin-activity-checkin', 'admin-activity-lottery', 'admin-license'];
+const USER_MODULES = ['checkin', 'activity-invite', 'activity-lottery', 'business-license', 'activity-available', 'activity-rewards-tab', 'search-record', 'external-login'];
+const ADMIN_MODULES = ['admin-checkin', 'admin-activity-invite', 'admin-activity-checkin', 'admin-activity-lottery', 'admin-license', 'admin-system-config'];
 
 // ===================== 大整数安全JSON解析 =====================
 function safeJsonParse(text) {
@@ -440,11 +442,13 @@ function renderModule(module) {
         case 'activity-available': html = renderActivityAvailableModule(); break;
         case 'activity-rewards-tab': html = renderActivityRewardsModule(); break;
         case 'search-record': html = renderSearchRecordModule(); break;
+        case 'external-login': html = renderExternalLoginModule(); break;
         case 'admin-checkin': html = renderAdminCheckinModule(); break;
         case 'admin-activity-invite': html = renderAdminActivityInviteModule(); break;
         case 'admin-activity-checkin': html = renderAdminActivityCheckinModule(); break;
         case 'admin-activity-lottery': html = renderAdminActivityLotteryModule(); break;
         case 'admin-license': html = renderAdminLicenseModule(); break;
+        case 'admin-system-config': html = renderAdminSystemConfigModule(); break;
     }
     panels.innerHTML = html;
     panels.querySelectorAll('input[data-raw]').forEach(input => {
@@ -793,6 +797,48 @@ const FIELD_DESC = {
         ['data.prices[].stockStatus', 'String', '库存状态(IN_STOCK/LOW_STOCK/OUT_OF_STOCK)'],
         ['data.prices[].isLowest', 'Boolean', '是否为最低价'],
         ['data.prices[].purchaseUrl', 'String', '平台跳转链接']
+    ],
+    'externalLoginResponse': [
+        ['data.userId', 'String', '用户ID(字符串,避免JS大数精度丢失)'],
+        ['data.accessToken', 'String', '访问令牌'],
+        ['data.refreshToken', 'String', '刷新令牌'],
+        ['data.expiresIn', 'Long', 'accessToken有效期(秒),默认7200'],
+        ['data.newUser', 'Boolean', '是否为新注册用户'],
+        ['data.needBindPhone', 'Boolean', '始终返回false'],
+        ['data.role', 'String', '固定值"USER"'],
+        ['data.userInfo.id', 'String', '用户ID'],
+        ['data.userInfo.phone', 'String', '手机号(脱敏)'],
+        ['data.userInfo.nickname', 'String', '昵称(即第三方传入的username或nickname)'],
+        ['data.userInfo.avatar', 'String', '头像URL'],
+        ['data.userInfo.wechatBound', 'Boolean', '是否已绑定微信'],
+        ['data.userInfo.loginSource', 'String', '登录来源(MINIAPP)'],
+        ['data.userInfo.membershipLevel', 'String', '会员等级代码'],
+        ['data.userInfo.membershipLevelName', 'String', '会员等级名称'],
+        ['data.userInfo.createTime', 'String', '注册时间']
+    ],
+    'systemConfig': [
+        ['data.id', 'Long', '配置ID'],
+        ['data.configKey', 'String', '配置键(语义化标识,如 miniapp_external_api,程序通过此key定位配置)'],
+        ['data.configValue', 'String', '配置值(JSON格式,包含该配置项所有参数,如{"apiKey":"xxx","allowedIps":"..."})'],
+        ['data.configType', 'String', '配置类型:EXTERNAL_API / GENERAL'],
+        ['data.configName', 'String', '配置名称(中文描述)'],
+        ['data.enabled', 'Boolean', '是否启用'],
+        ['data.remark', 'String', '备注'],
+        ['data.createTime', 'DateTime', '创建时间'],
+        ['data.updateTime', 'DateTime', '更新时间']
+    ],
+    'systemConfigList': [
+        ['data.records[].id', 'Long', '配置ID'],
+        ['data.records[].configKey', 'String', '配置键'],
+        ['data.records[].configValue', 'String', '配置值JSON'],
+        ['data.records[].configType', 'String', '配置类型'],
+        ['data.records[].configName', 'String', '配置名称'],
+        ['data.records[].enabled', 'Boolean', '是否启用'],
+        ['data.records[].remark', 'String', '备注'],
+        ['data.total', 'Long', '总记录数'],
+        ['data.current', 'Long', '当前页'],
+        ['data.size', 'Long', '每页条数'],
+        ['data.pages', 'Long', '总页数']
     ]
 };
 
@@ -1053,6 +1099,61 @@ function renderSearchRecordModule() {
     ${testSection('sr-clear', '清空记录', 'DELETE', 'delete', '/api/search-record/history', '需认证', 'auth-required', null, false, null, '清空当前用户全部搜索记录')}`;
 }
 
+// ==================== 用户端:第三方登录模块(API Key鉴权) ====================
+function renderExternalLoginModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runExternalLoginTests()">运行第三方登录测试</button>
+    </div>
+    <div class="test-section">
+        <div class="section-title">
+            <span class="method method-post">POST</span>
+            <span class="path">/api/auth/external/miniapp-token</span>
+            <span class="side-tag side-user">用户侧</span>
+            <span class="auth-public auth-tag">公开(API Key)</span>
+        </div>
+        <div class="section-desc">第三方系统通过API Key换取用户登录Token(免密登录)。新用户自动注册并初始化PLUS会员,已存在用户更新传入信息。鉴权方式:请求头 X-Api-Key</div>
+        <details class="field-desc-details">
+            <summary class="field-desc-summary">📞 cURL 调用示例</summary>
+            <pre class="curl-example"># 第三方系统通过API Key换取Token:
+curl -X POST "$" + "{BASE_URL}/api/auth/external/miniapp-token" \\
+  -H "Content-Type: application/json" \\
+  -H "X-Api-Key: $" + "{API_KEY}" \\
+  -d '{"phone":"13800138000","username":"yaohuigou001","pharmacyName":"XX大药房","province":"广东省","city":"深圳市","district":"南山区"}'</pre>
+        </details>
+        ${renderFieldDesc('externalLoginResponse')}
+        <div class="form-grid">
+            <div class="form-group"><label>API Key(X-Api-Key) *</label><input type="text" id="external-login-apikey" placeholder="第三方系统的API Key" value=""></div>
+            <div class="form-group"><label>手机号(phone) *</label><input type="text" id="external-login-phone" placeholder="13800138000" value="18776156917"></div>
+            <div class="form-group"><label>邀请码(inviteCode)</label><input type="text" id="external-login-inviteCode" placeholder="仅新用户注册时生效" value=""></div>
+            <div class="form-group"><label>用户名(username)</label><input type="text" id="external-login-username" placeholder="保存到nickname字段" value="yaohuigou001"></div>
+            <div class="form-group"><label>昵称(nickname)</label><input type="text" id="external-login-nickname" placeholder="优先级低于username" value=""></div>
+            <div class="form-group"><label>药店名称(pharmacyName)</label><input type="text" id="external-login-pharmacyName" placeholder="XX大药房" value=""></div>
+            <div class="form-group"><label>省(province)</label><input type="text" id="external-login-province" placeholder="广东省" value=""></div>
+            <div class="form-group"><label>市(city)</label><input type="text" id="external-login-city" placeholder="深圳市" value=""></div>
+            <div class="form-group"><label>区(district)</label><input type="text" id="external-login-district" placeholder="南山区" value=""></div>
+            <div class="form-group"><label>药店地址(pharmacyAddress)</label><input type="text" id="external-login-pharmacyAddress" placeholder="科技园路1号" value=""></div>
+            <div class="form-group"><label>联系人(contactPerson)</label><input type="text" id="external-login-contactPerson" placeholder="张三" value=""></div>
+            <div class="form-group"><label>联系电话(contactPhone)</label><input type="text" id="external-login-contactPhone" placeholder="13800138000" value=""></div>
+            <div class="form-group"><label>营业执照号(businessLicenseNo)</label><input type="text" id="external-login-businessLicenseNo" placeholder="91110105MA12345678" value=""></div>
+            <div class="form-group"><label>店铺名称(storeName)</label><input type="text" id="external-login-storeName" placeholder="入驻信息专用,不传则用pharmacyName" value=""></div>
+            <div class="form-group"><label>终端类型(terminalType)</label><input type="text" id="external-login-terminalType" placeholder="SINGLE/CHAIN/CLINIC/COMMUNITY_HEALTH" value=""></div>
+            <div class="form-group"><label>营业执照图片(businessLicenseUrl)</label><input type="text" id="external-login-businessLicenseUrl" placeholder="https://oss.example.com/yyzz.jpg" value=""></div>
+            <div class="form-group"><label>药品经营许可证(drugLicenseUrl)</label><input type="text" id="external-login-drugLicenseUrl" placeholder="https://oss.example.com/ypjy.jpg" value=""></div>
+            <div class="form-group"><label>二类医疗器械备案(medicalDeviceClass2Url)</label><input type="text" id="external-login-medicalDeviceClass2Url" placeholder="https://oss.example.com/elqx.jpg" value=""></div>
+            <div class="form-group"><label>三类医疗器械备案(medicalDeviceClass3Url)</label><input type="text" id="external-login-medicalDeviceClass3Url" placeholder="https://oss.example.com/slqx.jpg" value=""></div>
+        </div>
+        <button class="btn btn-primary" onclick="runExternalMiniappToken()">执行测试</button>
+        <div class="response-box" id="external-login-box" style="display:none;">
+            <div class="response-header">
+                <span class="response-title">响应 <span id="external-login-duration" style="color:#999;font-weight:400;"></span></span>
+                <span class="status-badge" id="external-login-status"></span>
+            </div>
+            <div class="response-content" id="external-login-raw"></div>
+        </div>
+    </div>`;
+}
+
 // ==================== 运营端:签到管理模块 ====================
 function renderAdminCheckinModule() {
     return `
@@ -1236,6 +1337,47 @@ function renderAdminLicenseModule() {
          {label:'审核动作(action)', name:'action', placeholder:'REJECTED', default:'REJECTED'},
          {label:'拒绝原因(rejectReason)', name:'rejectReason', placeholder:'图片不清晰', default:''}
         ], true, null, '拒绝入驻信息申请,需填写拒绝原因供申请人查看')}`;
+};
+
+// ==================== 运营端:系统配置管理模块 ====================
+function renderAdminSystemConfigModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runAdminSystemConfigTests()">运行全部系统配置测试</button>
+    </div>
+    ${testSection('admin-system-config-list', '分页查询配置列表', 'GET', 'get', '/api/admin/system-config/list?configType={configType}&page={page}&size={size}', '需认证', 'auth-required',
+        [
+         {label:'配置类型(configType,可选)', name:'configType', placeholder:'EXTERNAL_API / GENERAL', default:''},
+         {label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页(size)', name:'size', placeholder:'20', default:'20'}
+        ], true, 'systemConfigList', '分页查询系统配置列表,支持按configType筛选。configType可选值:EXTERNAL_API-外部API鉴权 / GENERAL-通用配置')}
+    ${testSection('admin-system-config-enabled', '查询启用的配置', 'GET', 'get', '/api/admin/system-config/enabled?configType={configType}', '需认证', 'auth-required',
+        [{label:'配置类型(configType,可选)', name:'configType', placeholder:'EXTERNAL_API / GENERAL', default:''}], true, 'systemConfig', '查询所有启用的配置列表(不分页),支持按configType筛选')}
+    ${testSection('admin-system-config-by-id', '按ID查询配置', 'GET', 'get', '/api/admin/system-config/{id}', '需认证', 'auth-required',
+        [{label:'配置ID(id)', name:'id', placeholder:'1', default:'1'}], true, 'systemConfig', '根据主键ID查询单条配置详情')}
+    ${testSection('admin-system-config-by-key', '按Key查询配置', 'GET', 'get', '/api/admin/system-config/by-key?key={key}', '需认证', 'auth-required',
+        [{label:'配置键(key)', name:'key', placeholder:'API Key字符串', default:''}], true, 'systemConfig', '根据configKey精确查询配置。EXTERNAL_API类型时key即为API Key本身')}
+    ${testSection('admin-system-config-create', '新增配置', 'POST', 'post', '/api/admin/system-config', '需认证', 'auth-required',
+        [
+         {label:'配置键(configKey) *', name:'configKey', placeholder:'API Key或自定义Key', default:'test-api-key-001'},
+         {label:'配置值(configValue)', name:'configValue', placeholder:'{"allowedIps":"192.168.1.1,10.0.0.2"}', default:'{"allowedIps":""}'},
+         {label:'配置类型(configType) *', name:'configType', placeholder:'EXTERNAL_API', default:'EXTERNAL_API'},
+         {label:'配置名称(configName) *', name:'configName', placeholder:'第三方API鉴权配置', default:'测试API鉴权配置'},
+         {label:'启用(enabled)', name:'enabled', placeholder:'true/false', default:'true', type:'string'},
+         {label:'备注(remark)', name:'remark', placeholder:'可选备注', default:'测试用配置'}
+        ], true, 'systemConfig', '新增一条系统配置。EXTERNAL_API类型:configKey填API Key,configValue填JSON如{"allowedIps":"192.168.1.1"},ip留空表示不限制IP')}
+    ${testSection('admin-system-config-update', '更新配置', 'PUT', 'put', '/api/admin/system-config/{id}', '需认证', 'auth-required',
+        [
+         {label:'配置ID(id)', name:'id', placeholder:'1', default:''},
+         {label:'配置键(configKey)', name:'configKey', placeholder:'API Key或自定义Key', default:''},
+         {label:'配置值(configValue)', name:'configValue', placeholder:'{"allowedIps":"192.168.1.1"}', default:'{"allowedIps":"192.168.1.1"}'},
+         {label:'配置类型(configType)', name:'configType', placeholder:'EXTERNAL_API', default:''},
+         {label:'配置名称(configName)', name:'configName', placeholder:'第三方API鉴权配置', default:''},
+         {label:'启用(enabled)', name:'enabled', placeholder:'true/false', default:'', type:'string'},
+         {label:'备注(remark)', name:'remark', placeholder:'可选备注', default:''}
+        ], true, 'systemConfig', '更新指定ID的系统配置。修改IP白名单后将立即生效(无需重启)')}
+    ${testSection('admin-system-config-delete', '删除配置', 'DELETE', 'delete', '/api/admin/system-config/{id}', '需认证', 'auth-required',
+        [{label:'配置ID(id)', name:'id', placeholder:'需删除的配置ID', default:''}], true, null, '删除指定ID的系统配置。删除后对应API Key将立即失效')}`;
 }
 
 // ===================== 批量测试 ====================
@@ -1403,6 +1545,7 @@ function runAdminActivityInviteTests() { runBatchTests('admin-activity-invite');
 function runAdminActivityCheckinTests() { runBatchTests('admin-activity-checkin'); }
 function runAdminActivityLotteryTests() { runBatchTests('admin-activity-lottery'); }
 function runAdminLicenseTests() { runBatchTests('admin-license'); }
+function runAdminSystemConfigTests() { runBatchTests('admin-system-config'); }
 function runSearchRecordTests() { runBatchTests('search-record'); }
 
 // ===================== 验证器 =====================
@@ -1448,6 +1591,15 @@ function validate_adminLicenseList(result) { return result.data?.code === 200 &&
 function validate_adminLicenseApprove(result) { return result.data?.code === 200; }
 function validate_adminLicenseReject(result) { return result.data?.code === 200; }
 
+// 系统配置管理模块
+function validate_adminSystemConfigList(result) { return result.data?.code === 200 && result.data?.data?.records !== undefined; }
+function validate_adminSystemConfigEnabled(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_adminSystemConfigById(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminSystemConfigByKey(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminSystemConfigCreate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminSystemConfigUpdate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminSystemConfigDelete(result) { return result.data?.code === 200; }
+
 // 搜索记录模块
 function validate_srSave(result) { return result.data?.code === 200 && result.data?.data?.id; }
 function validate_srList(result) { return result.data?.code === 200 && result.data?.data?.records !== undefined; }
@@ -1455,6 +1607,67 @@ function validate_srDetail(result) { return result.data?.code === 200 && result.
 function validate_srLotteryStatus(result) { return result.data?.code === 200 && result.data?.data?.enabled !== undefined; }
 function validate_srLotteryDraw(result) { return result.data?.code === 200 && result.data?.data?.prizeName !== undefined; }
 
+// 第三方登录模块
+function validate_externalLogin(result) { return result.data?.code === 200 && result.data?.data?.accessToken; }
+
+async function runExternalMiniappToken() {
+    const sectionId = 'external-login';
+    const apiKey = document.getElementById(sectionId + '-apikey').value;
+    if (!apiKey) { alert('请填写 API Key'); return; }
+
+    const fieldNames = ['phone', 'inviteCode', 'username', 'nickname', 'pharmacyName', 'province', 'city', 'district', 'pharmacyAddress', 'contactPerson', 'contactPhone', 'businessLicenseNo', 'storeName', 'terminalType', 'businessLicenseUrl', 'drugLicenseUrl', 'medicalDeviceClass2Url', 'medicalDeviceClass3Url'];
+    const body = {};
+    fieldNames.forEach(f => {
+        const val = document.getElementById(sectionId + '-' + f).value;
+        if (val) body[f] = val;
+    });
+    if (!body.phone) { alert('请填写手机号'); return; }
+
+    const box = document.getElementById(sectionId + '-box');
+    const statusEl = document.getElementById(sectionId + '-status');
+    const rawEl = document.getElementById(sectionId + '-raw');
+    const durEl = document.getElementById(sectionId + '-duration');
+
+    updateBadge(statusEl, 'pending', '运行中...');
+    if (box) box.style.display = 'block';
+
+    if (!stats.hasOwnProperty(sectionId)) {
+        stats.total++;
+        stats.pending = Math.max(0, stats.total - stats.pass - stats.fail);
+        document.getElementById('totalCount').textContent = stats.total;
+        document.getElementById('pendingCount').textContent = stats.pending;
+    }
+
+    const startTime = Date.now();
+    try {
+        const res = await fetch(BASE_URL() + '/api/auth/external/miniapp-token', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },
+            body: JSON.stringify(body)
+        });
+        const text = await res.text();
+        let data;
+        try { data = safeJsonParse(text); } catch (e) { data = { _raw: text }; }
+        const result = { ok: res.ok, status: res.status, data, duration: Date.now() - startTime };
+
+        if (durEl) durEl.textContent = result.duration + 'ms';
+        if (rawEl) rawEl.textContent = JSON.stringify(result.data, null, 2);
+
+        const passed = validate_externalLogin(result);
+        updateStats(sectionId, passed);
+        updateBadge(statusEl, passed ? 'success' : 'error', passed ? '通过 ' + result.status : '失败 ' + result.status);
+    } catch (e) {
+        const dur = Date.now() - startTime;
+        if (durEl) durEl.textContent = dur + 'ms';
+        if (rawEl) rawEl.textContent = JSON.stringify({ error: e.message }, null, 2);
+        updateStats(sectionId, false);
+        updateBadge(statusEl, 'error', '失败 ' + e.message);
+    }
+    if (box) box.style.display = 'block';
+}
+
+function runExternalLoginTests() { runExternalMiniappToken(); }
+
 // ===================== 初始化 =====================
 window.addEventListener('DOMContentLoaded', () => {
     const params = new URLSearchParams(window.location.search);

+ 2 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/config/MyBatisPlusMetaHandler.java

@@ -24,6 +24,7 @@ public class MyBatisPlusMetaHandler implements MetaObjectHandler {
 
     @Override
     public void updateFill(MetaObject metaObject) {
-        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now(ZONE_BEIJING));
+        // 使用 setFieldValByName 而非 strictUpdateFill,确保每次更新都刷新更新时间
+        this.setFieldValByName("updateTime", LocalDateTime.now(ZONE_BEIJING), metaObject);
     }
 }

+ 17 - 89
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java

@@ -1,14 +1,9 @@
 package com.xuekairui.user.controller;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.common.Result;
-import com.xuekairui.common.event.ActivityEventRedisService;
-import com.xuekairui.user.entity.BusinessLicense;
-import com.xuekairui.user.enums.MembershipLevel;
-import com.xuekairui.user.enums.OperationType;
-import com.xuekairui.user.mapper.BusinessLicenseMapper;
-import com.xuekairui.user.service.MembershipService;
-import com.xuekairui.user.service.OperationAuditLogService;
+import com.xuekairui.user.dto.BusinessLicenseReviewResponse;
+import com.xuekairui.user.enums.LicenseStatus;
+import com.xuekairui.user.service.BusinessLicenseService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
@@ -16,9 +11,6 @@ import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.transaction.annotation.Transactional;
-import org.springframework.transaction.support.TransactionSynchronization;
-import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -26,7 +18,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.time.LocalDateTime;
 import java.util.List;
 
 /**
@@ -38,99 +29,36 @@ import java.util.List;
 @RequiredArgsConstructor
 public class AdminLicenseController {
 
-    private final BusinessLicenseMapper businessLicenseMapper;
-    private final OperationAuditLogService auditLogService;
-    private final MembershipService membershipService;
-    private final ActivityEventRedisService activityEventRedisService;
+    private final BusinessLicenseService businessLicenseService;
 
+    /**
+     * 审核列表查询
+     * @param reviewStatus 审核状态筛选(可选):PENDING/APPROVED/REJECTED,不传则查全部
+     * @param keyword      手机号关键词(可选):匹配用户手机号或入驻联系电话
+     */
     @GetMapping("/review")
-    public Result<List<BusinessLicense>> listPendingReviews(
-            @RequestParam(required = false) String reviewStatus) {
-        String status = reviewStatus != null ? reviewStatus : "PENDING";
-        List<BusinessLicense> licenses = businessLicenseMapper.selectList(
-                new LambdaQueryWrapper<BusinessLicense>()
-                        .eq(BusinessLicense::getReviewStatus, status)
-                        .orderByDesc(BusinessLicense::getCreateTime));
-        return Result.success(licenses);
+    public Result<List<BusinessLicenseReviewResponse>> listPendingReviews(
+            @RequestParam(required = false) String reviewStatus,
+            @RequestParam(required = false) String keyword) {
+        return Result.success(businessLicenseService.listByReviewStatus(reviewStatus, keyword));
     }
 
-    @Transactional
     @PostMapping("/review")
     public Result<Void> reviewLicense(
             @Valid @RequestBody ReviewRequest request,
             HttpServletRequest httpRequest) {
         Long reviewerId = (Long) httpRequest.getAttribute("userId");
 
-        BusinessLicense license = businessLicenseMapper.selectById(request.getLicenseId());
-        if (license == null) {
-            return Result.error(404, "入驻信息记录不存在");
-        }
-        if (!"PENDING".equals(license.getReviewStatus())) {
-            return Result.error(400, "该入驻信息已审核,无法重复操作");
-        }
-
-        if ("APPROVED".equals(request.getAction())) {
-            license.setReviewStatus("APPROVED");
-            license.setShowVerifiedBadge(true);
-
-            // 入驻信息首次审核通过,赠送 30 天高级会员
-            Long userId = license.getUserId();
-            if (userId != null && !hasApprovedLicense(userId, license.getId())) {
-                membershipService.grantOrExtendMembership(
-                        userId,
-                        MembershipLevel.PRO,
-                        30,
-                        "LICENSE",
-                        "入驻信息审核通过赠送30天高级会员");
-                log.info("入驻信息审核通过,赠送用户{} 30天高级会员", userId);
-
-                // 存入 Redis 事件队列,事务提交后由活动模块消费(邀请有礼:被邀请人资质通过后发邀请人奖励)
-                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                    @Override
-                    public void afterCommit() {
-                        try {
-                            activityEventRedisService.pushLicenseApproved(userId, license.getId(), "BUSINESS_LICENSE");
-                        } catch (Exception e) {
-                            log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e);
-                        }
-                    }
-                });
-            }
-        } else if ("REJECTED".equals(request.getAction())) {
-            license.setReviewStatus("REJECTED");
-            license.setShowVerifiedBadge(false);
-            license.setRejectReason(request.getRejectReason());
+        if (LicenseStatus.APPROVED.name().equals(request.getAction())) {
+            businessLicenseService.approve(request.getLicenseId(), reviewerId);
+        } else if (LicenseStatus.REJECTED.name().equals(request.getAction())) {
+            businessLicenseService.reject(request.getLicenseId(), reviewerId, request.getRejectReason());
         } else {
             return Result.error(400, "审核动作无效,请使用 APPROVED 或 REJECTED");
         }
-
-        license.setReviewerId(reviewerId);
-        license.setReviewTime(LocalDateTime.now());
-        businessLicenseMapper.updateById(license);
-
-        log.info("入驻信息审核完成: licenseId={}, action={}, reviewerId={}",
-                request.getLicenseId(), request.getAction(), reviewerId);
-
-        // 记录审计日志
-        OperationType opType = "APPROVED".equals(request.getAction())
-                ? OperationType.LICENSE_APPROVE : OperationType.LICENSE_REJECT;
-        auditLogService.logAdmin(reviewerId, opType,
-                "LICENSE:" + license.getId(), license.getId());
         return Result.success();
     }
 
-    /**
-     * 检查用户是否已有其他审核通过的入驻信息(避免重复赠送会员)
-     */
-    private boolean hasApprovedLicense(Long userId, Long excludeLicenseId) {
-        Long count = businessLicenseMapper.selectCount(
-                new LambdaQueryWrapper<BusinessLicense>()
-                        .eq(BusinessLicense::getUserId, userId)
-                        .eq(BusinessLicense::getReviewStatus, "APPROVED")
-                        .ne(BusinessLicense::getId, excludeLicenseId));
-        return count != null && count > 0;
-    }
-
     @Data
     public static class ReviewRequest {
         @NotNull(message = "入驻信息ID不能为空")

+ 116 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminSystemConfigController.java

@@ -0,0 +1,116 @@
+package com.xuekairui.user.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.common.Result;
+import com.xuekairui.user.entity.SystemConfig;
+import com.xuekairui.user.service.SystemConfigService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 全局系统配置管理(运营端)
+ * <p>
+ * 管理员可通过此接口管理所有类型的系统配置(外部 API Key、业务开关等)。
+ * 按 configType 分组,修改后立即生效,无需重启服务。
+ * <p>
+ * <b>设计说明:</b>
+ * <ul>
+ *   <li>config_key — 语义化标识符(如 miniapp_external_api),程序通过 key 定位配置</li>
+ *   <li>config_value — JSON 格式,包含该配置项的所有参数</li>
+ *   <li>例:EXTERNAL_API 类型,config_key=miniapp_external_api,config_value={"apiKey":"xxx","allowedIps":"..."}</li>
+ * </ul>
+ * <p>
+ * <b>预定义 configType:</b>
+ * <ul>
+ *   <li>EXTERNAL_API — 外部 API 访问配置</li>
+ *   <li>GENERAL — 通用配置</li>
+ * </ul>
+ * <b>后续新增类型只需在此接口新增记录,无需代码改动。</b>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-09
+ */
+@RestController
+@RequestMapping("/api/admin/system-config")
+@RequiredArgsConstructor
+public class AdminSystemConfigController {
+
+    private final SystemConfigService systemConfigService;
+
+    /**
+     * 分页查询配置列表(可按 configType 筛选)
+     * <p>
+     * GET /api/admin/system-config/list?configType=EXTERNAL_API&page=1&size=20
+     */
+    @GetMapping("/list")
+    public Result<Page<SystemConfig>> list(
+            @RequestParam(required = false) String configType,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size) {
+        return Result.success(systemConfigService.listByType(configType, page, size));
+    }
+
+    /**
+     * 查询启用的配置列表
+     * <p>
+     * GET /api/admin/system-config/enabled?configType=EXTERNAL_API
+     */
+    @GetMapping("/enabled")
+    public Result<List<SystemConfig>> listEnabled(
+            @RequestParam(required = false) String configType) {
+        return Result.success(systemConfigService.listEnabled(configType));
+    }
+
+    /**
+     * 查询单个配置
+     * <p>
+     * GET /api/admin/system-config/{id}
+     */
+    @GetMapping("/{id}")
+    public Result<SystemConfig> getById(@PathVariable Long id) {
+        return Result.success(systemConfigService.getById(id));
+    }
+
+    /**
+     * 根据 configKey 精确查询
+     * <p>
+     * GET /api/admin/system-config/by-key?key=xxx
+     */
+    @GetMapping("/by-key")
+    public Result<SystemConfig> getByKey(@RequestParam String key) {
+        return Result.success(systemConfigService.getByKey(key));
+    }
+
+    /**
+     * 新增配置
+     * <p>
+     * POST /api/admin/system-config
+     */
+    @PostMapping
+    public Result<SystemConfig> create(@RequestBody SystemConfig config) {
+        return Result.success(systemConfigService.create(config));
+    }
+
+    /**
+     * 更新配置
+     * <p>
+     * PUT /api/admin/system-config/{id}
+     */
+    @PutMapping("/{id}")
+    public Result<SystemConfig> update(@PathVariable Long id, @RequestBody SystemConfig config) {
+        return Result.success(systemConfigService.update(id, config));
+    }
+
+    /**
+     * 删除配置
+     * <p>
+     * DELETE /api/admin/system-config/{id}
+     */
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(@PathVariable Long id) {
+        systemConfigService.delete(id);
+        return Result.success(null);
+    }
+}

+ 5 - 11
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java

@@ -1,17 +1,15 @@
 package com.xuekairui.user.controller;
 
-import com.xuekairui.common.BusinessException;
-import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.Result;
 import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.user.dto.*;
 import com.xuekairui.user.service.AuthService;
+import com.xuekairui.user.service.SystemConfigService;
 import com.xuekairui.user.service.WechatService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 
 /**
@@ -25,9 +23,7 @@ public class AuthController {
 
     private final AuthService authService;
     private final WechatService wechatService;
-
-    @Value("${external-system.miniapp.api-key:}")
-    private String externalApiKey;
+    private final SystemConfigService systemConfigService;
 
     /**
      * 发送验证码
@@ -284,13 +280,11 @@ public class AuthController {
     public Result<LoginResponse> externalMiniAppToken(
             @Valid @RequestBody ExternalTokenRequest request,
             HttpServletRequest httpRequest) {
-        // API Key 鉴权
+        // API Key + IP 白名单鉴权(通过数据库配置校验)
         String apiKey = httpRequest.getHeader("X-Api-Key");
-        if (externalApiKey == null || externalApiKey.isBlank()
-                || !externalApiKey.equals(apiKey)) {
-            throw new BusinessException(ErrorCode.UNAUTHORIZED, "API Key 无效");
-        }
         String ip = ValidateUtil.getClientIp(httpRequest);
+        systemConfigService.validateAccess(apiKey, ip);
+
         LoginResponse response = authService.externalMiniAppToken(request, ip);
         return Result.success(response);
     }

+ 22 - 54
zhijiayun-user/src/main/java/com/xuekairui/user/controller/BusinessLicenseController.java

@@ -2,7 +2,7 @@ package com.xuekairui.user.controller;
 
 import com.xuekairui.common.Result;
 import com.xuekairui.user.entity.BusinessLicense;
-import com.xuekairui.user.mapper.BusinessLicenseMapper;
+import com.xuekairui.user.service.BusinessLicenseService;
 import com.xuekairui.user.service.OssService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
@@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit;
 @RequiredArgsConstructor
 public class BusinessLicenseController {
 
-    private final BusinessLicenseMapper businessLicenseMapper;
+    private final BusinessLicenseService businessLicenseService;
     private final OssService ossService;
     private final RedisTemplate<String, Object> redisTemplate;
 
@@ -38,7 +38,7 @@ public class BusinessLicenseController {
             @Valid @RequestBody UploadRequest request,
             HttpServletRequest httpRequest) {
         Long userId = (Long) httpRequest.getAttribute("userId");
-        saveLicense(userId, request);
+        businessLicenseService.saveOrUpdate(userId, toSaveRequest(request));
         return Result.success();
     }
 
@@ -80,59 +80,27 @@ public class BusinessLicenseController {
     @GetMapping("/info")
     public Result<BusinessLicense> getInfo(HttpServletRequest httpRequest) {
         Long userId = (Long) httpRequest.getAttribute("userId");
-        BusinessLicense license = businessLicenseMapper.selectByUserId(userId);
-        return Result.success(license);
+        return Result.success(businessLicenseService.getByUserId(userId));
     }
 
-    // ---- 私有辅助方法 ----
-
-    private void saveLicense(Long userId, UploadRequest req) {
-        BusinessLicense existing = businessLicenseMapper.selectByUserId(userId);
-        if (existing == null) {
-            BusinessLicense license = BusinessLicense.builder()
-                    .userId(userId)
-                    // 必填字段
-                    .storeName(req.getStoreName())
-                    .terminalType(req.getTerminalType())
-                    .province(req.getProvince())
-                    .city(req.getCity())
-                    .district(req.getDistrict())
-                    .storeAddress(req.getStoreAddress())
-                    .contactPerson(req.getContactPerson())
-                    .contactPhone(req.getContactPhone())
-                    // 资质图片
-                    .licenseImageUrl(req.getBusinessLicenseUrl())
-                    .drugLicenseUrl(req.getDrugLicenseUrl())
-                    // 可选字段
-                    .creditCode(req.getCreditCode())
-                    .medicalDeviceClass2Url(req.getMedicalDeviceClass2Url())
-                    .medicalDeviceClass3Url(req.getMedicalDeviceClass3Url())
-                    // 状态
-                    .reviewStatus("PENDING")
-                    .showVerifiedBadge(false)
-                    .build();
-            businessLicenseMapper.insert(license);
-        } else {
-            // 必填字段
-            existing.setStoreName(req.getStoreName());
-            existing.setTerminalType(req.getTerminalType());
-            existing.setProvince(req.getProvince());
-            existing.setCity(req.getCity());
-            existing.setDistrict(req.getDistrict());
-            existing.setStoreAddress(req.getStoreAddress());
-            existing.setContactPerson(req.getContactPerson());
-            existing.setContactPhone(req.getContactPhone());
-            // 资质图片
-            existing.setLicenseImageUrl(req.getBusinessLicenseUrl());
-            existing.setDrugLicenseUrl(req.getDrugLicenseUrl());
-            // 可选字段(非空才覆盖)
-            if (req.getCreditCode() != null) existing.setCreditCode(req.getCreditCode());
-            if (req.getMedicalDeviceClass2Url() != null) existing.setMedicalDeviceClass2Url(req.getMedicalDeviceClass2Url());
-            if (req.getMedicalDeviceClass3Url() != null) existing.setMedicalDeviceClass3Url(req.getMedicalDeviceClass3Url());
-            // 状态重置
-            existing.setReviewStatus("PENDING");
-            businessLicenseMapper.updateById(existing);
-        }
+    // ---- 工具方法 ----
+
+    private static BusinessLicenseService.BusinessLicenseSaveRequest toSaveRequest(UploadRequest req) {
+        return BusinessLicenseService.BusinessLicenseSaveRequest.builder()
+                .storeName(req.getStoreName())
+                .terminalType(req.getTerminalType())
+                .province(req.getProvince())
+                .city(req.getCity())
+                .district(req.getDistrict())
+                .storeAddress(req.getStoreAddress())
+                .contactPerson(req.getContactPerson())
+                .contactPhone(req.getContactPhone())
+                .businessLicenseUrl(req.getBusinessLicenseUrl())
+                .drugLicenseUrl(req.getDrugLicenseUrl())
+                .creditCode(req.getCreditCode())
+                .medicalDeviceClass2Url(req.getMedicalDeviceClass2Url())
+                .medicalDeviceClass3Url(req.getMedicalDeviceClass3Url())
+                .build();
     }
 
     /**

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

@@ -0,0 +1,103 @@
+package com.xuekairui.user.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 入驻信息审核列表响应(含提交者信息)
+ *
+ * @author ProPrice Team
+ * @since 2026-07-09
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BusinessLicenseReviewResponse {
+
+    // ===== 入驻信息字段 =====
+
+    /** 入驻信息ID */
+    private Long id;
+
+    /** 店铺名称 */
+    private String storeName;
+
+    /** 终端类型 */
+    private String terminalType;
+
+    /** 省 */
+    private String province;
+
+    /** 市 */
+    private String city;
+
+    /** 区 */
+    private String district;
+
+    /** 店铺地址 */
+    private String storeAddress;
+
+    /** 联系人姓名 */
+    private String contactPerson;
+
+    /** 联系电话 */
+    private String contactPhone;
+
+    /** 统一社会信用代码 */
+    private String creditCode;
+
+    /** 营业执照图片URL */
+    private String licenseImageUrl;
+
+    /** 药品经营许可证图片URL */
+    private String drugLicenseUrl;
+
+    /** 二类医疗器械备案图片URL */
+    private String medicalDeviceClass2Url;
+
+    /** 三类医疗器械备案图片URL */
+    private String medicalDeviceClass3Url;
+
+    /** 审核状态 */
+    private String reviewStatus;
+
+    /** 驳回原因 */
+    private String rejectReason;
+
+    /** 来源 */
+    private String syncSource;
+
+    /** 是否展示认证标识 */
+    private Boolean showVerifiedBadge;
+
+    /** 审核人ID */
+    private Long reviewerId;
+
+    /** 审核时间 */
+    private LocalDateTime reviewTime;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+
+    // ===== 提交者(用户)信息 =====
+
+    /** 提交者用户ID */
+    private Long submitterUserId;
+
+    /** 提交者手机号 */
+    private String submitterPhone;
+
+    /** 提交者昵称 */
+    private String submitterNickname;
+
+    /** 提交者用户名(第三方系统传入) */
+    private String submitterUserName;
+}

+ 20 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java

@@ -45,4 +45,24 @@ public class ExternalTokenRequest {
 
     /** 可选:营业执照号 */
     private String businessLicenseNo;
+
+    // ===== 入驻信息字段(对应 /api/business-license/upload,均为可选) =====
+
+    /** 可选:店铺名称(入驻信息专用,不传则用 pharmacyName 兜底) */
+    private String storeName;
+
+    /** 可选:终端类型:SINGLE-单店, CHAIN-连锁, CLINIC-诊所, COMMUNITY_HEALTH-社康 */
+    private String terminalType;
+
+    /** 可选:营业执照图片URL */
+    private String businessLicenseUrl;
+
+    /** 可选:药品经营许可证图片URL */
+    private String drugLicenseUrl;
+
+    /** 可选:二类医疗器械备案图片URL */
+    private String medicalDeviceClass2Url;
+
+    /** 可选:三类医疗器械备案图片URL */
+    private String medicalDeviceClass3Url;
 }

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

@@ -106,7 +106,7 @@ public class BusinessLicense {
     /** 有效期截止日期(null表示长期有效) */
     private LocalDateTime validTo;
     
-    /** 审核状态:PENDING-待审核, APPROVED-已通过, REJECTED-已驳回 */
+    /** 审核状态:NOT_SUBMITTED-待入驻, PENDING-待审核, APPROVED-已通过, REJECTED-已驳回,参见 {@link com.xuekairui.user.enums.LicenseStatus} */
     @TableField("review_status")
     private String reviewStatus;
     

+ 81 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/SystemConfig.java

@@ -0,0 +1,81 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 全局系统配置实体(键值对模式)
+ * <p>
+ * 运营端可管理任意类型的系统配置,按 config_type 分组。无需代码改动即可新增配置项。
+ * <p>
+ * <b>设计说明:</b>
+ * <ul>
+ *   <li><b>config_key</b> — 语义化标识符(如 miniapp_external_api),程序通过 key 定位配置项</li>
+ *   <li><b>config_value</b> — JSON 格式,包含该配置项的所有参数</li>
+ *   <li>例如 EXTERNAL_API 类型:config_key=miniapp_external_api,config_value={"apiKey":"xxx","allowedIps":"..."}</li>
+ * </ul>
+ * <p>
+ * <b>预定义 config_type:</b>
+ * <ul>
+ *   <li><b>EXTERNAL_API</b> — 外部 API 访问配置</li>
+ *   <li><b>GENERAL</b> — 通用配置,可自由扩展</li>
+ * </ul>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-09
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("t_system_config")
+public class SystemConfig {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 配置键(唯一标识,语义化命名)
+     * <p>程序通过此 key 定位配置项,不应存储敏感值(敏感值放 config_value JSON 中)。
+     * <p>例如:miniapp_external_api 表示小程序后台外部 API 配置</p>
+     */
+    @TableField("config_key")
+    private String configKey;
+
+    /**
+     * 配置值(JSON 格式,包含该配置项的所有参数)
+     * <p>EXTERNAL_API 类型示例:{"apiKey":"sk-xxx","allowedIps":"192.168.1.1,10.0.0.2"}</p>
+     */
+    @TableField("config_value")
+    private String configValue;
+
+    /** 配置类型(分组):EXTERNAL_API / GENERAL */
+    @TableField("config_type")
+    private String configType;
+
+    /** 配置名称(中文描述) */
+    @TableField("config_name")
+    private String configName;
+
+    /** 是否启用 */
+    @TableField("enabled")
+    private Boolean enabled;
+
+    /** 备注 */
+    @TableField("remark")
+    private String remark;
+
+    /** 创建时间 */
+    @TableField(value = "create_time", fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 68 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/enums/LicenseStatus.java

@@ -0,0 +1,68 @@
+package com.xuekairui.user.enums;
+
+/**
+ * 入驻信息审核状态枚举
+ * <p>
+ * 四个状态及其流转:
+ * <pre>
+ *   NOT_SUBMITTED(待入驻,无 DB 记录)
+ *        │  用户提交入驻信息
+ *        ▼
+ *   PENDING(待审核)
+ *       ╱         ╲
+ *      ▼           ▼
+ *   APPROVED    REJECTED
+ *  (已通过)    (已驳回)
+ *      │           │
+ *      │   用户重新提交
+ *      │           │
+ *      ▼           ▼
+ *   保持        PENDING
+ * </pre>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-09
+ */
+public enum LicenseStatus {
+
+    /** 待入驻 — 用户尚未提交入驻信息(DB 中无记录,仅逻辑状态) */
+    NOT_SUBMITTED,
+
+    /** 待审核 — 用户已提交,等待运营审核 */
+    PENDING,
+
+    /** 已通过 — 运营审核通过,展示认证标识 */
+    APPROVED,
+
+    /** 已驳回 — 运营审核驳回,含驳回原因,用户可重新提交 */
+    REJECTED;
+
+    /**
+     * 是否为最终态(不可再审核操作)
+     */
+    public boolean isFinal() {
+        return this == APPROVED || this == REJECTED;
+    }
+
+    /**
+     * 是否可审核(只有 PENDING 可审核)
+     */
+    public boolean isReviewable() {
+        return this == PENDING;
+    }
+
+    /**
+     * 从 DB 值解析,未知或 null 返回 NOT_SUBMITTED
+     */
+    public static LicenseStatus fromDb(String dbValue) {
+        if (dbValue == null || dbValue.isBlank()) {
+            return NOT_SUBMITTED;
+        }
+        for (LicenseStatus s : values()) {
+            if (s.name().equals(dbValue)) {
+                return s;
+            }
+        }
+        return NOT_SUBMITTED;
+    }
+}

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

@@ -1,9 +1,11 @@
 package com.xuekairui.user.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.dto.BusinessLicenseReviewResponse;
 import com.xuekairui.user.entity.BusinessLicense;
-import org.apache.ibatis.annotations.Mapper;
-import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
 
 /**
  * 入驻信息Mapper
@@ -16,4 +18,27 @@ public interface BusinessLicenseMapper extends BaseMapper<BusinessLicense> {
      */
     @Select("SELECT * FROM t_business_license WHERE user_id = #{userId} LIMIT 1")
     BusinessLicense selectByUserId(Long userId);
+
+    /**
+     * 审核列表查询(LEFT JOIN t_user,支持按审核状态筛选 + 手机号/联系电话关键词搜索)
+     */
+    @Select("<script>" +
+            "SELECT bl.id, bl.store_name, bl.terminal_type, bl.province, bl.city, bl.district, " +
+            "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.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 " +
+            "FROM t_business_license bl LEFT JOIN t_user u ON bl.user_id = u.id " +
+            "<where>" +
+            "<if test='reviewStatus != null and reviewStatus != \"\"'>bl.review_status = #{reviewStatus}</if>" +
+            "<if test='keyword != null and keyword != \"\"'>" +
+            "AND (u.phone LIKE CONCAT('%', #{keyword}, '%') OR bl.contact_phone LIKE CONCAT('%', #{keyword}, '%'))" +
+            "</if>" +
+            "</where>" +
+            "ORDER BY bl.create_time DESC" +
+            "</script>")
+    List<BusinessLicenseReviewResponse> selectReviewList(@Param("reviewStatus") String reviewStatus,
+                                                         @Param("keyword") String keyword);
 }

+ 29 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SystemConfigMapper.java

@@ -0,0 +1,29 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.entity.SystemConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 全局系统配置 Mapper
+ */
+@Mapper
+public interface SystemConfigMapper extends BaseMapper<SystemConfig> {
+
+    /**
+     * 根据 configKey 和 configType 查询启用的配置
+     * <p>
+     * 用于外部 API 鉴权:通过语义键(如 miniapp_external_api)查找 EXTERNAL_API 分组的启用记录,
+     * 再从 config_value JSON 中提取 apiKey 与请求头 X-Api-Key 比对。
+     */
+    @Select("SELECT * FROM t_system_config WHERE config_key = #{configKey} AND config_type = #{configType} AND enabled = 1 LIMIT 1")
+    SystemConfig selectByKeyAndType(@Param("configKey") String configKey, @Param("configType") String configType);
+
+    /**
+     * 根据 configKey 查询配置(不限配置类型和启用状态,用于唯一性校验)
+     */
+    @Select("SELECT * FROM t_system_config WHERE config_key = #{configKey} LIMIT 1")
+    SystemConfig selectByKey(@Param("configKey") String configKey);
+}

+ 103 - 5
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -6,6 +6,7 @@ import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.user.dto.*;
 import com.xuekairui.user.entity.User;
+import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.event.UserRegisteredEvent;
@@ -43,7 +44,7 @@ public class AuthService {
     private final WechatMiniProgramService wechatMiniProgramService;
     private final ExternalUserSyncService externalUserSyncService;
     private final CertificateSyncService certificateSyncService;
-    private final com.xuekairui.user.mapper.BusinessLicenseMapper businessLicenseMapper;
+    private final BusinessLicenseService businessLicenseService;
     private final com.xuekairui.user.mapper.PlatformAccountMapper platformAccountMapper;
     private final com.xuekairui.user.mapper.CrawlerUsageLogMapper crawlerUsageLogMapper;
     private final com.xuekairui.user.mapper.SearchRecordMapper searchRecordMapper;
@@ -320,6 +321,9 @@ public class AuthService {
         user.setLastLoginIp(ip);
         userMapper.updateById(user);
 
+        // 同步入驻信息到 t_business_license(仅当传入入驻相关字段时)
+        syncBusinessLicense(request, user.getId());
+
         LoginResponse response = buildMiniAppLoginResponse(user);
         response.setNewUser(isNewUser);
 
@@ -485,11 +489,105 @@ public class AuthService {
     // 聚合查询辅助方法
     // ====================================
 
+    /**
+     * 同步第三方传入的入驻信息到 t_business_license
+     * 仅当请求中至少有一个入驻相关字段非空时才执行
+     */
+    private void syncBusinessLicense(ExternalTokenRequest request, Long userId) {
+        String storeName = (request.getStoreName() != null && !request.getStoreName().isBlank())
+                ? request.getStoreName() : request.getPharmacyName();
+
+        boolean hasLicenseField = isNotBlank(storeName)
+                || isNotBlank(request.getTerminalType())
+                || isNotBlank(request.getBusinessLicenseUrl())
+                || isNotBlank(request.getDrugLicenseUrl())
+                || isNotBlank(request.getMedicalDeviceClass2Url())
+                || isNotBlank(request.getMedicalDeviceClass3Url());
+
+        if (!hasLicenseField) {
+            return;
+        }
+
+        com.xuekairui.user.entity.BusinessLicense existing =
+                businessLicenseService.getByUserId(userId);
+
+        if (existing == null) {
+            com.xuekairui.user.entity.BusinessLicense license =
+                    com.xuekairui.user.entity.BusinessLicense.builder()
+                            .userId(userId)
+                            .storeName(storeName)
+                            .terminalType(request.getTerminalType())
+                            .province(request.getProvince())
+                            .city(request.getCity())
+                            .district(request.getDistrict())
+                            .storeAddress(request.getPharmacyAddress())
+                            .contactPerson(request.getContactPerson())
+                            .contactPhone(request.getContactPhone())
+                            .licenseImageUrl(request.getBusinessLicenseUrl())
+                            .drugLicenseUrl(request.getDrugLicenseUrl())
+                            .creditCode(request.getBusinessLicenseNo())
+                            .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url())
+                            .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url())
+                            .reviewStatus(LicenseStatus.PENDING.name())
+                            .showVerifiedBadge(false)
+                            .syncSource("MINIAPP_SYNC")
+                            .build();
+            businessLicenseService.insert(license);
+        } else {
+            if (storeName != null && !storeName.isBlank()) {
+                existing.setStoreName(storeName);
+            }
+            if (isNotBlank(request.getTerminalType())) {
+                existing.setTerminalType(request.getTerminalType());
+            }
+            if (isNotBlank(request.getProvince())) {
+                existing.setProvince(request.getProvince());
+            }
+            if (isNotBlank(request.getCity())) {
+                existing.setCity(request.getCity());
+            }
+            if (isNotBlank(request.getDistrict())) {
+                existing.setDistrict(request.getDistrict());
+            }
+            if (isNotBlank(request.getPharmacyAddress())) {
+                existing.setStoreAddress(request.getPharmacyAddress());
+            }
+            if (isNotBlank(request.getContactPerson())) {
+                existing.setContactPerson(request.getContactPerson());
+            }
+            if (isNotBlank(request.getContactPhone())) {
+                existing.setContactPhone(request.getContactPhone());
+            }
+            if (isNotBlank(request.getBusinessLicenseUrl())) {
+                existing.setLicenseImageUrl(request.getBusinessLicenseUrl());
+            }
+            if (isNotBlank(request.getDrugLicenseUrl())) {
+                existing.setDrugLicenseUrl(request.getDrugLicenseUrl());
+            }
+            if (isNotBlank(request.getBusinessLicenseNo())) {
+                existing.setCreditCode(request.getBusinessLicenseNo());
+            }
+            if (isNotBlank(request.getMedicalDeviceClass2Url())) {
+                existing.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url());
+            }
+            if (isNotBlank(request.getMedicalDeviceClass3Url())) {
+                existing.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url());
+            }
+            existing.setReviewStatus(LicenseStatus.PENDING.name());
+            existing.setSyncSource("MINIAPP_SYNC");
+            businessLicenseService.updateById(existing);
+        }
+    }
+
+    private boolean isNotBlank(String s) {
+        return s != null && !s.isBlank();
+    }
+
     private UserGateStatus buildGateStatus(Long userId) {
         // 执照审核状态
         com.xuekairui.user.entity.BusinessLicense license =
-                businessLicenseMapper.selectByUserId(userId);
-        String licenseStatus = license != null ? license.getReviewStatus() : "NONE";
+                businessLicenseService.getByUserId(userId);
+        String licenseStatus = license != null ? license.getReviewStatus() : LicenseStatus.NOT_SUBMITTED.name();
 
         // 已绑定平台数
         Long boundCount = platformAccountMapper.selectCount(
@@ -497,10 +595,10 @@ public class AuthService {
                         .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId)
                         .eq(com.xuekairui.user.entity.PlatformAccount::getEnabled, true));
 
-        boolean canQuery = "APPROVED".equals(licenseStatus) && boundCount >= 1;
+        boolean canQuery = LicenseStatus.APPROVED.name().equals(licenseStatus) && boundCount >= 1;
 
         java.util.List<String> messages = new java.util.ArrayList<>();
-        if (!"APPROVED".equals(licenseStatus)) {
+        if (!LicenseStatus.APPROVED.name().equals(licenseStatus)) {
             messages.add("请先提交入驻信息并通过审核");
         }
         if (boundCount == 0) {

+ 264 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java

@@ -0,0 +1,264 @@
+package com.xuekairui.user.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.common.event.ActivityEventRedisService;
+import com.xuekairui.user.dto.BusinessLicenseReviewResponse;
+import com.xuekairui.user.entity.BusinessLicense;
+import com.xuekairui.user.enums.LicenseStatus;
+import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.mapper.BusinessLicenseMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 入驻信息服务(封装所有 BusinessLicenseMapper 操作 + 审核业务逻辑)
+ * <p>
+ * 用户侧:提交/查询入驻信息
+ * 运营侧:列表查询、审核通过(赠送会员)、驳回
+ *
+ * @author ProPrice Team
+ * @since 2026-07-09
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BusinessLicenseService {
+
+    private final BusinessLicenseMapper businessLicenseMapper;
+    private final MembershipService membershipService;
+    private final ActivityEventRedisService activityEventRedisService;
+    private final OperationAuditLogService auditLogService;
+
+    // ======================== 查询方法 ========================
+
+    /**
+     * 按审核状态 + 手机号关键词查询列表(不传则查全部),LEFT JOIN 查提交者信息,一次 SQL
+     */
+    public List<BusinessLicenseReviewResponse> listByReviewStatus(String reviewStatus, String keyword) {
+        if (reviewStatus != null && reviewStatus.isBlank()) {
+            reviewStatus = null;
+        }
+        if (keyword != null && keyword.isBlank()) {
+            keyword = null;
+        }
+        return businessLicenseMapper.selectReviewList(reviewStatus, keyword);
+    }
+
+    /**
+     * 根据 ID 查询
+     */
+    public BusinessLicense getById(Long id) {
+        return businessLicenseMapper.selectById(id);
+    }
+
+    /**
+     * 根据用户 ID 查询入驻信息
+     */
+    public BusinessLicense getByUserId(Long userId) {
+        return businessLicenseMapper.selectByUserId(userId);
+    }
+
+    /**
+     * 插入新入驻信息(通用方法,供同步等场景使用)
+     */
+    public void insert(BusinessLicense license) {
+        businessLicenseMapper.insert(license);
+    }
+
+    /**
+     * 更新入驻信息(通用方法,供同步等场景使用)
+     */
+    public void updateById(BusinessLicense license) {
+        businessLicenseMapper.updateById(license);
+    }
+
+    /**
+     * 检查用户是否已有其他审核通过的入驻信息(避免重复赠送会员)
+     */
+    private boolean hasOtherApprovedLicense(Long userId, Long excludeLicenseId) {
+        Long count = businessLicenseMapper.selectCount(
+                new LambdaQueryWrapper<BusinessLicense>()
+                        .eq(BusinessLicense::getUserId, userId)
+                        .eq(BusinessLicense::getReviewStatus, LicenseStatus.APPROVED.name())
+                        .ne(BusinessLicense::getId, excludeLicenseId));
+        return count != null && count > 0;
+    }
+
+    // ======================== 用户侧:提交入驻信息 ========================
+
+    /**
+     * 提交或更新入驻信息(用户端)
+     */
+    @Transactional
+    public void saveOrUpdate(Long userId, BusinessLicenseSaveRequest request) {
+        BusinessLicense existing = getByUserId(userId);
+        if (existing == null) {
+            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)
+                    .build();
+            businessLicenseMapper.insert(license);
+            log.info("用户{}提交入驻信息:id={}", userId, license.getId());
+        } else {
+            existing.setStoreName(request.getStoreName());
+            existing.setTerminalType(request.getTerminalType());
+            existing.setProvince(request.getProvince());
+            existing.setCity(request.getCity());
+            existing.setDistrict(request.getDistrict());
+            existing.setStoreAddress(request.getStoreAddress());
+            existing.setContactPerson(request.getContactPerson());
+            existing.setContactPhone(request.getContactPhone());
+            existing.setLicenseImageUrl(request.getBusinessLicenseUrl());
+            existing.setDrugLicenseUrl(request.getDrugLicenseUrl());
+            if (request.getCreditCode() != null) {
+                existing.setCreditCode(request.getCreditCode());
+            }
+            if (request.getMedicalDeviceClass2Url() != null) {
+                existing.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url());
+            }
+            if (request.getMedicalDeviceClass3Url() != null) {
+                existing.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url());
+            }
+            existing.setReviewStatus(LicenseStatus.PENDING.name());
+            businessLicenseMapper.updateById(existing);
+            log.info("用户{}更新入驻信息:id={}", userId, existing.getId());
+        }
+    }
+
+    // ======================== 运营侧:审核操作 ========================
+
+    /**
+     * 审核通过
+     * <p>
+     * 更新审核状态为 APPROVED,展示认证标识。首次审核通过时赠送 30 天高级会员,
+     * 并推送 Redis 事件供活动模块消费(邀请有礼:被邀请人资质通过后发邀请人奖励)。
+     *
+     * @param licenseId  入驻信息 ID
+     * @param reviewerId 审核人 ID
+     */
+    @Transactional
+    public void approve(Long licenseId, Long reviewerId) {
+        BusinessLicense license = validateReviewable(licenseId);
+
+        license.setReviewStatus(LicenseStatus.APPROVED.name());
+        license.setReviewerId(reviewerId);
+        license.setReviewTime(LocalDateTime.now());
+        license.setShowVerifiedBadge(true);
+        businessLicenseMapper.updateById(license);
+
+        // 首次审核通过,赠送 30 天高级会员
+        Long userId = license.getUserId();
+        if (userId != null && !hasOtherApprovedLicense(userId, licenseId)) {
+            membershipService.grantOrExtendMembership(
+                    userId,
+                    MembershipLevel.PRO,
+                    30,
+                    "LICENSE",
+                    "入驻信息审核通过赠送30天高级会员");
+            log.info("入驻信息审核通过,赠送用户{} 30天高级会员", userId);
+
+            // 事务提交后推送 Redis 事件,供活动模块消费
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    try {
+                        activityEventRedisService.pushLicenseApproved(userId, license.getId(), "BUSINESS_LICENSE");
+                    } catch (Exception e) {
+                        log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e);
+                    }
+                }
+            });
+        }
+
+        auditLogService.logAdmin(reviewerId, OperationType.LICENSE_APPROVE,
+                "LICENSE:" + license.getId(), license.getId());
+        log.info("入驻信息审核通过: licenseId={}, reviewerId={}", licenseId, reviewerId);
+    }
+
+    /**
+     * 审核驳回
+     *
+     * @param licenseId    入驻信息 ID
+     * @param reviewerId   审核人 ID
+     * @param rejectReason 驳回原因
+     */
+    @Transactional
+    public void reject(Long licenseId, Long reviewerId, String rejectReason) {
+        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);
+
+        auditLogService.logAdmin(reviewerId, OperationType.LICENSE_REJECT,
+                "LICENSE:" + license.getId(), license.getId());
+        log.info("入驻信息审核驳回: licenseId={}, reviewerId={}, reason={}",
+                licenseId, reviewerId, rejectReason);
+    }
+
+    /**
+     * 校验入驻信息是否可审核(存在 + 状态为 PENDING)
+     */
+    private BusinessLicense validateReviewable(Long licenseId) {
+        BusinessLicense license = getById(licenseId);
+        if (license == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "入驻信息记录不存在");
+        }
+        if (!LicenseStatus.PENDING.name().equals(license.getReviewStatus())) {
+            throw new BusinessException(ErrorCode.BAD_REQUEST, "该入驻信息已审核,无法重复操作");
+        }
+        return license;
+    }
+
+    // ======================== 请求 DTO ========================
+
+    /**
+     * 入驻信息提交请求参数(用于 Service 层,避免依赖 Controller 层 DTO)
+     */
+    @lombok.Data
+    @lombok.Builder
+    @lombok.NoArgsConstructor
+    @lombok.AllArgsConstructor
+    public static class BusinessLicenseSaveRequest {
+        private String storeName;
+        private String terminalType;
+        private String province;
+        private String city;
+        private String district;
+        private String storeAddress;
+        private String contactPerson;
+        private String contactPhone;
+        private String businessLicenseUrl;
+        private String drugLicenseUrl;
+        private String creditCode;
+        private String medicalDeviceClass2Url;
+        private String medicalDeviceClass3Url;
+    }
+}

+ 277 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/SystemConfigService.java

@@ -0,0 +1,277 @@
+package com.xuekairui.user.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.entity.SystemConfig;
+import com.xuekairui.user.mapper.SystemConfigMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 全局系统配置服务(两层架构:数据库优先 → 配置文件兜底)
+ * <p>
+ * 负责:
+ * <ul>
+ *   <li><b>外部 API 鉴权</b>:通过语义键(miniapp_external_api)查 DB 获取 apiKey + IP 白名单配置,兜底匹配 yml</li>
+ *   <li><b>运营端管理</b>:通用 CRUD,支持按 configType 分组查询</li>
+ 
+ * </ul>
+ * <p>
+ * <b>两层设计:</b>
+ * <ol>
+ *   <li>数据库(t_system_config)— 运营端可实时修改,即时生效,支持 IP 白名单等扩展字段</li>
+ *   <li>配置文件(application.yml / 环境变量)— 兜底,当数据库无匹配记录时生效</li>
+ * </ol>
+ * <b>config_key 设计:</b>语义化标识符(如 miniapp_external_api),程序通过 key 定位配置项;
+ * config_value 为 JSON,包含该配置项的所有参数(如 apiKey、allowedIps)。
+ * 后续新增配置类型只需运营端新增记录,无需代码改动。
+ *
+ * @author ProPrice Team
+ * @since 2026-07-09
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SystemConfigService {
+
+    private final SystemConfigMapper systemConfigMapper;
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 配置文件兜底:外部 API 的默认 Key(来自 application.yml 的 external-system.miniapp.api-key)
+     * <p>当数据库 t_system_config 中无 EXTERNAL_API 匹配记录时,回退到此 Key 做等值比较</p>
+     */
+    @Value("${external-system.miniapp.api-key:}")
+    private String defaultExternalApiKey;
+
+    /** 外部 API 访问配置类型 */
+    public static final String TYPE_EXTERNAL_API = "EXTERNAL_API";
+    /** 通用配置类型 */
+    public static final String TYPE_GENERAL = "GENERAL";
+
+    /** 外部 API 配置的语义键(对应 t_system_config.config_key) */
+    public static final String CONFIG_KEY_EXTERNAL_API = "miniapp_external_api";
+
+    // ======================== 外部 API 鉴权(两层:DB 优先 → yml 兜底) ========================
+
+    /**
+     * 校验 API Key 和 IP 是否允许访问(两层架构)
+     * <p>
+     * <b>校验流程:</b>
+     * <ol>
+     *   <li>以语义键 miniapp_external_api 查询 DB 中 configType=EXTERNAL_API 的启用记录</li>
+     *   <li>DB 命中 → 从 config_value JSON 中提取 apiKey 比对,再检查 allowedIps</li>
+     *   <li>DB 未命中或 apiKey 不匹配 → 兜底:比对 application.yml 的 external-system.miniapp.api-key</li>
+     *   <li>两级均不匹配 → 401</li>
+     * </ol>
+     * <p>
+     * <b>注意:IP 白名单仅在 DB 命中且 apiKey 匹配时生效,配置文件兜底时不校验 IP。</b>
+     *
+     * @param apiKey   请求头中的 X-Api-Key
+     * @param clientIp 客户端真实 IP
+     */
+    public void validateAccess(String apiKey, String clientIp) {
+        if (apiKey == null || apiKey.isBlank()) {
+            log.warn("外部 API 鉴权失败:缺少 X-Api-Key");
+            throw new BusinessException(ErrorCode.UNAUTHORIZED, "缺少 API Key");
+        }
+
+        // 第一层:数据库优先 — 通过语义键查找配置,再从 JSON 中提取 apiKey 比对
+        SystemConfig config = systemConfigMapper.selectByKeyAndType(CONFIG_KEY_EXTERNAL_API, TYPE_EXTERNAL_API);
+
+        if (config != null) {
+            String dbApiKey = readJsonField(config.getConfigValue(), "apiKey");
+            if (dbApiKey != null && dbApiKey.equals(apiKey)) {
+                // 数据库命中且 apiKey 匹配 → 检查 IP 白名单
+                validateIpWhitelist(config, clientIp);
+                log.debug("外部 API 鉴权通过(数据库):name={}, clientIp={}", config.getConfigName(), clientIp);
+                return;
+            }
+            // DB 中有配置但 apiKey 不匹配,继续走 yml 兜底
+            log.debug("外部 API 鉴权:数据库 apiKey 不匹配,尝试配置文件兜底");
+        }
+
+        // 第二层:配置文件兜底
+        if (defaultExternalApiKey != null && !defaultExternalApiKey.isBlank()
+                && defaultExternalApiKey.equals(apiKey)) {
+            log.debug("外部 API 鉴权通过(配置文件兜底):clientIp={}", clientIp);
+            return;
+        }
+
+        // 两层均不匹配
+        log.warn("外部 API 鉴权失败:API Key 无效(数据库和配置文件均不匹配),key={}", maskKey(apiKey));
+        throw new BusinessException(ErrorCode.UNAUTHORIZED, "API Key 无效");
+    }
+
+    /**
+     * 检查 IP 白名单(仅数据库匹配时调用)
+     */
+    private void validateIpWhitelist(SystemConfig config, String clientIp) {
+        String allowedIps = readJsonField(config.getConfigValue(), "allowedIps");
+        if (allowedIps == null || allowedIps.isBlank()) {
+            return; // 未配置白名单,不限制 IP
+        }
+        List<String> ipList = Arrays.stream(allowedIps.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .toList();
+        if (!ipList.isEmpty() && !ipList.contains(clientIp)) {
+            log.warn("外部 API 鉴权失败:IP 不在白名单中,name={}, clientIp={}, allowedIps={}",
+                    config.getConfigName(), clientIp, allowedIps);
+            throw new BusinessException(ErrorCode.UNAUTHORIZED, "IP 不在白名单中");
+        }
+    }
+
+    // ======================== 运营端通用管理 ========================
+
+    /**
+     * 按 configType 分页查询配置
+     *
+     * @param configType 配置类型,可空(查全部)
+     */
+    public Page<SystemConfig> listByType(String configType, int page, int size) {
+        LambdaQueryWrapper<SystemConfig> wrapper = new LambdaQueryWrapper<>();
+        if (configType != null && !configType.isBlank()) {
+            wrapper.eq(SystemConfig::getConfigType, configType);
+        }
+        wrapper.orderByAsc(SystemConfig::getConfigType)
+                .orderByDesc(SystemConfig::getCreateTime);
+        return systemConfigMapper.selectPage(new Page<>(page, size), wrapper);
+    }
+
+    /**
+     * 查询所有启用的配置
+     */
+    public List<SystemConfig> listEnabled(String configType) {
+        LambdaQueryWrapper<SystemConfig> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(SystemConfig::getEnabled, true);
+        if (configType != null && !configType.isBlank()) {
+            wrapper.eq(SystemConfig::getConfigType, configType);
+        }
+        wrapper.orderByDesc(SystemConfig::getCreateTime);
+        return systemConfigMapper.selectList(wrapper);
+    }
+
+    /**
+     * 根据 ID 查询
+     */
+    public SystemConfig getById(Long id) {
+        SystemConfig config = systemConfigMapper.selectById(id);
+        if (config == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "配置不存在");
+        }
+        return config;
+    }
+
+    /**
+     * 根据 configKey 查询(不限制类型和状态)
+     */
+    public SystemConfig getByKey(String configKey) {
+        return systemConfigMapper.selectByKey(configKey);
+    }
+
+    /**
+     * 新增配置
+     */
+    public SystemConfig create(SystemConfig config) {
+        // 唯一性校验
+        if (systemConfigMapper.selectByKey(config.getConfigKey()) != null) {
+            throw new BusinessException(ErrorCode.BAD_REQUEST, "配置键已存在");
+        }
+        if (config.getConfigName() == null || config.getConfigName().isBlank()) {
+            throw new BusinessException(ErrorCode.BAD_REQUEST, "配置名称不能为空");
+        }
+        if (config.getConfigType() == null || config.getConfigType().isBlank()) {
+            config.setConfigType(TYPE_GENERAL);
+        }
+        if (config.getEnabled() == null) {
+            config.setEnabled(true);
+        }
+        systemConfigMapper.insert(config);
+        log.info("新增系统配置:id={}, type={}, key={}, name={}",
+                config.getId(), config.getConfigType(), maskKey(config.getConfigKey()), config.getConfigName());
+        return config;
+    }
+
+    /**
+     * 更新配置
+     */
+    public SystemConfig update(Long id, SystemConfig config) {
+        SystemConfig existing = getById(id);
+
+        // configKey 唯一性校验(排除自身)
+        if (config.getConfigKey() != null && !config.getConfigKey().equals(existing.getConfigKey())) {
+            SystemConfig dup = systemConfigMapper.selectByKey(config.getConfigKey());
+            if (dup != null && !dup.getId().equals(id)) {
+                throw new BusinessException(ErrorCode.BAD_REQUEST, "配置键已存在");
+            }
+        }
+
+        // 只更新非 null 字段
+        Optional.ofNullable(config.getConfigKey()).ifPresent(existing::setConfigKey);
+        Optional.ofNullable(config.getConfigValue()).ifPresent(existing::setConfigValue);
+        Optional.ofNullable(config.getConfigType()).ifPresent(existing::setConfigType);
+        Optional.ofNullable(config.getConfigName()).ifPresent(existing::setConfigName);
+        Optional.ofNullable(config.getEnabled()).ifPresent(existing::setEnabled);
+        Optional.ofNullable(config.getRemark()).ifPresent(existing::setRemark);
+
+        existing.setUpdateTime(LocalDateTime.now());
+
+        systemConfigMapper.updateById(existing);
+        log.info("更新系统配置:id={}, type={}, key={}, name={}, enabled={}",
+                id, existing.getConfigType(), maskKey(existing.getConfigKey()),
+                existing.getConfigName(), existing.getEnabled());
+        return existing;
+    }
+
+    /**
+     * 删除配置
+     */
+    public void delete(Long id) {
+        SystemConfig config = getById(id);
+        systemConfigMapper.deleteById(id);
+        log.info("删除系统配置:id={}, type={}, key={}, name={}",
+                id, config.getConfigType(), maskKey(config.getConfigKey()), config.getConfigName());
+    }
+
+    // ======================== 工具方法 ========================
+
+    /**
+     * 从 JSON 字符串中读取指定字段(容错,解析失败返回 null)
+     */
+    static String readJsonField(String json, String fieldName) {
+        if (json == null || json.isBlank()) {
+            return null;
+        }
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            JsonNode field = node.get(fieldName);
+            return field != null && !field.isNull() ? field.asText() : null;
+        } catch (JsonProcessingException e) {
+            log.debug("JSON 解析失败:field={}, json={}", fieldName, json);
+            return null;
+        }
+    }
+
+    /**
+     * 脱敏 configKey(仅显示前4位 + 后4位)
+     */
+    static String maskKey(String key) {
+        if (key == null || key.length() <= 8) {
+            return key;
+        }
+        return key.substring(0, 4) + "****" + key.substring(key.length() - 4);
+    }
+}