liuchengsen 1 місяць тому
батько
коміт
9db89902d0
18 змінених файлів з 660 додано та 18 видалено
  1. 299 0
      docs/03-接口文档.md
  2. 4 0
      docs/第三方店铺同步接口文档.md
  3. 11 5
      docs/第三方登录接口文档.md
  4. 2 2
      zhijiayun-gateway/src/main/resources/application.yml
  5. 28 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java
  6. 16 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminUserController.java
  7. 6 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/BusinessLicenseController.java
  8. 80 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/AdminUserDetailResponse.java
  9. 19 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/BusinessLicenseReviewResponse.java
  10. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java
  11. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SyncInfoRequest.java
  12. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SyncShopReportRequest.java
  13. 6 2
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/BusinessLicenseMapper.java
  14. 31 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/UserMapper.java
  15. 24 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java
  16. 21 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  17. 100 9
      zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java
  18. 4 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/impl/ShopResourceSyncServiceImpl.java

+ 299 - 0
docs/03-接口文档.md

@@ -980,6 +980,305 @@ GET /api/admin/invite/conversion-stats?userId=1
 
 ---
 
+### 6.9 入驻审核列表(运营端)
+
+```
+GET /api/admin/license/review?reviewStatus=PENDING&keyword=138&page=1&size=20
+```
+
+**需要管理员权限**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| reviewStatus | String | 否 | 审核状态:PENDING/APPROVED/REJECTED,不传查全部 |
+| keyword | String | 否 | 手机号关键词(匹配用户手机号或入驻联系电话) |
+| page | int | 否 | 页码,默认1 |
+| size | int | 否 | 每页条数,默认20 |
+
+**响应:** 分页格式,records 包含入驻信息及关联用户手机号、昵称等。
+
+---
+
+### 6.10 管理员新增入驻信息
+
+```
+POST /api/admin/license
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "userId": 1,
+  "storeName": "XX大药房",
+  "terminalType": "SINGLE",
+  "province": "广东省",
+  "city": "深圳市",
+  "district": "南山区",
+  "storeAddress": "科技园路1号",
+  "contactPerson": "张三",
+  "contactPhone": "13800138000",
+  "businessLicenseUrl": "https://oss.example.com/license/xxx.jpg",
+  "drugLicenseUrl": "https://oss.example.com/drug/xxx.jpg",
+  "creditCode": "91110000XXXXXXXXXX",
+  "medicalDeviceClass2Url": "https://oss.example.com/device2/xxx.jpg",
+  "medicalDeviceClass3Url": "https://oss.example.com/device3/xxx.jpg"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| userId | Long | 是 | 客户用户ID(必须未提交过入驻信息) |
+| storeName | String | 是 | 店铺名称 |
+| terminalType | String | 是 | SINGLE-单店 / CHAIN-连锁 / CLINIC-诊所 |
+| province | String | 是 | 省 |
+| city | String | 是 | 市 |
+| district | String | 是 | 区 |
+| storeAddress | String | 是 | 店铺详细地址 |
+| contactPerson | String | 是 | 联系人姓名 |
+| contactPhone | String | 是 | 联系电话 |
+| businessLicenseUrl | String | 是 | 营业执照图片URL |
+| drugLicenseUrl | String | 是 | 药品经营许可证图片URL |
+| creditCode | String | 否 | 统一社会信用代码 |
+| medicalDeviceClass2Url | String | 否 | 二类医疗器械备案图片URL(与三类至少选一) |
+| medicalDeviceClass3Url | String | 否 | 三类医疗器械经营许可图片URL(与二类至少选一) |
+
+> 创建后状态为 PENDING,需再操作审核确认。
+
+---
+
+### 6.11 管理员编辑入驻信息
+
+```
+PUT /api/admin/license/{licenseId}
+```
+
+**需要管理员权限**,请求体同新增接口(不含 userId)。
+
+---
+
+### 6.12 审核入驻信息
+
+```
+POST /api/admin/license/review
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "licenseId": 1,
+  "action": "APPROVED",
+  "rejectReason": "资质不符合要求"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| licenseId | Long | 是 | 入驻信息ID |
+| action | String | 是 | APPROVED-审核通过 / REJECTED-驳回 |
+| rejectReason | String | 否 | 驳回原因(驳回时必填) |
+
+> 审核通过后自动赠送30天PRO高级会员(首次),并同步到第三方。
+
+---
+
+### 6.13 手动同步入驻信息到第三方
+
+```
+POST /api/admin/license/{licenseId}/sync-third
+```
+
+**需要管理员权限**
+
+单条入驻记录手动同步到第三方(智价云药店版),同步执行并立即返回结果。
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": "同步成功,第三方用户ID:1234567890123456789"
+}
+```
+
+---
+
+### 6.14 批量同步入驻信息到第三方
+
+```
+POST /api/admin/license/batch-sync-third
+```
+
+**需要管理员权限**
+
+运营在后台勾选多条入驻记录,一键批量同步到第三方。逐条独立处理,某条失败不影响其他记录。
+
+**请求体:**
+```json
+{
+  "licenseIds": [1, 2, 3, 5]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| licenseIds | Array\<Long\> | 是 | 入驻信息ID列表,至少一条 |
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "total": 4,
+    "successCount": 3,
+    "failCount": 1,
+    "items": [
+      { "licenseId": 1, "success": true, "message": "同步成功,第三方用户ID:xxx" },
+      { "licenseId": 2, "success": true, "message": "同步成功,第三方用户ID:yyy" },
+      { "licenseId": 3, "success": false, "message": "同步未成功:第三方未返回用户ID" },
+      { "licenseId": 5, "success": true, "message": "同步成功,第三方用户ID:zzz" }
+    ]
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---------|------|------|
+| total | int | 总条数 |
+| successCount | int | 成功条数 |
+| failCount | int | 失败条数 |
+| items | Array | 逐条明细 |
+| items[].licenseId | Long | 入驻信息ID |
+| items[].success | Boolean | 是否成功 |
+| items[].message | String | 结果描述 |
+
+---
+
+### 6.15 查询同步差异列表
+
+```
+GET /api/admin/license/sync-diff?status=0
+```
+
+**需要管理员权限**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| status | Integer | 否 | 处理状态:0-待处理 1-已采纳第三方 2-已保留本地 3-已手动调整 |
+
+**响应:** 返回差异记录数组,包含 licenseId、字段名、本地值、第三方值等。
+
+---
+
+### 6.16 处理同步差异
+
+```
+POST /api/admin/license/sync-diff/{diffId}/handle
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "action": "ADOPT_THIRD",
+  "remark": "第三方数据更准确"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| action | String | 是 | ADOPT_THIRD-采纳第三方 / KEEP_LOCAL-保留本地 / MANUAL-手动调整 |
+| remark | String | 否 | 处理备注 |
+
+---
+
+### 6.17 用户搜索(手机号模糊匹配+入驻信息)
+
+```
+GET /api/admin/users/search?phone=138&page=1&size=20
+```
+
+**需要管理员权限**
+
+按手机号片段模糊搜索用户,同时关联返回该用户的入驻信息(店铺名称、审核状态、同步状态)和会员等级。未提交入驻信息的用户,入驻相关字段为 `null`。
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| phone | String | 是 | 手机号关键词(LIKE 模糊匹配) |
+| page | int | 否 | 页码,默认1 |
+| size | int | 否 | 每页条数,默认20 |
+
+**响应:** 分页格式
+
+```json
+{
+  "code": 200,
+  "data": {
+    "total": 2,
+    "page": 1,
+    "size": 20,
+    "records": [
+      {
+        "userId": "1",
+        "phone": "13800138000",
+        "nickname": "张三",
+        "wechatNickname": null,
+        "userStatus": 1,
+        "createTime": "2026-06-01T10:00:00",
+        "membershipLevel": "PRO",
+        "membershipExpireAt": "2026-07-15T10:00:00",
+        "licenseId": "1",
+        "storeName": "XX大药房",
+        "terminalType": "SINGLE",
+        "contactPerson": "张三",
+        "contactPhone": "13800138000",
+        "reviewStatus": "APPROVED",
+        "syncStatus": 1,
+        "externalId": "123456789"
+      },
+      {
+        "userId": "2",
+        "phone": "13800138001",
+        "nickname": "用户138****0001",
+        "wechatNickname": null,
+        "userStatus": 1,
+        "createTime": "2026-07-01T10:00:00",
+        "membershipLevel": "PLUS",
+        "membershipExpireAt": null,
+        "licenseId": null,
+        "storeName": null,
+        "terminalType": null,
+        "contactPerson": null,
+        "contactPhone": null,
+        "reviewStatus": null,
+        "syncStatus": null,
+        "externalId": null
+      }
+    ]
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---------|------|------|
+| userId | String | 用户ID |
+| phone | String | 手机号 |
+| nickname | String | 昵称 |
+| wechatNickname | String | 微信昵称 |
+| userStatus | Integer | 0-禁用 1-正常 |
+| membershipLevel | String | PLUS/PRO/ULTRA |
+| membershipExpireAt | String | 会员过期时间(null=永久) |
+| licenseId | String | 入驻信息ID(null=未提交入驻) |
+| storeName | String | 店铺名称 |
+| terminalType | String | SINGLE/CHAIN/CLINIC |
+| reviewStatus | String | PENDING/APPROVED/REJECTED |
+| syncStatus | Integer | 0-未同步 1-成功 2-失败 |
+
+---
+
 ## 7. B2B平台账号绑定模块 /api/platform-account
 
 ### 7.1 绑定B2B平台账号

+ 4 - 0
docs/第三方店铺同步接口文档.md

@@ -62,6 +62,7 @@
 | 参数名 | 类型 | 必填 | 说明 |
 |--------|------|------|------|
 | `status` | String | 否 | 审核状态:`0`=审核中 / `1`=审核成功 / `2`=审核失败(不传默认`0`) |
+| `remarks` | String | 否 | 备注信息(如拒绝原因等) |
 | `operator_userid` | String | 否 | 操作人用户ID(默认`0`) |
 | `user_type` | String | 否 | 用户类型:`1`=客户端用户 / `2`=后台用户 |
 | `app_id` | String | 否 | 小程序APPID |
@@ -86,6 +87,7 @@
   "two_medical_device_registration": "https://kailin-saas.oss-cn-shenzhen.aliyuncs.com/uploads/process/2026/07/ch8K4VULklSVWYEYolVPTxvURA7fJlDsnzpHRZno.png",
   "three_medical_device_business_license": "https://kailin-saas.oss-cn-shenzhen.aliyuncs.com/uploads/process/2026/07/ch8K4VULklSVWYEYolVPTxvURA7fJlDsnzpHRZno.png",
   "status": "0",
+  "remarks": "资料不全,缺少药品经营许可证",
   "operator_userid": "0",
   "user_type": "1",
   "app_id": "wx246605ec671bf08d"
@@ -201,6 +203,7 @@
 | `two_medical_device_registration` | `medical_device_class2_url` | 二类医疗器械备案图片URL |
 | `three_medical_device_business_license` | `medical_device_class3_url` | 三类医疗器械备案图片URL |
 | `status` | `review_status` | 审核状态(`0`→PENDING / `1`→APPROVED / `2`→REJECTED) |
+| `remarks` | `remark` | 备注(如拒绝原因等) |
 
 > 💡 入驻记录保存时,自动补充以下字段:
 > - `sync_source` ← 固定为 `MINIAPP_SYNC`
@@ -227,4 +230,5 @@
 
 | 版本 | 日期 | 变更内容 |
 |------|------|----------|
+| V1.1 | 2026-07-15 | 新增 `remarks` 字段,支持双向传递拒绝原因等备注信息 |
 | V1.0 | 2026-07-11 | 初始版本,支持第三方通过 Query 参数同步店铺信息 |

+ 11 - 5
docs/第三方登录接口文档.md

@@ -1,8 +1,8 @@
 # 第三方登录接口文档
 
-> 版本:V2.1  
-> 更新日期:2026-07-11  
-> 变更说明:**登录接口恢复全部字段**(登录同时可一并传入用户资料和入驻信息,兼容旧版);信息同步接口(`/external/sync`)已存在记录时仅更新审核状态,其他字段不允许覆盖
+> 版本:V2.2  
+> 更新日期:2026-07-15  
+> 变更说明:新增 `remarks` 字段,用于传递拒绝原因等备注信息
 
 ---
 
@@ -104,6 +104,7 @@ X-Api-Key: {我方提供的API Key}
 | `medicalDeviceClass2Url` | String | 否 | 二类医疗器械备案图片URL | `medical_device_class2_url` |
 | `medicalDeviceClass3Url` | String | 否 | 三类医疗器械备案图片URL | `medical_device_class3_url` |
 | `status` | Integer | 否 | **审核状态**:`0`-审核中 / `1`-审核成功 / `2`-审核失败(不传默认`0`) | — |
+| `remarks` | String | 否 | 备注(如拒绝原因等) | `remark` |
 
 ### 3.3 请求示例
 
@@ -158,7 +159,8 @@ X-Api-Key: {我方提供的API Key}
   "drugLicenseUrl": "https://oss.example.com/ypjy.jpg",
   "medicalDeviceClass2Url": "https://oss.example.com/elqx.jpg",
   "medicalDeviceClass3Url": "https://oss.example.com/slqx.jpg",
-  "status": 1
+  "status": 1,
+  "remarks": "资料审核不通过,缺少药品经营许可证"
 }
 ```
 
@@ -307,6 +309,7 @@ X-Api-Key: {我方提供的API Key}
 | `medicalDeviceClass2Url` | String | 否 | 二类医疗器械备案图片URL |
 | `medicalDeviceClass3Url` | String | 否 | 三类医疗器械备案图片URL |
 | `status` | Integer | 否 | **审核状态**:`0`-审核中 / `1`-审核成功 / `2`-审核失败(不传默认`0`) |
+| `remarks` | String | 否 | 备注(如拒绝原因等) |
 
 ### 4.3 审核状态说明
 
@@ -338,7 +341,8 @@ X-Api-Key: {我方提供的API Key}
   "province": "北京市",
   "city": "北京市",
   "district": "朝阳区",
-  "status": 1
+  "status": 1,
+  "remarks": "资料审核不通过"
 }
 ```
 
@@ -493,6 +497,7 @@ curl -X POST "https://your-domain.com/api/auth/external/sync" \
 | `medicalDeviceClass2Url` | `medical_device_class2_url` | 二类医疗器械备案图片URL |
 | `medicalDeviceClass3Url` | `medical_device_class3_url` | 三类医疗器械备案图片URL |
 | `status` | `review_status` | 审核状态:`0`→PENDING / `1`→APPROVED / `2`→REJECTED |
+| `remarks` | `remark` | 备注(如拒绝原因等) |
 
 > 💡 入驻记录保存时,以下字段由系统自动填入:
 > - `store_address` ← 从 `pharmacyAddress` 提取
@@ -515,3 +520,4 @@ curl -X POST "https://your-domain.com/api/auth/external/sync" \
 | V1.0 ~ V1.7 | 2026-07-07 ~ 2026-07-11 | 初始版本、字段扩展、状态字段、loginSource/skipRefreshToken |
 | V2.0 | 2026-07-11 | 拆分为登录接口 + 信息同步接口;登录接口仅接收登录字段 |
 | **V2.1** | 2026-07-11 | **登录接口恢复全部字段**(登录与信息同步可在同一请求完成);同步接口已存在入驻记录时仅允许更新审核状态 |
+| **V2.2** | 2026-07-15 | 新增 `remarks` 字段(登录+同步接口均支持),用于传递拒绝原因等备注信息 |

+ 2 - 2
zhijiayun-gateway/src/main/resources/application.yml

@@ -77,8 +77,8 @@ sms:
 # 阿里云OSS配置
 oss:
   endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
-  access-key-id: ${ALIBABA_CLOUD_ACCESS_KEY_ID:LTAI5tDwjfteBvivYN41r8sJ}
-  access-key-secret: ${ALIBABA_CLOUD_ACCESS_KEY_SECRET:yowuOGi2nYYnrqGpO3qcz94C4brcPp}
+  access-key-id: ${ALIBABA_CLOUD_ACCESS_KEY_ID:LTAI5t5pWgfa1BMztEuWBjdK}
+  access-key-secret: ${ALIBABA_CLOUD_ACCESS_KEY_SECRET:wU7FLzEr1NqLg2rJrmAu7Ibn69np0u}
   bucket-name: ${OSS_BUCKET_NAME:kailin-saas}
   base-path: ${OSS_BASE_PATH:uploads/}
   # 访问URL前缀(如绑定了自定义域名,可覆盖;默认使用OSS公网域名)

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

@@ -8,6 +8,7 @@ import com.xuekairui.user.service.BusinessLicenseService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
@@ -112,6 +113,23 @@ public class AdminLicenseController {
         return Result.success(result);
     }
 
+    /**
+     * 运营端批量同步入驻信息到第三方(智价云药店版)
+     * <p>
+     * 运营在后台勾选多条入驻记录,点击"批量同步"按钮时调用。
+     * 逐条同步,每条记录独立处理(某条失败不影响其他记录),返回汇总结果。
+     * POST /api/admin/license/batch-sync-third
+     */
+    @PostMapping("/batch-sync-third")
+    public Result<com.xuekairui.user.service.BusinessLicenseService.BatchSyncResult> batchSyncToThirdParty(
+            @Valid @RequestBody BatchSyncRequest request,
+            HttpServletRequest httpRequest) {
+        Long reviewerId = (Long) httpRequest.getAttribute("userId");
+        com.xuekairui.user.service.BusinessLicenseService.BatchSyncResult result =
+                businessLicenseService.batchSyncToThirdParty(request.getLicenseIds(), reviewerId);
+        return Result.success(result);
+    }
+
     /**
      * 查询入驻信息同步差异列表(运营端)
      * <p>
@@ -171,6 +189,16 @@ public class AdminLicenseController {
         private String remark;
     }
 
+    /**
+     * 批量同步请求
+     */
+    @Data
+    public static class BatchSyncRequest {
+        /** 入驻信息 ID 列表(至少一条) */
+        @NotEmpty(message = "请至少选择一条入驻记录")
+        private List<Long> licenseIds;
+    }
+
     /**
      * 管理员新增入驻信息请求
      */

+ 16 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminUserController.java

@@ -1,7 +1,9 @@
 package com.xuekairui.user.controller;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.Result;
+import com.xuekairui.user.dto.AdminUserDetailResponse;
 import com.xuekairui.user.dto.AdminUserResponse;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.service.AdminUserService;
@@ -38,6 +40,20 @@ public class AdminUserController {
         return Result.success(adminUserService.listUsers(page, size, userId, phone, nickname));
     }
 
+    /**
+     * 按手机号模糊 / 用户ID精确搜索用户(含入驻信息+会员等级)
+     * GET /api/admin/users/search?phone=138&page=1&size=20
+     * GET /api/admin/users/search?userId=123456&page=1&size=20
+     */
+    @GetMapping("/search")
+    public Result<IPage<AdminUserDetailResponse>> searchUsers(
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "20") Integer size,
+            @RequestParam(required = false) String phone,
+            @RequestParam(required = false) Long userId) {
+        return Result.success(adminUserService.searchUsers(page, size, phone, userId));
+    }
+
     /**
      * 强制踢出用户(使所有 Token 失效,强制重新登录)
      * POST /api/admin/users/{userId}/kick

+ 6 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/BusinessLicenseController.java

@@ -89,6 +89,12 @@ public class BusinessLicenseController {
                     .showVerifiedBadge(false)
                     .build();
         }
+        // 审核拒绝时,rejectReason 为空则以 remark 兜底
+        if ("REJECTED".equals(license.getReviewStatus())
+                && (license.getRejectReason() == null || license.getRejectReason().isBlank())
+                && license.getRemark() != null && !license.getRemark().isBlank()) {
+            license.setRejectReason(license.getRemark());
+        }
         return Result.success(license);
     }
 

+ 80 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/AdminUserDetailResponse.java

@@ -0,0 +1,80 @@
+package com.xuekairui.user.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.xuekairui.user.enums.MembershipLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 运营端用户搜索聚合响应(用户基本信息 + 入驻信息 + 会员等级)
+ *
+ * @author ProPrice Team
+ * @since 2026-07-15
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AdminUserDetailResponse {
+
+    // ===== 用户基本信息 =====
+
+    /** 用户ID(JS 大数精度保护) */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long userId;
+
+    /** 手机号 */
+    private String phone;
+
+    /** 昵称 */
+    private String nickname;
+
+    /** 微信昵称 */
+    private String wechatNickname;
+
+    /** 账号状态:0-禁用 1-正常 */
+    private Integer userStatus;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    // ===== 会员信息 =====
+
+    /** 会员等级:PLUS/PRO/ULTRA */
+    private MembershipLevel membershipLevel;
+
+    /** 会员过期时间 */
+    private LocalDateTime membershipExpireAt;
+
+    // ===== 入驻信息(未提交入驻时以下字段均为 null) =====
+
+    /** 入驻信息ID */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long licenseId;
+
+    /** 店铺名称 */
+    private String storeName;
+
+    /** 终端类型:SINGLE/CHAIN/CLINIC */
+    private String terminalType;
+
+    /** 联系人 */
+    private String contactPerson;
+
+    /** 联系电话 */
+    private String contactPhone;
+
+    /** 审核状态:NOT_SUBMITTED/PENDING/APPROVED/REJECTED */
+    private String reviewStatus;
+
+    /** 第三方同步状态:0-未同步 1-同步成功 2-同步失败(无入驻记录时为 null) */
+    private Integer syncStatus;
+
+    /** 外部系统用户ID */
+    private String externalId;
+}

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

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
@@ -78,10 +80,14 @@ public class BusinessLicenseReviewResponse {
     /** 第三方同步状态:0-未同步 1-同步成功 2-同步失败 */
     private Integer status;
 
+    /** 备注(如拒绝原因等) */
+    private String remark;
+
     /** 是否展示认证标识 */
     private Boolean showVerifiedBadge;
 
     /** 审核人ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long reviewerId;
 
     /** 审核时间 */
@@ -96,6 +102,7 @@ public class BusinessLicenseReviewResponse {
     // ===== 提交者(用户)信息 =====
 
     /** 提交者用户ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long submitterUserId;
 
     /** 提交者手机号 */
@@ -106,4 +113,16 @@ public class BusinessLicenseReviewResponse {
 
     /** 提交者用户名(第三方系统传入) */
     private String submitterUserName;
+
+    /** 提交者会员等级:PLUS/PRO/ULTRA */
+    private String submitterMembershipLevel;
+
+    /** 提交者会员过期时间 */
+    private java.time.LocalDateTime submitterMembershipExpireAt;
+
+    /** 提交者账号状态:0-禁用 1-正常 */
+    private Integer submitterUserStatus;
+
+    /** 提交者注册时间 */
+    private java.time.LocalDateTime submitterCreateTime;
 }

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

@@ -75,6 +75,9 @@ public class ExternalTokenRequest {
     /** 可选:入驻审核状态:0-审核中 / 1-审核成功 / 2-审核失败 */
     private Integer status;
 
+    /** 可选:备注(如拒绝原因等) */
+    private String remarks;
+
     // ===== 登录控制字段 =====
 
     /** 可选:是否跳过刷新 refreshToken */

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SyncInfoRequest.java

@@ -73,4 +73,7 @@ public class SyncInfoRequest {
 
     /** 可选:入驻审核状态:0-审核中 / 1-审核成功 / 2-审核失败(不传默认0) */
     private Integer status;
+
+    /** 可选:备注(如拒绝原因等) */
+    private String remarks;
 }

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SyncShopReportRequest.java

@@ -75,4 +75,7 @@ public class SyncShopReportRequest {
     /** 小程序APPID */
     @JsonProperty("app_id")
     private String appId;
+
+    /** 备注(如拒绝原因等) */
+    private String remarks;
 }

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

@@ -35,11 +35,15 @@ public interface BusinessLicenseMapper extends BaseMapper<BusinessLicense> {
             "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.review_status, bl.reject_reason, bl.remark, bl.sync_source, bl.show_verified_badge, " +
             "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 " +
+            "u.phone AS submitterPhone, u.nickname AS submitterNickname, u.user_name AS submitterUserName, " +
+            "u.membership_level AS submitterMembershipLevel, " +
+            "u.membership_expire_at AS submitterMembershipExpireAt, " +
+            "u.status AS submitterUserStatus, " +
+            "u.create_time AS submitterCreateTime " +
             "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>" +

+ 31 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/UserMapper.java

@@ -1,12 +1,43 @@
 package com.xuekairui.user.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.user.dto.AdminUserDetailResponse;
 import com.xuekairui.user.entity.User;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 /**
  * 用户数据访问层
  */
 @Mapper
 public interface UserMapper extends BaseMapper<User> {
+
+    /**
+     * 按手机号模糊 / 用户ID精确搜索用户,LEFT JOIN 入驻信息,返回聚合结果。
+     * 未提交入驻信息的用户,入驻相关字段为 null。
+     * phone 和 userId 至少传一个,同时传时 phone 优先。
+     *
+     * @param page   分页参数
+     * @param phone  手机号关键词(LIKE 模糊匹配,可选)
+     * @param userId 用户ID(精确匹配,可选)
+     * @return 分页聚合结果
+     */
+    @Select("<script>" +
+            "SELECT u.id AS userId, u.phone, u.nickname, u.wechat_nickname, u.status AS userStatus, " +
+            "u.create_time, u.membership_level, u.membership_expire_at, " +
+            "bl.id AS licenseId, bl.store_name, bl.terminal_type, " +
+            "bl.contact_person, bl.contact_phone, bl.review_status, bl.status AS syncStatus, bl.external_id " +
+            "FROM t_user u LEFT JOIN t_business_license bl ON u.id = bl.user_id " +
+            "<where>" +
+            "<if test='phone != null and phone != \"\"'>u.phone LIKE CONCAT('%', #{phone}, '%')</if>" +
+            "<if test='userId != null'>u.id = #{userId}</if>" +
+            "</where>" +
+            "ORDER BY u.create_time DESC" +
+            "</script>")
+    IPage<AdminUserDetailResponse> selectUsersWithLicense(Page<?> page,
+                                                          @Param("phone") String phone,
+                                                          @Param("userId") Long userId);
 }

+ 24 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java

@@ -1,9 +1,11 @@
 package com.xuekairui.user.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.dto.AdminUserDetailResponse;
 import com.xuekairui.user.dto.AdminUserResponse;
 import com.xuekairui.user.entity.Admin;
 import com.xuekairui.user.entity.User;
@@ -58,6 +60,28 @@ public class AdminUserService {
         return resultPage;
     }
 
+    /**
+     * 按手机号模糊 / 用户ID精确搜索用户,关联入驻信息一起返回。
+     * phone 和 userId 至少传一个,同时传时 phone 优先。
+     *
+     * @param pageNum  页码
+     * @param pageSize 每页大小
+     * @param phone    手机号关键词(LIKE 模糊匹配,可选)
+     * @param userId   用户ID(精确匹配,可选)
+     * @return 分页聚合结果
+     */
+    public IPage<AdminUserDetailResponse> searchUsers(Integer pageNum, Integer pageSize, String phone, Long userId) {
+        boolean hasPhone = phone != null && !phone.isBlank();
+        boolean hasUserId = userId != null;
+        if (!hasPhone && !hasUserId) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "请输入手机号或用户ID");
+        }
+        // 同时传时 phone 优先,userId 置空避免 SQL 同时命中两个条件
+        String effectivePhone = hasPhone ? phone.trim() : null;
+        Long effectiveUserId = hasPhone ? null : userId;
+        return userMapper.selectUsersWithLicense(new Page<>(pageNum, pageSize), effectivePhone, effectiveUserId);
+    }
+
     /**
      * 分页查询管理员列表(查 t_admin 表)
      */

+ 21 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -743,6 +743,9 @@ public class AuthService {
                             .reviewStatus(reviewStatus)
                             .showVerifiedBadge(false)
                             .syncSource("MINIAPP_SYNC")
+                            .remark(req.getRemarks())
+                            .rejectReason(LicenseStatus.REJECTED.name().equals(reviewStatus)
+                                    ? req.getRemarks() : null)
                             .build();
             businessLicenseService.insert(license);
             log.info("第三方店铺同步创建入驻记录: userId={}, licenseId={}, status={}",
@@ -770,6 +773,10 @@ public class AuthService {
 
             // 更新第三方传入的入驻字段(非空即覆盖)
             applyThirdPartyLicenseFields(existing, req, storeName, terminalType);
+            // 拒绝时,remarks 同时保存为 rejectReason
+            if (LicenseStatus.REJECTED.name().equals(existing.getReviewStatus()) && isNotBlank(req.getRemarks())) {
+                existing.setRejectReason(req.getRemarks());
+            }
             businessLicenseService.updateById(existing);
 
             log.info("第三方店铺同步已存在记录: licenseId={}, userId={}, wasApproved={}, newStatus={}, upgradedToApproved={}",
@@ -846,6 +853,9 @@ public class AuthService {
         if (isNotBlank(req.getThreeMedicalDeviceBusinessLicense())) {
             existing.setMedicalDeviceClass3Url(req.getThreeMedicalDeviceBusinessLicense());
         }
+        if (isNotBlank(req.getRemarks())) {
+            existing.setRemark(req.getRemarks());
+        }
     }
 
     /**
@@ -944,6 +954,7 @@ public class AuthService {
         syncReq.setContactPhone(req.getContactPhone());
         syncReq.setBusinessLicenseNo(req.getBusinessLicenseNo());
         syncReq.setStatus(req.getStatus());
+        syncReq.setRemarks(req.getRemarks());
         syncBusinessLicense(syncReq, userId);
     }
 
@@ -1006,6 +1017,9 @@ public class AuthService {
                             .reviewStatus(reviewStatus)
                             .showVerifiedBadge(false)
                             .syncSource("MINIAPP_SYNC")
+                            .remark(request.getRemarks())
+                            .rejectReason(LicenseStatus.REJECTED.name().equals(reviewStatus)
+                                    ? request.getRemarks() : null)
                             .build();
             businessLicenseService.insert(license);
             log.info("第三方用户{}同步入驻信息,创建新记录: licenseId={}, status={}",
@@ -1035,6 +1049,10 @@ public class AuthService {
 
             // 更新第三方传入的入驻字段(非空即覆盖)
             applySyncInfoLicenseFields(existing, request, storeName);
+            // 拒绝时,remarks 同时保存为 rejectReason
+            if (LicenseStatus.REJECTED.name().equals(existing.getReviewStatus()) && isNotBlank(request.getRemarks())) {
+                existing.setRejectReason(request.getRemarks());
+            }
             businessLicenseService.updateById(existing);
 
             log.info("第三方用户{}重新同步入驻信息: licenseId={}, wasApproved={}, newStatus={}",
@@ -1096,6 +1114,9 @@ public class AuthService {
         if (isNotBlank(req.getBusinessLicenseNo())) {
             existing.setCreditCode(req.getBusinessLicenseNo());
         }
+        if (isNotBlank(req.getRemarks())) {
+            existing.setRemark(req.getRemarks());
+        }
     }
 
     /**

+ 100 - 9
zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java

@@ -24,6 +24,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -532,19 +533,19 @@ public class BusinessLicenseService {
                     }
                 }
             });
-
-            // 事务提交后同步客户入驻信息到第三方系统(智价云药店版),仅首次审核通过时同步
-            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                @Override
-                public void afterCommit() {
-                    syncShopResourceToThirdParty(submitter, license, "APPROVE");
-                }
-            });
         } else {
-            log.info("入驻信息重新审核通过,奖励已发放(CAS防并发),跳过重复奖励及第三方同步: licenseId={}, userId={}",
+            log.info("入驻信息重新审核通过,奖励已发放(CAS防并发),跳过重复奖励: licenseId={}, userId={}",
                     licenseId, userId);
         }
 
+        // 事务提交后同步客户入驻信息到第三方(无论是否首次通过,每次审核状态变更都同步)
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                syncShopResourceToThirdParty(submitter, license, "APPROVE");
+            }
+        });
+
         // 审计日志:审核通过,记录客户信息
         String userName = submitter.getNickname() != null
                 ? submitter.getNickname() : String.valueOf(userId);
@@ -704,6 +705,7 @@ public class BusinessLicenseService {
         license.setReviewTime(LocalDateTime.now());
         license.setShowVerifiedBadge(false);
         license.setRejectReason(rejectReason);
+        license.setRemark(rejectReason);
         businessLicenseMapper.updateById(license);
 
         // 变更后快照
@@ -827,6 +829,95 @@ public class BusinessLicenseService {
         }
     }
 
+    /**
+     * 运营端批量同步入驻信息到第三方系统(智价云药店版)
+     * <p>
+     * 运营在后台勾选多条入驻记录,点击"批量同步"按钮时调用。
+     * 逐条同步,每条记录独立处理(某条失败不影响其他记录),全部完成后返回汇总结果。
+     * 同步成功的回写 externalId 并标记 status=1,失败的标记 status=2。
+     *
+     * @param licenseIds 入驻信息 ID 列表
+     * @param reviewerId 操作管理员 ID
+     * @return 批量同步结果(总数/成功数/失败数 + 逐条明细)
+     */
+    public BatchSyncResult batchSyncToThirdParty(List<Long> licenseIds, Long reviewerId) {
+        if (licenseIds == null || licenseIds.isEmpty()) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "请至少选择一条入驻记录");
+        }
+
+        List<BatchSyncResult.SyncItem> items = new ArrayList<>();
+        int successCount = 0;
+        int failCount = 0;
+
+        for (Long licenseId : licenseIds) {
+            BatchSyncResult.SyncItem item = new BatchSyncResult.SyncItem();
+            item.setLicenseId(licenseId);
+            try {
+                String result = manualSyncToThirdParty(licenseId, reviewerId);
+                if (result != null && result.startsWith("同步成功")) {
+                    item.setSuccess(true);
+                    item.setMessage(result);
+                    successCount++;
+                } else {
+                    item.setSuccess(false);
+                    item.setMessage(result != null ? result : "同步未成功:第三方未返回用户ID");
+                    failCount++;
+                }
+            } catch (BusinessException e) {
+                item.setSuccess(false);
+                item.setMessage(e.getMessage());
+                failCount++;
+                log.warn("批量同步入驻信息,单条记录不存在: licenseId={}, error={}", licenseId, e.getMessage());
+            } catch (Exception e) {
+                item.setSuccess(false);
+                item.setMessage("同步异常:" + e.getMessage());
+                failCount++;
+                log.warn("批量同步入驻信息异常: licenseId={}, error={}", licenseId, e.getMessage());
+            }
+            items.add(item);
+        }
+
+        log.info("运营批量同步入驻信息完成: total={}, success={}, fail={}, reviewerId={}",
+                licenseIds.size(), successCount, failCount, reviewerId);
+
+        return BatchSyncResult.builder()
+                .total(licenseIds.size())
+                .successCount(successCount)
+                .failCount(failCount)
+                .items(items)
+                .build();
+    }
+
+    /**
+     * 批量同步结果
+     */
+    @lombok.Data
+    @lombok.Builder
+    @lombok.NoArgsConstructor
+    @lombok.AllArgsConstructor
+    public static class BatchSyncResult {
+        /** 总条数 */
+        private int total;
+        /** 成功条数 */
+        private int successCount;
+        /** 失败条数 */
+        private int failCount;
+        /** 逐条明细 */
+        private List<SyncItem> items;
+
+        @lombok.Data
+        @lombok.NoArgsConstructor
+        @lombok.AllArgsConstructor
+        public static class SyncItem {
+            /** 入驻信息 ID */
+            private Long licenseId;
+            /** 是否成功 */
+            private boolean success;
+            /** 结果描述 */
+            private String message;
+        }
+    }
+
     // ======================== 运营侧:第三方同步差异处理 ========================
 
     /**

+ 4 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/impl/ShopResourceSyncServiceImpl.java

@@ -98,6 +98,10 @@ public class ShopResourceSyncServiceImpl implements ShopResourceSyncService {
         }
         params.put("app_id", appId);
         params.put("status", mapReviewStatus(license.getReviewStatus()));
+        // remarks 优先取拒绝原因,无拒绝原因才用 remark 字段兜底
+        String remarks = (license.getRejectReason() != null && !license.getRejectReason().isBlank())
+                ? license.getRejectReason() : license.getRemark();
+        params.put("remarks", nullToEmpty(remarks));
         return params;
     }