فهرست منبع

邀请有礼活动管理更新

liuchengsen 1 ماه پیش
والد
کامیت
b05a9e2f9e

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

@@ -0,0 +1,227 @@
+# 第三方店铺同步接口文档
+
+> 版本:V1.0  
+> 更新日期:2026-07-11  
+> 说明:第三方(如省药监平台)将药店入驻信息同步到我方系统,自动创建/更新用户及入驻记录
+
+---
+
+## 1. 概述
+
+本接口用于第三方系统向我方同步药店入驻信息。按手机号自动匹配用户,用户不存在则自动创建,并同步入驻信息到 `t_business_license`。
+
+| 项目 | 说明 |
+|------|------|
+| **接口名称** | 第三方店铺信息同步 |
+| **请求方式** | `POST` |
+| **接口路径** | `/api/sync_shop_resource/report_user` |
+| **鉴权** | **无鉴权**(白名单路径,对第三方开放) |
+| **Content-Type** | `application/json` |
+
+---
+
+## 2. 请求参数
+
+所有参数通过 **JSON Body** 传递,字段名使用 **snake_case** 格式。
+
+### 2.1 必填字段
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `phone` | String | 是 | 手机号码(用于识别客户唯一身份) |
+
+### 2.2 用户/药店信息字段(全部可选)
+
+| 参数名 | 类型 | 必填 | 说明 | 映射我方字段 |
+|--------|------|------|------|--------------|
+| `contact_name` | String | 否 | 联系人名称 | `nickname`, `contact_person` |
+| `contact_phone` | String | 否 | 联系电话 | `contact_phone` |
+| `contact_shop` | String | 否 | 店铺名称 | `pharmacy_name`, `store_name` |
+| `contact_province` | String | 否 | 省份 | `province` |
+| `contact_city` | String | 否 | 城市 | `city` |
+| `contact_area` | String | 否 | 地区 | `district` |
+| `contact_addr` | String | 否 | 详细地址 | `pharmacy_address`, `store_address` |
+
+### 2.3 店铺类型
+
+| 参数名 | 类型 | 必填 | 说明 | 映射值 |
+|--------|------|------|------|--------|
+| `shop_type` | String | 否 | 店铺类型 | `1`→SINGLE 单店 / `2`→CHAIN 连锁 / `3`→CLINIC 诊所、社康 |
+
+### 2.4 证照图片字段(全部可选)
+
+| 参数名 | 类型 | 必填 | 说明 | 映射我方字段 |
+|--------|------|------|------|--------------|
+| `business_license_image` | String | 否 | 营业执照图片URL | `license_image_url` |
+| `drug_business_license_image` | String | 否 | 药品经营许可证图片URL | `drug_license_url` |
+| `two_medical_device_registration` | String | 否 | 二类医疗器械备案图片URL | `medical_device_class2_url` |
+| `three_medical_device_business_license` | String | 否 | 三类医疗器械经营许可图片URL | `medical_device_class3_url` |
+
+### 2.5 审核与操作字段
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `status` | String | 否 | 审核状态:`0`=审核中 / `1`=审核成功 / `2`=审核失败(不传默认`0`) |
+| `operator_userid` | String | 否 | 操作人用户ID(默认`0`) |
+| `user_type` | String | 否 | 用户类型:`1`=客户端用户 / `2`=后台用户 |
+| `app_id` | String | 否 | 小程序APPID |
+
+---
+
+## 3. 请求示例
+
+```json
+{
+  "phone": "13800138000",
+  "contact_name": "张老三",
+  "contact_shop": "好店铺",
+  "shop_type": "1",
+  "contact_province": "广东省",
+  "contact_city": "深圳市",
+  "contact_area": "龙华新区",
+  "contact_addr": "上芬街道",
+  "contact_phone": "18219207546",
+  "business_license_image": "https://kailin-saas.oss-cn-shenzhen.aliyuncs.com/uploads/process/2026/07/ch8K4VULklSVWYEYolVPTxvURA7fJlDsnzpHRZno.png",
+  "drug_business_license_image": "https://kailin-saas.oss-cn-shenzhen.aliyuncs.com/uploads/process/2026/07/ch8K4VULklSVWYEYolVPTxvURA7fJlDsnzpHRZno.png",
+  "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",
+  "operator_userid": "0",
+  "user_type": "1",
+  "app_id": "wx246605ec671bf08d"
+}
+```
+
+---
+
+## 4. 响应
+
+### 4.1 通用响应格式
+
+```json
+{
+  "code": "success",
+  "msg": "同步成功",
+  "data": {
+    "user_id": "1234567890123456789",
+    "new_user": false
+  }
+}
+```
+
+### 4.2 响应字段
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `code` | String | `"success"` 成功 / `"error"` 失败 |
+| `msg` | String | 提示信息 |
+| `data.user_id` | String | 我方系统中的用户ID |
+| `data.new_user` | Boolean | 是否为新创建的用户 |
+
+### 4.3 错误响应
+
+```json
+{
+  "code": "error",
+  "msg": "手机号不能为空"
+}
+```
+
+---
+
+## 5. 业务逻辑
+
+### 5.1 用户处理
+
+| 场景 | 行为 |
+|------|------|
+| 手机号**不存在** | 自动创建新用户(PLUS 会员),昵称用 contactName |
+| 手机号**已存在** | 更新非空的用户信息字段 |
+
+### 5.2 入驻信息处理
+
+| 场景 | 行为 |
+|------|------|
+| 无入驻记录 | 创建新记录,写入全部证照和店铺信息 |
+| 已有入驻记录 | 更新非空字段(证照 URL、店铺信息等) |
+
+### 5.3 审核状态
+
+| status 值 | 我方状态 | 说明 |
+|-----------|----------|------|
+| `0` 或不传 | `PENDING` | 待运营审核 |
+| `1` | `APPROVED` | 审核通过,**首次通过赠送 30 天 PRO 高级会员** |
+| `2` | `REJECTED` | 审核失败 |
+
+> 💡 已 APPROVED 的状态不会因后续调用而降级。
+
+### 5.4 shopType 映射
+
+| 第三方值 | 我方 terminalType |
+|----------|-------------------|
+| `"1"` | `SINGLE`(单店) |
+| `"2"` | `CHAIN`(连锁) |
+| `"3"` | `CLINIC`(诊所、社康) |
+| 其他 | 原值透传 |
+
+---
+
+## 6. 字段映射表
+
+### 6.1 用户信息(t_users)
+
+| 第三方字段 | 我方字段 | 说明 |
+|------------|----------|------|
+| `phone` | `phone` | 手机号(唯一标识) |
+| `contact_name` | `nickname` | 用户昵称 |
+| `contact_shop` | `pharmacy_name` | 药店名称 |
+| `contact_province` | `province` | 省 |
+| `contact_city` | `city` | 市 |
+| `contact_area` | `district` | 区 |
+| `contact_addr` | `pharmacy_address` | 详细地址 |
+| `contact_phone` | `contact_phone` | 联系电话 |
+
+### 6.2 入驻信息(t_business_license)
+
+| 第三方字段 | 我方字段 | 说明 |
+|------------|----------|------|
+| `contact_shop` | `store_name` | 店铺名称 |
+| `shop_type` | `terminal_type` | 终端类型(`1`→SINGLE / `2`→CHAIN / `3`→CLINIC) |
+| `contact_province` | `province` | 省 |
+| `contact_city` | `city` | 市 |
+| `contact_area` | `district` | 区 |
+| `contact_addr` | `store_address` | 详细地址 |
+| `contact_name` | `contact_person` | 联系人 |
+| `contact_phone` | `contact_phone` | 联系电话 |
+| `business_license_image` | `license_image_url` | 营业执照图片URL |
+| `drug_business_license_image` | `drug_license_url` | 药品经营许可证图片URL |
+| `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) |
+
+> 💡 入驻记录保存时,自动补充以下字段:
+> - `sync_source` ← 固定为 `MINIAPP_SYNC`
+> - `show_verified_badge` ← 首次审核通过时设为 `true`
+> - `credit_code` ← 本次不自动填入(第三方可通过 `/external/sync` 接口传入 `businessLicenseNo` 补充)
+
+---
+
+## 7. 注意事项
+
+| # | 说明 |
+|---|------|
+| 1 | 本接口**无鉴权**,直接对第三方开放,路径已加入白名单 |
+| 2 | 请求参数通过 JSON Body 传递,字段名使用 snake_case 格式 |
+| 3 | 手机号未注册时自动创建用户,初始化 PLUS 会员 |
+| 4 | 不返回 Token,仅返回同步确认(user_id + new_user) |
+| 5 | 如需返回 Token,请调用登录接口 `/api/auth/external/miniapp-token` |
+| 6 | 建议第三方在店铺信息变更时主动调用本接口更新 |
+| 7 | 状态字段为字符串类型(`"0"`/`"1"`/`"2"`),内部自动转为 Integer |
+
+---
+
+## 8. 变更记录
+
+| 版本 | 日期 | 变更内容 |
+|------|------|----------|
+| V1.0 | 2026-07-11 | 初始版本,支持第三方通过 Query 参数同步店铺信息 |

+ 264 - 230
docs/第三方登录接口文档.md

@@ -1,77 +1,87 @@
 # 第三方登录接口文档
 
-> 版本:V1.6  
-> 更新日期:2026-07-10  
-> 变更说明:`licenseApproved`(Boolean)改为 `status`(Integer:0-审核中 / 1-审核成功 / 2-审核失败),支持审核失败状态;调用第三方同步接口同步传 `status`
+> 版本:V2.1  
+> 更新日期:2026-07-11  
+> 变更说明:**登录接口恢复全部字段**(登录同时可一并传入用户资料和入驻信息,兼容旧版);信息同步接口(`/external/sync`)已存在记录时仅更新审核状态,其他字段不允许覆盖
 
 ---
 
 ## 1. 概述
 
-本接口用于第三方系统通过用户手机号换取登录 Token,实现免密登录。系统会自动查找或注册用户,返回访问令牌和刷新令牌。同时支持第三方系统传入用户详细信息(用户名、药店名称、地址、联系人等),由我方负责存储,第三方无需我方再次维护用户信息。
+本套接口用于第三方系统与药汇购之间的用户数据互通,包含**两个接口**:
+
+| # | 接口 | 路径 | 鉴权 | 职责 |
+|---|------|------|------|------|
+| **登录接口** | 按手机号签发 Token | `POST /api/auth/external/miniapp-token` | X-Api-Key | 查找/注册用户 + 更新资料 + 同步入驻信息,返回 Token |
+| **同步接口** | 信息同步(独立) | `POST /api/auth/external/sync` | X-Api-Key | 更新用户资料 + 同步入驻信息(需用户已存在) |
+
+> 💡 **两种使用方式**:
+> - **一步到位**:登录接口直接传入全部字段(手机号 + 用户信息 + 入驻信息),一次请求完成登录和信息同步
+> - **分步调用**:先调登录接口获取 Token,再调同步接口更新详细资料和入驻资质
 
 **核心特性:**
 - 按手机号自动查找或注册用户
-- 支持第三方传入用户名、药店信息等,自动保存到用户表
-- 支持第三方传入入驻信息(资质图片URL等),同步写入 `t_business_license`,免去额外调用入驻接口
-- 不递增 `tokenVersion`,不踢出 PC 端已有会话
+- **支持双渠道**:`loginSource=MINIAPP`(小程序,30天过期,不踢PC) / `loginSource=PC`(PC端,1小时过期,踢旧PC会话),不传默认 MINIAPP
+- 支持传入 `skipRefreshToken=true` 仅获取 accessToken,不生成/覆盖 refreshToken
 - 新用户自动初始化 **PLUS 会员(普通会员)**
 - 第三方已审核用户(`status=1`)自动赠送 **30 天 PRO 高级会员**
-- 支持传入小程序 `openId` / `unionId`,一次性保存后后续支付等场景直接从用户表读取,**无需重复授权**
-
----
-
-## 2. 接口信息
-
-| 项目 | 说明 |
-|------|------|
-| **接口名称** | 外部系统换取小程序 Token |
-| **请求方式** | `POST` |
-| **接口路径** | `/api/auth/external/miniapp-token` |
-| **Content-Type** | `application/json` |
-| **字符编码** | `UTF-8` |
+- 支持传入小程序 `openId` / `unionId`,支付场景无需重复授权
+- **已存在用户的入驻信息仅允许更新审核状态,其他字段保留原值**(后续由运营端确认更新)
 
 ---
 
-## 3. 鉴权方式
+## 2. 鉴权方式
 
-使用 **API Key** 鉴权,在请求头中传入:
+两个接口均使用 **API Key** 鉴权,在请求头中传入:
 
 ```
 X-Api-Key: {我方提供的API Key}
 ```
 
-> **API Key 格式建议**:32 位随机十六进制字符串(大小写字母 + 数字)。  
-> 参考示例:`a3f8c21e67b94d0e5f1a6c8d3b9e2071`
->
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `X-Api-Key` | String | 是 | 接口鉴权密钥 |
+| `Content-Type` | String | 是 | 固定值 `application/json` |
+
 > ⚠️ 请妥善保管 API Key,不要泄露到前端代码或客户端。请在后端服务中调用此接口。
 
 ---
 
-## 4. 请求参数
+## 3. 登录接口(`miniapp-token`)
 
-### 4.1 请求头(Headers)
+### 3.1 接口信息
 
-| 参数名 | 类型 | 必填 | 说明 |
-|--------|------|------|------|
-| `X-Api-Key` | String | 是 | 接口鉴权密钥 |
-| `Content-Type` | String | 是 | 固定值 `application/json` |
+| 项目 | 说明 |
+|------|------|
+| **接口名称** | 外部系统换取登录 Token |
+| **请求方式** | `POST` |
+| **接口路径** | `/api/auth/external/miniapp-token` |
+| **Content-Type** | `application/json` |
+
+> 💡 登录接口支持**登录 + 信息同步一体化**:除了必传手机号外,可选择性传入用户资料和入驻信息字段,一步完成登录和信息同步。
 
-### 4.2 请求体(Body)
+### 3.2 请求参数
 
-#### 基础字段
+#### 必填字段
 
 | 参数名 | 类型 | 必填 | 说明 |
 |--------|------|------|------|
 | `phone` | String | 是 | 用户手机号 |
+
+#### 登录控制字段
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
 | `inviteCode` | String | 否 | 邀请码(仅新用户注册时生效) |
+| `loginSource` | String | 否 | 登录来源。`MINIAPP`(小程序,30天过期,不踢PC) / `PC`(PC端,1小时过期,踢旧PC会话)。**不传默认 `MINIAPP`** |
+| `skipRefreshToken` | Boolean | 否 | `true`=仅返回 accessToken(refreshToken 返回 null),适用于客户端已持有 refreshToken 仅需换 accessToken 的场景;**不传默认 `false`** |
 
-#### 用户信息字段(第三方传入,自动保存到用户表)
+#### 用户信息字段(全部可选,非空才写入/覆盖
 
 | 参数名 | 类型 | 必填 | 说明 | 对应我方字段 |
 |--------|------|------|------|--------------|
-| `username` | String | 否 | 用户名(保存到 userName 字段;若未传 nickname,则自动作为 nickname 的兜底值) | `nickname` / `userName` |
-| `nickname` | String | 否 | 昵称(不传则使用 username,再不传则按手机号生成) | `nickname` |
+| `username` | String | 否 | 用户名(保存到 userName 字段;若未传 nickname,则自动作为 nickname 的兜底值) | `user_name` |
+| `nickname` | String | 否 | 昵称(不传则依次取 username、手机号后4位生成) | `nickname` |
 | `pharmacyName` | String | 否 | 药店名称 | `pharmacy_name` |
 | `province` | String | 否 | 省 | `province` |
 | `city` | String | 否 | 市 | `city` |
@@ -80,44 +90,24 @@ X-Api-Key: {我方提供的API Key}
 | `contactPerson` | String | 否 | 联系人 | `contact_person` |
 | `contactPhone` | String | 否 | 联系电话 | `contact_phone` |
 | `businessLicenseNo` | String | 否 | 营业执照号(统一社会信用代码) | `business_license_no` |
-| `openId` | String | 否 | 微信小程序 openId(第三方从小程序 wx.login 获取后传入,保存到 wechat_mini_open_id。传入后支付场景无需重复授权) | `wechat_mini_open_id` |
-| `unionId` | String | 否 | 微信开放平台 unionId(用于跨应用用户统一标识,保存到 wechat_union_id) | `wechat_union_id` |
+| `openId` | String | 否 | 微信小程序 openId(保存到 wechat_mini_open_id) | `wechat_mini_open_id` |
+| `unionId` | String | 否 | 微信开放平台 unionId(跨应用用户统一标识) | `wechat_union_id` |
 
-#### 入驻信息字段(对应 /api/business-license/upload,均为可选,传入后同步写入 t_business_license)
+#### 入驻信息字段(全部可选,传入后同步写入 t_business_license)
 
 | 参数名 | 类型 | 必填 | 说明 | 对应我方字段 |
 |--------|------|------|------|--------------|
-| `storeName` | String | 否 | 店铺名称(不传则使用 pharmacyName) | `store_name` |
-| `terminalType` | String | 否 | 终端类型:`1`-单店 / `2`-连锁 / `3`-诊所、社康等 | `terminal_type` |
+| `storeName` | String | 否 | 店铺名称(不传则使用 pharmacyName 兜底) | `store_name` |
+| `terminalType` | String | 否 | 终端类型:`SINGLE`-单店 / `CHAIN`-连锁 / `CLINIC`-诊所 / `COMMUNITY_HEALTH`-社康 | `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` |
-| `status` | Integer | 否 | **审核状态**:`0`-审核中 / `1`-审核成功 / `2`-审核失败(不传默认`0`)。`1` = 第三方已审核通过,无需我方再次审核,直接设为 APPROVED 并赠送 30 天高级会员 | — |
-
-> 💡 **审核状态说明**:
-> - 如果第三方系统已完成资质审核,传 `status: 1`,我方将直接标记为"审核通过"并赠送 30 天 PRO 高级会员
-> - 如果第三方审核失败,传 `status: 2`,我方将标记为"审核失败"
-> - 如果第三方尚未审核或不确定,传 `status: 0` 或不传(默认 `PENDING`),由我方运营审核
-> - 已审核通过的入驻记录不会因后续调用而降级
-> - 只要请求中包含任一入驻信息字段(storeName / terminalType / businessLicenseUrl / drugLicenseUrl / medicalDeviceClass2Url / medicalDeviceClass3Url),即自动写入 `t_business_license` 表
-> - `status=1`:审核状态设为 `APPROVED`(审核通过),首次通过赠送 30 天 PRO 高级会员
-> - `status=0` 或不传:审核状态设为 `PENDING`(待运营审核)
-> - `status=2`:审核状态设为 `REJECTED`(审核失败)
-> - 已存在入驻记录:已 APPROVED 状态不降级,PENDING/REJECTED 状态可随 `status` 更新(含互转、升级到 APPROVED)
-> - 省/市/区/地址/联系人/电话等字段会自动从用户信息字段中提取填入
->
-> 💡 **字段更新规则**:
-> - 新用户会写入所有传入字段
-> - 已存在用户仅更新非空字段(不会清空已有数据)
->
-> 💡 **nickname 优先级**:`nickname` > `username` > 手机号生成(如 `用户8000`)
->
-> 💡 **建议**:第三方每次调用时传入最新的用户信息,我方会自动同步更新,无需额外维护。
-
-### 4.3 请求示例
-
-#### 最简请求(仅必填字段)
+| `status` | Integer | 否 | **审核状态**:`0`-审核中 / `1`-审核成功 / `2`-审核失败(不传默认`0`) | — |
+
+### 3.3 请求示例
+
+#### 最简请求(默认小程序,30天过期)
 
 ```json
 {
@@ -125,12 +115,31 @@ X-Api-Key: {我方提供的API Key}
 }
 ```
 
-#### 完整请求(含用户信息 + 入驻信息
+#### PC端登录(1小时过期
 
 ```json
 {
   "phone": "13800138000",
-  "inviteCode": "ABC123",
+  "loginSource": "PC"
+}
+```
+
+#### 仅刷新 accessToken(不生成新 refreshToken)
+
+```json
+{
+  "phone": "13800138000",
+  "loginSource": "MINIAPP",
+  "skipRefreshToken": true
+}
+```
+
+#### 一步到位(登录 + 用户信息 + 入驻信息)
+
+```json
+{
+  "phone": "13800138000",
+  "loginSource": "MINIAPP",
   "username": "yaohuigou001",
   "nickname": "药汇购001",
   "pharmacyName": "北京同仁堂大药房",
@@ -144,7 +153,7 @@ X-Api-Key: {我方提供的API Key}
   "openId": "oJx1x5xxxxxxxxxxxxxxxxx",
   "unionId": "oABCD1234xxxxxxxxxxxxxx",
   "storeName": "北京同仁堂大药房(朝阳分店)",
-  "terminalType": "1",
+  "terminalType": "SINGLE",
   "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
   "drugLicenseUrl": "https://oss.example.com/ypjy.jpg",
   "medicalDeviceClass2Url": "https://oss.example.com/elqx.jpg",
@@ -153,11 +162,9 @@ X-Api-Key: {我方提供的API Key}
 }
 ```
 
----
-
-## 5. 响应参数
+### 3.4 响应参数
 
-### 5.1 通用响应结构
+#### 通用响应结构
 
 ```json
 {
@@ -173,43 +180,43 @@ X-Api-Key: {我方提供的API Key}
 | `message` | String | 提示信息 |
 | `data` | Object | 登录响应数据 |
 
-### 5.2 data 字段
+#### data 字段
 
 | 参数名 | 类型 | 说明 |
 |--------|------|------|
 | `userId` | String | 用户ID(字符串,避免JS大数精度丢失) |
 | `accessToken` | String | 访问令牌 |
-| `refreshToken` | String | 刷新令牌 |
-| `expiresIn` | Long | accessToken有效期(秒),默认`7200` |
+| `refreshToken` | String | 刷新令牌(`skipRefreshToken=true` 时为 null) |
+| `expiresIn` | Long | accessToken有效期(秒):MINIAPP=2592000(30天),PC=3600(1小时) |
 | `newUser` | Boolean | 是否为新注册用户 |
 | `needBindPhone` | Boolean | 始终返回`false` |
 | `role` | String | 固定值`"USER"` |
 | `userInfo` | Object | 用户基本信息 |
 
-### 5.3 userInfo 字段说明
+#### userInfo 字段
 
 | 参数名 | 类型 | 说明 |
 |--------|------|------|
 | `id` | String | 用户ID |
 | `phone` | String | 手机号 |
-| `nickname` | String | 昵称(即第三方传入的 nickname 或 username) |
+| `nickname` | String | 昵称 |
 | `userName` | String | 第三方传入的原始用户名 |
 | `avatar` | String | 头像 URL |
 | `wechatBound` | Boolean | 是否已绑定微信 |
-| `loginSource` | String | 登录来源,`"MINIAPP"` 表示小程序 |
+| `loginSource` | String | 登录来源,`"MINIAPP"` 或 `"PC"` |
 | `pharmacyName` | String | 药店名称 |
 | `province` | String | 省 |
 | `city` | String | 市 |
 | `district` | String | 区 |
 | `membershipLevel` | String | 会员等级代码(PLUS / PRO / ULTRA) |
 | `membershipLevelName` | String | 会员等级名称 |
-| `licenseStatus` | String | 营业执照审核状态:`NOT_SUBMITTED` / `PENDING` / `APPROVED` / `REJECTED` |
-| `inviterId` | String | 邀请人用户ID(未绑定邀请码则为 null) |
+| `licenseStatus` | String | 营业执照审核状态 |
+| `inviterId` | String | 邀请人用户ID |
 | `inviterNickname` | String | 邀请人昵称 |
 | `hasBoundInviteCode` | Boolean | 是否已绑定邀请码 |
 | `createTime` | String | 注册时间 |
 
-### 5.4 响应示例
+#### 响应示例
 
 ```json
 {
@@ -219,15 +226,15 @@ X-Api-Key: {我方提供的API Key}
     "userId": "1234567890123456789",
     "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
     "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
-    "expiresIn": 7200,
+    "expiresIn": 2592000,
     "newUser": false,
     "needBindPhone": false,
     "role": "USER",
     "userInfo": {
       "id": "1234567890123456789",
       "phone": "138****8000",
-      "nickname": "药汇购001",
-      "userName": "yaohuigou001",
+      "nickname": "用户8000",
+      "userName": null,
       "avatar": null,
       "wechatBound": false,
       "loginSource": "MINIAPP",
@@ -249,30 +256,124 @@ X-Api-Key: {我方提供的API Key}
 
 ---
 
-## 6. 错误码
+## 4. 信息同步接口(`sync`)
+
+### 4.1 接口信息
+
+| 项目 | 说明 |
+|------|------|
+| **接口名称** | 外部系统信息同步 |
+| **请求方式** | `POST` |
+| **接口路径** | `/api/auth/external/sync` |
+| **Content-Type** | `application/json` |
+
+> ⚠️ 用户必须先通过登录接口创建后,才能调用本接口同步信息。如果手机号对应的用户不存在,将返回错误。
+
+### 4.2 请求参数
+
+#### 必填字段
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `phone` | String | 是 | 用户手机号(用于定位用户) |
+
+#### 用户信息字段(全部可选,非空才覆盖)
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `username` | String | 否 | 用户名 |
+| `nickname` | String | 否 | 昵称 |
+| `pharmacyName` | String | 否 | 药店名称 |
+| `province` | String | 否 | 省 |
+| `city` | String | 否 | 市 |
+| `district` | String | 否 | 区 |
+| `pharmacyAddress` | String | 否 | 药店详细地址 |
+| `contactPerson` | String | 否 | 联系人 |
+| `contactPhone` | String | 否 | 联系电话 |
+| `businessLicenseNo` | String | 否 | 营业执照号 |
+| `openId` | String | 否 | 微信小程序 openId |
+| `unionId` | String | 否 | 微信开放平台 unionId |
+
+#### 入驻信息字段(全部可选)
+
+> ⚠️ **重要**:入驻信息已存在记录时,**仅允许更新审核状态(status)**,其他字段(店名、地区、证照图片等)保留原值,不允许通过第三方接口覆盖。后续修改需通过运营端确认。
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `storeName` | String | 否 | 店铺名称(不传则用 pharmacyName 兜底) |
+| `terminalType` | String | 否 | 终端类型 |
+| `businessLicenseUrl` | String | 否 | 营业执照图片URL |
+| `drugLicenseUrl` | String | 否 | 药品经营许可证图片URL |
+| `medicalDeviceClass2Url` | String | 否 | 二类医疗器械备案图片URL |
+| `medicalDeviceClass3Url` | String | 否 | 三类医疗器械备案图片URL |
+| `status` | Integer | 否 | **审核状态**:`0`-审核中 / `1`-审核成功 / `2`-审核失败(不传默认`0`) |
+
+### 4.3 审核状态说明
+
+- `status=1`:审核状态设为 `APPROVED`(审核通过),**首次通过赠送 30 天 PRO 高级会员**
+- `status=0` 或不传:审核状态设为 `PENDING`(待运营审核)
+- `status=2`:审核状态设为 `REJECTED`(审核失败)
+- 已 APPROVED 状态**不降级**(不会因后续调用覆盖)
+- PENDING/REJECTED 状态可随 `status` 更新(含互转、升级到 APPROVED)
+
+### 4.4 入驻信息更新规则
+
+| 场景 | 行为 |
+|------|------|
+| **首次同步**(无入驻记录) | 创建新记录,写入全部字段 |
+| **已存在记录** | **仅更新审核状态**,店名、证照图片等所有字段保留原值 |
+| 需修改入驻信息 | 通过运营端人工审核后更新 |
+
+### 4.5 请求示例
+
+```json
+{
+  "phone": "13800138000",
+  "username": "yaohuigou001",
+  "nickname": "药汇购新昵称",
+  "pharmacyName": "北京同仁堂大药房",
+  "province": "北京市",
+  "city": "北京市",
+  "district": "朝阳区",
+  "status": 1
+}
+```
+
+### 4.6 响应
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": null
+}
+```
+
+> 响应 `code=200` 表示同步成功,无 data 内容。
+
+---
+
+## 5. 错误码
 
 | 错误码 | 说明 | 处理建议 |
 |--------|------|----------|
 | `200` | 成功 | — |
 | `401` | API Key 无效或缺失 | 检查`X-Api-Key`请求头 |
 | `400` | 参数校验失败 | 检查`phone`是否为空 |
+| `400` | 用户不存在(同步接口) | 先调用登录接口创建用户 |
 | `500` | 服务器内部错误 | 重试,持续失败联系我方 |
 
 ---
 
-## 7. Token 使用说明
-
-### 7.1 携带 Token
+## 6. Token 使用说明
 
-后续所有业务接口请求需在请求头中携带 accessToken:
+### 6.1 携带 Token
 
 ```
 Authorization: Bearer {accessToken}
 ```
 
-### 7.2 Token 刷新
-
-accessToken 过期后,使用 refreshToken 换取新 Token:
+### 6.2 Token 刷新
 
 ```
 POST /api/auth/refresh
@@ -283,49 +384,54 @@ Content-Type: application/json
 
 > 刷新后旧 refreshToken 立即失效(Token 旋转机制)。
 
-### 7.3 心跳保活
-
-建议客户端定时调用心跳接口,保持登录状态:
+### 6.3 心跳保活
 
 ```
 POST /api/auth/heartbeat
 Authorization: Bearer {currentAccessToken}
 ```
 
-> 建议间隔 = accessToken有效期 × 80%,约5760秒。
+> **建议间隔**:PC 端约 48 分钟(3600 × 80%),小程序端约 24 天(2592000 × 80%)。
+
+> 💡 **小程序端建议**:已持有有效 refreshToken 时,调用登录接口传 `skipRefreshToken=true` 仅换新 accessToken,避免频繁生成 refreshToken。
 
 ---
 
-## 8. 注意事项
+## 7. 注意事项
 
 | # | 说明 |
 |---|------|
-| 1 | PC端严格单设备,小程序与PC互不干扰,同一账号可同时登录 |
-| 2 | 手机号未注册时自动创建账号并初始化 **PLUS 会员(普通会员)** |
-| 3 | `inviteCode`仅新用户注册时生效 |
-| 4 | API Key请在后端调用,勿暴露到客户端 |
-| 5 | accessToken默认2小时,refreshToken默认7天 |
-| 6 | 每次刷新后旧refreshToken立即作废,请保存最新的 |
-| 7 | 用户信息字段(username、药店信息等)每次调用都会同步更新,建议传入最新数据 |
-| 8 | `username` 会保存到我方的 `nickname` 字段,不需要额外维护用户名表 |
-| 9 | 传入入驻信息字段后自动创建/更新入驻记录,无需再调用 `/api/business-license/upload` |
-| 10 | `status=1` 时入驻状态直接审核通过,首次通过赠送 **30 天 PRO 高级会员** |
-| 11 | 已审核通过的入驻记录(APPROVED)不会因后续调用而降级 |
-| 12 | 传入 `openId`/`unionId` 后,小程序支付无需再次 `wx.login` 授权,后端直接从 `t_user.wechat_mini_open_id` 读取 |
+| 1 | 登录接口支持一步完成登录+信息同步(传入全部字段即可) |
+| 2 | 同步接口要求用户已存在(先调用登录接口创建),否则返回错误 |
+| 3 | PC端单设备互踢(递增 tokenVersion),小程序与PC互不干扰 |
+| 4 | Token 过期时间:PC=1小时,小程序=30天 |
+| 5 | 手机号未注册时自动创建账号并初始化 **PLUS 会员** |
+| 6 | `inviteCode`仅新用户注册时生效 |
+| 7 | API Key请在后端调用,勿暴露到客户端 |
+| 8 | `skipRefreshToken=true` 时 refreshToken 返回 null |
+| 9 | 每次刷新后旧 refreshToken 立即作废,请保存最新的 |
+| 10 | 同步接口仅更新非空字段,不会清空已有数据 |
+| 11 | `status=1` 时入驻状态直接审核通过,**首次**通过赠送 30 天 PRO 高级会员 |
+| 12 | 已审核通过的入驻记录(APPROVED)不会因后续调用而降级 |
+| 13 | 传入 `openId`/`unionId` 后,小程序支付无需再次 `wx.login` 授权 |
+| 14 | **入驻信息已存在时仅更新审核状态**,其他字段保留原值,需通过运营端修改 |
 
 ---
 
-## 9. 调用示例
+## 8. 调用示例
 
-### cURL
+### 完整流程(cURL
 
 ```bash
+# 一步到位:登录 + 同步用户信息和入驻资质
 curl -X POST "https://your-domain.com/api/auth/external/miniapp-token" \
   -H "Content-Type: application/json" \
   -H "X-Api-Key: your-api-key-here" \
   -d '{
     "phone": "13800138000",
+    "loginSource": "PC",
     "username": "yaohuigou001",
+    "nickname": "药汇购001",
     "pharmacyName": "北京同仁堂大药房",
     "province": "北京市",
     "city": "北京市",
@@ -334,147 +440,75 @@ curl -X POST "https://your-domain.com/api/auth/external/miniapp-token" \
     "contactPerson": "张三",
     "contactPhone": "13800138000",
     "businessLicenseNo": "91110105MA12345678",
-    "openId": "oJx1x5xxxxxxxxxxxxxxxxx",
-    "unionId": "oABCD1234xxxxxxxxxxxxxx",
     "storeName": "北京同仁堂大药房(朝阳分店)",
-    "terminalType": "1",
+    "terminalType": "SINGLE",
     "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
     "drugLicenseUrl": "https://oss.example.com/ypjy.jpg",
     "status": 1
   }'
-```
-
-### Java (OkHttp)
-
-```java
-OkHttpClient client = new OkHttpClient();
-
-String json = "{"
-    + "\"phone\": \"13800138000\","
-    + "\"username\": \"yaohuigou001\","
-    + "\"pharmacyName\": \"北京同仁堂大药房\","
-    + "\"province\": \"北京市\","
-    + "\"city\": \"北京市\","
-    + "\"district\": \"朝阳区\","
-    + "\"pharmacyAddress\": \"建国路88号\","
-    + "\"contactPerson\": \"张三\","
-    + "\"contactPhone\": \"13800138000\","
-    + "\"businessLicenseNo\": \"91110105MA12345678\","
-    + "\"openId\": \"oJx1x5xxxxxxxxxxxxxxxxx\","
-    + "\"unionId\": \"oABCD1234xxxxxxxxxxxxxx\","
-    + "\"storeName\": \"北京同仁堂大药房(朝阳分店)\","
-    + "\"terminalType\": \"1\","
-    + "\"businessLicenseUrl\": \"https://oss.example.com/yyzz.jpg\","
-    + "\"drugLicenseUrl\": \"https://oss.example.com/ypjy.jpg\","
-    + "\"status\": 1"
-    + "}";
-
-RequestBody body = RequestBody.create(json, MediaType.parse("application/json"));
-Request request = new Request.Builder()
-    .url("https://your-domain.com/api/auth/external/miniapp-token")
-    .post(body)
-    .addHeader("X-Api-Key", "your-api-key-here")
-    .addHeader("Content-Type", "application/json")
-    .build();
-
-try (Response response = client.newCall(request).execute()) {
-    System.out.println(response.body().string());
-}
-```
-
-### Python (requests)
 
-```python
-import requests
-
-url = "https://your-domain.com/api/auth/external/miniapp-token"
-headers = {
-    "X-Api-Key": "your-api-key-here",
-    "Content-Type": "application/json"
-}
-data = {
+# 后续独立更新信息(如仅变更用户昵称)
+curl -X POST "https://your-domain.com/api/auth/external/sync" \
+  -H "Content-Type: application/json" \
+  -H "X-Api-Key: your-api-key-here" \
+  -d '{
     "phone": "13800138000",
-    "username": "yaohuigou001",
-    "pharmacyName": "北京同仁堂大药房",
-    "province": "北京市",
-    "city": "北京市",
-    "district": "朝阳区",
-    "pharmacyAddress": "建国路88号",
-    "contactPerson": "张三",
-    "contactPhone": "13800138000",
-    "businessLicenseNo": "91110105MA12345678",
-    "openId": "oJx1x5xxxxxxxxxxxxxxxxx",
-    "unionId": "oABCD1234xxxxxxxxxxxxxx",
-    "storeName": "北京同仁堂大药房(朝阳分店)",
-    "terminalType": "1",
-    "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
-    "drugLicenseUrl": "https://oss.example.com/ypjy.jpg",
-    "status": 1
-}
-
-response = requests.post(url, json=data, headers=headers)
-print(response.json())
+    "nickname": "新昵称"
+  }'
 ```
 
 ---
 
-## 10. 字段映射表
-
-第三方传入字段与我方数据库字段的对应关系:
-
-### 10.1 用户信息(写入 t_users)
-
-| 第三方传入字段 | 我方数据库字段 | 我方实体字段 | 说明 |
-|----------------|----------------|--------------|------|
-| `phone` | `phone` | `phone` | 手机号(必填) |
-| `username` | `nickname` | `nickname` | 用户名(同时写入 nickname 和 userName) |
-| `nickname` | `nickname` | `nickname` | 昵称(优先级低于 username) |
-| `pharmacyName` | `pharmacy_name` | `pharmacyName` | 药店名称(也作为 storeName 兜底) |
-| `province` | `province` | `province` | 省 |
-| `city` | `city` | `city` | 市 |
-| `district` | `district` | `district` | 区 |
-| `pharmacyAddress` | `pharmacy_address` | `pharmacyAddress` | 药店详细地址 |
-| `contactPerson` | `contact_person` | `contactPerson` | 联系人 |
-| `contactPhone` | `contact_phone` | `contactPhone` | 联系电话 |
-| `businessLicenseNo` | `business_license_no` | `businessLicenseNo` | 营业执照号 |
-| `openId` | `wechat_mini_open_id` | `wechatMiniOpenId` | 微信小程序 openId |
-| `unionId` | `wechat_union_id` | `wechatUnionId` | 微信开放平台 unionId |
-
-### 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 |
-| `status` | `review_status` | `reviewStatus` | 审核状态:`0`→PENDING / `1`→APPROVED / `2`→REJECTED |
-
-> 💡 入驻记录保存时,以下字段由系统自动填入(无需第三方传入):
+## 9. 字段映射表
+
+### 9.1 用户信息(写入 t_users)
+
+| 第三方传入字段 | 我方数据库字段 | 说明 |
+|----------------|----------------|------|
+| `phone` | `phone` | 手机号(必填,用于定位用户) |
+| `username` | `user_name` | 用户名 |
+| `nickname` | `nickname` | 昵称 |
+| `pharmacyName` | `pharmacy_name` | 药店名称 |
+| `province` | `province` | 省 |
+| `city` | `city` | 市 |
+| `district` | `district` | 区 |
+| `pharmacyAddress` | `pharmacy_address` | 药店详细地址 |
+| `contactPerson` | `contact_person` | 联系人 |
+| `contactPhone` | `contact_phone` | 联系电话 |
+| `businessLicenseNo` | `business_license_no` | 营业执照号 |
+| `openId` | `wechat_mini_open_id` | 微信小程序 openId |
+| `unionId` | `wechat_union_id` | 微信开放平台 unionId |
+
+### 9.2 入驻信息(写入 t_business_license)
+
+| 第三方传入字段 | 我方数据库字段 | 说明 |
+|----------------|----------------|------|
+| `storeName` | `store_name` | 店铺名称(不传则用 pharmacyName) |
+| `terminalType` | `terminal_type` | 终端类型 |
+| `businessLicenseUrl` | `license_image_url` | 营业执照图片URL |
+| `drugLicenseUrl` | `drug_license_url` | 药品经营许可证图片URL |
+| `medicalDeviceClass2Url` | `medical_device_class2_url` | 二类医疗器械备案图片URL |
+| `medicalDeviceClass3Url` | `medical_device_class3_url` | 三类医疗器械备案图片URL |
+| `status` | `review_status` | 审核状态:`0`→PENDING / `1`→APPROVED / `2`→REJECTED |
+
+> 💡 入驻记录保存时,以下字段由系统自动填入:
 > - `store_address` ← 从 `pharmacyAddress` 提取
 > - `credit_code` ← 从 `businessLicenseNo` 提取
 > - `sync_source` ← 固定为 `MINIAPP_SYNC`
-> - `review_status` ← 取决于 `status`:`1` → `APPROVED`,`2` → `REJECTED`,`0`/不传 → `PENDING`
 > - `show_verified_badge` ← 首次审核通过时设为 `true`
 
 ---
 
-## 11. 联系方式
+## 10. 联系方式
 
 如有问题,请联系我方技术支持。
 
 ---
 
-## 12. 变更记录
+## 11. 变更记录
 
 | 版本 | 日期 | 变更内容 |
 |------|------|----------|
-| V1.0 | 2026-07-07 | 初始版本:基础登录接口 |
-| V1.1 | 2026-07-07 | 扩展请求参数:支持传入用户名、药店信息等,自动保存到用户表 |
-| V1.2 | 2026-07-09 | 新增入驻信息字段(storeName / terminalType / businessLicenseUrl / drugLicenseUrl / medicalDeviceClass2Url / medicalDeviceClass3Url),传入后同步写入 t_business_license,无需额外调用入驻接口 |
-| V1.3 | 2026-07-09 | 新增 `licenseApproved` 参数支持第三方已审核入驻状态直通;审核通过自动赠送 30 天 PRO 高级会员;已 APPROVED 状态不降级;响应新增 `licenseStatus` 字段 |
-| V1.4 | 2026-07-09 | 响应 userInfo 补全完整返回字段(userName、pharmacyName、province、city、district、inviterId、inviterNickname、hasBoundInviteCode);修正 nickname 优先级描述(nickname > username) |
-| V1.5 | 2026-07-09 | 新增 `openId`、`unionId` 参数,第三方传入后保存到 `wechat_mini_open_id` / `wechat_union_id`;后续小程序支付等场景直接从用户表读取,无需重复 wx.login 授权
-| V1.6 | 2026-07-10 | `licenseApproved`(Boolean)改为 `status`(Integer:`0`-审核中 / `1`-审核成功 / `2`-审核失败),支持审核失败状态;已 APPROVED 不降级,PENDING/REJECTED 可随 `status` 互转或升级;调用第三方同步接口同步传 `status` |
+| V1.0 ~ V1.7 | 2026-07-07 ~ 2026-07-11 | 初始版本、字段扩展、状态字段、loginSource/skipRefreshToken |
+| V2.0 | 2026-07-11 | 拆分为登录接口 + 信息同步接口;登录接口仅接收登录字段 |
+| **V2.1** | 2026-07-11 | **登录接口恢复全部字段**(登录与信息同步可在同一请求完成);同步接口已存在入驻记录时仅允许更新审核状态 |

+ 1 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java

@@ -60,6 +60,7 @@ public class SecurityConfig {
             "/api/payment/plans",        // 支付方案列表(公开)
             "/api/payment/channels",     // 支付渠道列表(公开)
             "/api/payment/wechat/openid", // 微信小程序获取OpenId(公开)
+            "/api/sync_shop_resource/**",  // 第三方店铺信息同步(无鉴权)
             "/api/membership/levels",    // 会员等级列表(公开)
             "/api/membership/levels/*",  // 会员等级详情(公开)
             "/error",                    // 错误页面

+ 30 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/config/ThirdPartySyncConfig.java

@@ -0,0 +1,30 @@
+package com.xuekairui.user.config;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
+
+import java.time.Duration;
+
+/**
+ * 第三方同步 HTTP 客户端配置
+ */
+@Configuration
+@RequiredArgsConstructor
+public class ThirdPartySyncConfig {
+
+    private final ThirdPartySyncProperties properties;
+
+    @Bean
+    public RestClient thirdPartySyncRestClient() {
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(Duration.ofMillis(properties.getConnectTimeout()));
+        factory.setReadTimeout(Duration.ofMillis(properties.getReadTimeout()));
+        return RestClient.builder()
+                .baseUrl(properties.getBaseUrl())
+                .requestFactory(factory)
+                .build();
+    }
+}

+ 30 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/config/ThirdPartySyncProperties.java

@@ -0,0 +1,30 @@
+package com.xuekairui.user.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 第三方店铺同步 API 配置(我方 → 第三方方向)
+ * <p>对应 application.yml 中的 external-system.shop-resource
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "external-system.shop-resource")
+public class ThirdPartySyncProperties {
+
+    /** 第三方同步接口基础地址 */
+    private String baseUrl;
+
+    /** 小程序 APPID */
+    private String appId;
+
+    /** 同步接口路径 */
+    private String syncPath = "/api/sync_shop_resource/report_user";
+
+    /** 连接超时(毫秒) */
+    private int connectTimeout = 5000;
+
+    /** 读取超时(毫秒) */
+    private int readTimeout = 10000;
+}

+ 18 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java

@@ -288,4 +288,22 @@ public class AuthController {
         LoginResponse response = authService.externalMiniAppToken(request, ip);
         return Result.success(response);
     }
+
+    /**
+     * 外部系统信息同步(独立于登录,更新用户信息 + 入驻信息)
+     * POST /api/auth/external/sync
+     * Header: X-Api-Key: {external-system.miniapp.api-key}
+     */
+    @PostMapping("/external/sync")
+    public Result<Void> syncExternalInfo(
+            @Valid @RequestBody SyncInfoRequest request,
+            HttpServletRequest httpRequest) {
+        // API Key + IP 白名单鉴权
+        String apiKey = httpRequest.getHeader("X-Api-Key");
+        String ip = ValidateUtil.getClientIp(httpRequest);
+        systemConfigService.validateAccess(apiKey, ip);
+
+        authService.syncExternalInfo(request);
+        return Result.success();
+    }
 }

+ 53 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/SyncShopController.java

@@ -0,0 +1,53 @@
+package com.xuekairui.user.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.user.dto.SyncShopReportRequest;
+import com.xuekairui.user.dto.SyncShopReportResponse;
+import com.xuekairui.user.dto.SyncShopReportWrapper;
+import com.xuekairui.user.service.AuthService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 同步用户店铺信息接口(第三方数据互通,无鉴权)
+ * <p>路径:POST /api/sync_shop_resource/report_user(query 参数,snake_case)
+ */
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class SyncShopController {
+
+    private final AuthService authService;
+    private final ObjectMapper objectMapper;
+
+    @PostMapping("/api/sync_shop_resource/report_user")
+    public SyncShopReportWrapper reportUser(HttpServletRequest httpRequest) {
+        try {
+            SyncShopReportRequest request = parseQueryParams(httpRequest);
+            SyncShopReportResponse data = authService.reportUser(request);
+            return SyncShopReportWrapper.success(data);
+        } catch (Exception e) {
+            log.error("同步用户店铺信息失败: error={}", e.getMessage(), e);
+            return SyncShopReportWrapper.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 将 query 参数(snake_case)通过 Jackson @JsonProperty 映射为 DTO
+     */
+    private SyncShopReportRequest parseQueryParams(HttpServletRequest request) {
+        Map<String, String> params = new HashMap<>();
+        request.getParameterMap().forEach((k, v) -> {
+            if (v != null && v.length > 0) {
+                params.put(k, v[0]);
+            }
+        });
+        return objectMapper.convertValue(params, SyncShopReportRequest.class);
+    }
+}

+ 16 - 8
zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java

@@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank;
 import lombok.Data;
 
 /**
- * 外部系统换取 Token 请求
+ * 外部系统换取 Token 请求(登录 + 信息同步,兼容旧版一并传入)
  */
 @Data
 public class ExternalTokenRequest {
@@ -46,18 +46,18 @@ public class ExternalTokenRequest {
     /** 可选:营业执照号 */
     private String businessLicenseNo;
 
-    /** 可选:微信小程序 openId(第三方系统从小程序 wx.login 获取后传入,保存到 wechat_mini_open_id) */
+    /** 可选:微信小程序 openId */
     private String openId;
 
-    /** 可选:微信开放平台 unionId(用于跨应用用户统一标识) */
+    /** 可选:微信开放平台 unionId */
     private String unionId;
 
-    // ===== 入驻信息字段(对应 /api/business-license/upload,均为可选) =====
+    // ===== 入驻信息字段 =====
 
-    /** 可选:店铺名称(入驻信息专用,不传则用 pharmacyName 兜底) */
+    /** 可选:店铺名称(不传则用 pharmacyName 兜底) */
     private String storeName;
 
-    /** 可选:终端类型:SINGLE-单店, CHAIN-连锁, CLINIC-诊所, COMMUNITY_HEALTH-社康 */
+    /** 可选:终端类型 */
     private String terminalType;
 
     /** 可选:营业执照图片URL */
@@ -72,6 +72,14 @@ public class ExternalTokenRequest {
     /** 可选:三类医疗器械备案图片URL */
     private String medicalDeviceClass3Url;
 
-    /** 可选:入驻审核状态:0-审核中 / 1-审核成功 / 2-审核失败(不传默认0-审核中) */
+    /** 可选:入驻审核状态:0-审核中 / 1-审核成功 / 2-审核失败 */
     private Integer status;
-}
+
+    // ===== 登录控制字段 =====
+
+    /** 可选:是否跳过刷新 refreshToken */
+    private Boolean skipRefreshToken;
+
+    /** 可选:登录来源,决定 token 过期策略(MINIAPP=30天 / PC=1小时),不传默认 MINIAPP */
+    private String loginSource;
+}

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

@@ -0,0 +1,76 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 第三方信息同步请求(独立于登录,通过手机号定位用户)
+ */
+@Data
+public class SyncInfoRequest {
+
+    /** 手机号(必填,用于定位用户) */
+    @NotBlank(message = "手机号不能为空")
+    private String phone;
+
+    // ===== 用户信息字段 =====
+
+    /** 可选:用户名(保存到 userName 字段;若未传 nickname,则自动作为 nickname 的兜底值) */
+    private String username;
+
+    /** 可选:昵称(不传则使用 username,再不传则按手机号生成) */
+    private String nickname;
+
+    /** 可选:药店名称 */
+    private String pharmacyName;
+
+    /** 可选:省 */
+    private String province;
+
+    /** 可选:市 */
+    private String city;
+
+    /** 可选:区 */
+    private String district;
+
+    /** 可选:药店详细地址 */
+    private String pharmacyAddress;
+
+    /** 可选:联系人 */
+    private String contactPerson;
+
+    /** 可选:联系电话 */
+    private String contactPhone;
+
+    /** 可选:营业执照号(统一社会信用代码) */
+    private String businessLicenseNo;
+
+    /** 可选:微信小程序 openId */
+    private String openId;
+
+    /** 可选:微信开放平台 unionId */
+    private String unionId;
+
+    // ===== 入驻信息字段(对应 t_business_license) =====
+
+    /** 可选:店铺名称(不传则用 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;
+
+    /** 可选:入驻审核状态:0-审核中 / 1-审核成功 / 2-审核失败(不传默认0) */
+    private Integer status;
+}

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

@@ -0,0 +1,78 @@
+package com.xuekairui.user.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 同步用户店铺信息请求(第三方 OpenAPI snake_case JSON 映射)
+ * <p>对应接口:POST /api/sync_shop_resource/report_user(application/json)
+ */
+@Data
+public class SyncShopReportRequest {
+
+    /** 手机号码(用于识别客户唯一身份) */
+    private String phone;
+
+    /** 省份 */
+    @JsonProperty("contact_province")
+    private String contactProvince;
+
+    /** 城市 */
+    @JsonProperty("contact_city")
+    private String contactCity;
+
+    /** 地区 */
+    @JsonProperty("contact_area")
+    private String contactArea;
+
+    /** 详细地址 */
+    @JsonProperty("contact_addr")
+    private String contactAddr;
+
+    /** 联系人名称 */
+    @JsonProperty("contact_name")
+    private String contactName;
+
+    /** 店铺名称 */
+    @JsonProperty("contact_shop")
+    private String contactShop;
+
+    /** 店铺类型:1=单店,2=连锁,3=诊所、社康等 */
+    @JsonProperty("shop_type")
+    private String shopType;
+
+    /** 联系电话 */
+    @JsonProperty("contact_phone")
+    private String contactPhone;
+
+    /** 营业执照图片URL */
+    @JsonProperty("business_license_image")
+    private String businessLicenseImage;
+
+    /** 药品经营许可证图片URL */
+    @JsonProperty("drug_business_license_image")
+    private String drugBusinessLicenseImage;
+
+    /** 二类医疗器械备案图片URL */
+    @JsonProperty("two_medical_device_registration")
+    private String twoMedicalDeviceRegistration;
+
+    /** 三类医疗器械经营许可图片URL */
+    @JsonProperty("three_medical_device_business_license")
+    private String threeMedicalDeviceBusinessLicense;
+
+    /** 审核状态:0=审核中,1=审核成功,2=审核失败 */
+    private String status;
+
+    /** 操作人用户ID(默认为0) */
+    @JsonProperty("operator_userid")
+    private String operatorUserid;
+
+    /** 用户类型:1=客户端用户,2=后台用户 */
+    @JsonProperty("user_type")
+    private String userType;
+
+    /** 小程序APPID */
+    @JsonProperty("app_id")
+    private String appId;
+}

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

@@ -0,0 +1,19 @@
+package com.xuekairui.user.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 同步用户店铺信息响应(给第三方确认同步结果,不含 Token)
+ */
+@Data
+@Builder
+public class SyncShopReportResponse {
+
+    @JsonProperty("user_id")
+    private String userId;
+
+    @JsonProperty("new_user")
+    private Boolean newUser;
+}

+ 35 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SyncShopReportWrapper.java

@@ -0,0 +1,35 @@
+package com.xuekairui.user.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 同步店铺信息接口通用响应(匹配第三方 OpenAPI 格式)
+ */
+@Data
+@Builder
+public class SyncShopReportWrapper {
+
+    private String code;
+
+    private String msg;
+
+    @JsonProperty("data")
+    private SyncShopReportResponse data;
+
+    public static SyncShopReportWrapper success(SyncShopReportResponse data) {
+        return SyncShopReportWrapper.builder()
+                .code("success")
+                .msg("同步成功")
+                .data(data)
+                .build();
+    }
+
+    public static SyncShopReportWrapper error(String message) {
+        return SyncShopReportWrapper.builder()
+                .code("error")
+                .msg(message)
+                .build();
+    }
+}

+ 36 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/ThirdPartySyncResponse.java

@@ -0,0 +1,36 @@
+package com.xuekairui.user.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 第三方同步接口响应(我方 → 第三方方向,匹配 OpenAPI 返回格式,含 Token)
+ */
+@Data
+public class ThirdPartySyncResponse {
+
+    private String code;
+
+    private String msg;
+
+    private SyncData data;
+
+    @Data
+    public static class SyncData {
+
+        @JsonProperty("access_token")
+        private String accessToken;
+
+        @JsonProperty("refresh_token")
+        private String refreshToken;
+
+        @JsonProperty("expires_in")
+        private Long expiresIn;
+
+        @JsonProperty("user_id")
+        private String userId;
+
+        @JsonProperty("new_user")
+        private Boolean newUser;
+    }
+}

+ 293 - 64
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -16,6 +16,9 @@ import com.xuekairui.user.dto.RegisterRequest;
 import com.xuekairui.user.dto.SendCodeRequest;
 import com.xuekairui.user.dto.SetPasswordRequest;
 import com.xuekairui.user.dto.SmsLoginRequest;
+import com.xuekairui.user.dto.SyncInfoRequest;
+import com.xuekairui.user.dto.SyncShopReportRequest;
+import com.xuekairui.user.dto.SyncShopReportResponse;
 import com.xuekairui.user.dto.UserGateStatus;
 import com.xuekairui.user.dto.UserInfoResponse;
 import com.xuekairui.user.dto.UserLevelResponse;
@@ -122,7 +125,7 @@ public class AuthService {
 
         // 4. 生成Token(小程序不递增tokenVersion,不踢PC)
         boolean incrementVersion = !"MINIAPP".equals(loginSource);
-        LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource);
+        LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource, false);
         response.setUserId(user.getId());
         response.setNewUser(isNewUser);
 
@@ -264,11 +267,15 @@ public class AuthService {
 
 
     /**
-     * 外部系统换取小程序 Token(供小程序后台按手机号签发
-     * <p>查找或自动注册用户,不递增 tokenVersion,不踢 PC
+     * 外部系统换取 Token(登录 + 信息同步,兼容旧版一并传入用户资料和入驻信息
+     * <p>查找或自动注册用户。根据 loginSource 决定过期策略:MINIAPP=30天不踢PC / 其他=1小时
      */
     @Transactional
     public LoginResponse externalMiniAppToken(ExternalTokenRequest request, String ip) {
+        // 登录来源:不传默认 MINIAPP(向后兼容)
+        String loginSource = request.getLoginSource() != null && !request.getLoginSource().isBlank()
+                ? request.getLoginSource() : "MINIAPP";
+
         User user = userMapper.selectOne(
                 new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
 
@@ -296,7 +303,7 @@ public class AuthService {
                     .contactPhone(request.getContactPhone())
                     .businessLicenseNo(request.getBusinessLicenseNo())
                     .membershipLevel(MembershipLevel.PLUS)
-                    .loginSource("MINIAPP")
+                    .loginSource(loginSource)
                     .status(1)
                     .build();
             userMapper.insert(user);
@@ -306,8 +313,7 @@ public class AuthService {
                         new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
             }
         } else {
-            // 已存在用户:更新第三方传入的信息(非空字段才覆盖,避免清空已有数据)
-            // userName 独立存储原始用户名;nickname 优先取 nickname,其次 username
+            // 已存在用户:更新第三方传入的信息(非空字段才覆盖)
             if (request.getUsername() != null && !request.getUsername().isBlank()) {
                 user.setUserName(request.getUsername());
             }
@@ -348,15 +354,17 @@ public class AuthService {
             }
         }
 
-        user.setLoginSource("MINIAPP");
+        user.setLoginSource(loginSource);
         user.setLastLoginTime(LocalDateTime.now());
         user.setLastLoginIp(ip);
         userMapper.updateById(user);
 
         // 同步入驻信息到 t_business_license(仅当传入入驻相关字段时)
-        syncBusinessLicense(request, user.getId());
+        syncBusinessLicenseFromTokenRequest(request, user.getId());
 
-        LoginResponse response = buildMiniAppLoginResponse(user);
+        boolean skipRefresh = request.getSkipRefreshToken() != null && request.getSkipRefreshToken();
+        boolean incrementVersion = !"MINIAPP".equals(loginSource);
+        LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource, skipRefresh);
         response.setNewUser(isNewUser);
 
         auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
@@ -379,12 +387,20 @@ public class AuthService {
 
         String newAccessToken = jwtUtil.generateAccessToken(
                 userId, roleCode, loginSource, currentVersion);
-        String newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource);
+
+        // 心跳不覆盖已有的 ACTIVE refreshToken,避免 PC/小程序互相踢 refreshToken
+        User user = userMapper.selectById(userId);
+        String newRefreshToken = null;
+        if (user != null && (user.getRefreshTokenHash() == null || !"ACTIVE".equals(user.getRefreshTokenStatus()))) {
+            newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource);
+        }
 
         User updateUser = new User();
         updateUser.setId(userId);
-        updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
-        updateUser.setRefreshTokenStatus("ACTIVE");
+        if (newRefreshToken != null) {
+            updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
+            updateUser.setRefreshTokenStatus("ACTIVE");
+        }
         userMapper.updateById(updateUser);
 
         return LoginResponse.builder()
@@ -522,10 +538,248 @@ public class AuthService {
     // ====================================
 
     /**
-     * 同步第三方传入的入驻信息到 t_business_license
-     * 仅当请求中至少有一个入驻相关字段非空时才执行
+     * 第三方信息同步(独立于登录,通过手机号定位用户,更新用户信息 + 入驻信息)
+     */
+    @Transactional
+    public void syncExternalInfo(SyncInfoRequest request) {
+        User user = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
+        if (user == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+
+        // 更新用户信息字段(非空覆盖)
+        if (request.getUsername() != null && !request.getUsername().isBlank()) {
+            user.setUserName(request.getUsername());
+        }
+        if (request.getNickname() != null && !request.getNickname().isBlank()) {
+            user.setNickname(request.getNickname());
+        } else if (request.getUsername() != null && !request.getUsername().isBlank()) {
+            user.setNickname(request.getUsername());
+        }
+        if (request.getPharmacyName() != null && !request.getPharmacyName().isBlank()) {
+            user.setPharmacyName(request.getPharmacyName());
+        }
+        if (request.getProvince() != null && !request.getProvince().isBlank()) {
+            user.setProvince(request.getProvince());
+        }
+        if (request.getCity() != null && !request.getCity().isBlank()) {
+            user.setCity(request.getCity());
+        }
+        if (request.getDistrict() != null && !request.getDistrict().isBlank()) {
+            user.setDistrict(request.getDistrict());
+        }
+        if (request.getPharmacyAddress() != null && !request.getPharmacyAddress().isBlank()) {
+            user.setPharmacyAddress(request.getPharmacyAddress());
+        }
+        if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
+            user.setContactPerson(request.getContactPerson());
+        }
+        if (request.getContactPhone() != null && !request.getContactPhone().isBlank()) {
+            user.setContactPhone(request.getContactPhone());
+        }
+        if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) {
+            user.setBusinessLicenseNo(request.getBusinessLicenseNo());
+        }
+        if (request.getOpenId() != null && !request.getOpenId().isBlank()) {
+            user.setWechatMiniOpenId(request.getOpenId());
+        }
+        if (request.getUnionId() != null && !request.getUnionId().isBlank()) {
+            user.setWechatUnionId(request.getUnionId());
+        }
+        userMapper.updateById(user);
+
+        // 同步入驻信息到 t_business_license
+        syncBusinessLicense(request, user.getId());
+
+        log.info("第三方信息同步完成: phone={}, userId={}", request.getPhone(), user.getId());
+    }
+
+    /**
+     * 第三方同步用户店铺信息(无鉴权,按手机号自动创建/更新用户,仅返回同步确认)
+     * <p>对应接口:POST /api/sync_shop_resource/report_user
+     */
+    @Transactional
+    public SyncShopReportResponse reportUser(SyncShopReportRequest request) {
+        String phone = request.getPhone();
+        if (phone == null || phone.isBlank()) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "手机号不能为空");
+        }
+
+        // 1. 查找或创建用户
+        User user = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
+        boolean isNewUser = false;
+        if (user == null) {
+            isNewUser = true;
+            user = User.builder()
+                    .phone(phone)
+                    .nickname("用户" + phone.substring(phone.length() - 4))
+                    .membershipLevel(MembershipLevel.PLUS)
+                    .loginSource("MINIAPP")
+                    .status(1)
+                    .build();
+            userMapper.insert(user);
+            membershipService.initPlusMembership(user.getId());
+        }
+
+        // 2. 映射第三方参数 → 更新用户信息(非空覆盖)
+        updateUserFromShopReport(user, request);
+        userMapper.updateById(user);
+
+        // 3. 同步入驻信息
+        syncBusinessLicenseFromShopReport(request, user.getId());
+
+        log.info("第三方店铺同步完成: phone={}, userId={}, newUser={}", phone, user.getId(), isNewUser);
+        return SyncShopReportResponse.builder()
+                .userId(String.valueOf(user.getId()))
+                .newUser(isNewUser)
+                .build();
+    }
+
+    /** 映射第三方 shop_type → 内部 terminalType */
+    private static String mapShopType(String shopType) {
+        if (shopType == null) return null;
+        return switch (shopType) {
+            case "1" -> "SINGLE";
+            case "2" -> "CHAIN";
+            case "3" -> "CLINIC";
+            default -> shopType;
+        };
+    }
+
+    /** 映射第三方 status → Integer */
+    private static Integer mapStatus(String status) {
+        if (status == null || status.isBlank()) return 0;
+        try {
+            return Integer.parseInt(status);
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    /** 更新用户信息字段 */
+    private void updateUserFromShopReport(User user, SyncShopReportRequest req) {
+        if (isNotBlank(req.getContactName())) {
+            user.setNickname(req.getContactName());
+        }
+        if (isNotBlank(req.getContactShop())) {
+            user.setPharmacyName(req.getContactShop());
+        }
+        if (isNotBlank(req.getContactProvince())) {
+            user.setProvince(req.getContactProvince());
+        }
+        if (isNotBlank(req.getContactCity())) {
+            user.setCity(req.getContactCity());
+        }
+        if (isNotBlank(req.getContactArea())) {
+            user.setDistrict(req.getContactArea());
+        }
+        if (isNotBlank(req.getContactAddr())) {
+            user.setPharmacyAddress(req.getContactAddr());
+        }
+        if (isNotBlank(req.getContactPhone())) {
+            user.setContactPhone(req.getContactPhone());
+        }
+    }
+
+    /** 同步入驻信息到 t_business_license */
+    private void syncBusinessLicenseFromShopReport(SyncShopReportRequest req, Long userId) {
+        String storeName = isNotBlank(req.getContactShop()) ? req.getContactShop() : null;
+        String terminalType = mapShopType(req.getShopType());
+        Integer status = mapStatus(req.getStatus());
+
+        boolean hasLicenseField = isNotBlank(storeName)
+                || isNotBlank(terminalType)
+                || isNotBlank(req.getBusinessLicenseImage())
+                || isNotBlank(req.getDrugBusinessLicenseImage())
+                || isNotBlank(req.getTwoMedicalDeviceRegistration())
+                || isNotBlank(req.getThreeMedicalDeviceBusinessLicense());
+
+        if (!hasLicenseField) {
+            return;
+        }
+
+        String reviewStatus;
+        if (status == null || status == 0) {
+            reviewStatus = LicenseStatus.PENDING.name();
+        } else if (status == 1) {
+            reviewStatus = LicenseStatus.APPROVED.name();
+        } else {
+            reviewStatus = LicenseStatus.REJECTED.name();
+        }
+
+        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(terminalType)
+                            .province(req.getContactProvince())
+                            .city(req.getContactCity())
+                            .district(req.getContactArea())
+                            .storeAddress(req.getContactAddr())
+                            .contactPerson(req.getContactName())
+                            .contactPhone(req.getContactPhone())
+                            .licenseImageUrl(req.getBusinessLicenseImage())
+                            .drugLicenseUrl(req.getDrugBusinessLicenseImage())
+                            .medicalDeviceClass2Url(req.getTwoMedicalDeviceRegistration())
+                            .medicalDeviceClass3Url(req.getThreeMedicalDeviceBusinessLicense())
+                            .reviewStatus(reviewStatus)
+                            .showVerifiedBadge(false)
+                            .syncSource("MINIAPP_SYNC")
+                            .build();
+            businessLicenseService.insert(license);
+            log.info("第三方店铺同步创建入驻记录: userId={}, licenseId={}, status={}",
+                    userId, license.getId(), reviewStatus);
+            if (LicenseStatus.APPROVED.name().equals(reviewStatus)) {
+                grantLicenseApprovedReward(userId, license.getId());
+            }
+        } else {
+            // 已存在记录:仅允许更新审核状态,其他字段保留原值(后续由运营端确认更新)
+            boolean upgradedToApproved = false;
+            if (!LicenseStatus.APPROVED.name().equals(existing.getReviewStatus())) {
+                existing.setReviewStatus(reviewStatus);
+                upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus);
+            }
+            businessLicenseService.updateById(existing);
+
+            log.info("第三方店铺同步已存在记录仅更新状态: licenseId={}, userId={}, oldStatus={}, newStatus={}",
+                    existing.getId(), userId, existing.getReviewStatus(), reviewStatus);
+
+            if (upgradedToApproved) {
+                grantLicenseApprovedReward(userId, existing.getId());
+            }
+        }
+    }
+
+    /**
+     * 第三方登录接口同步入驻信息(适配 ExternalTokenRequest → SyncInfoRequest)
      */
-    private void syncBusinessLicense(ExternalTokenRequest request, Long userId) {
+    private void syncBusinessLicenseFromTokenRequest(ExternalTokenRequest req, Long userId) {
+        SyncInfoRequest syncReq = new SyncInfoRequest();
+        syncReq.setStoreName(req.getStoreName());
+        syncReq.setPharmacyName(req.getPharmacyName());
+        syncReq.setTerminalType(req.getTerminalType());
+        syncReq.setBusinessLicenseUrl(req.getBusinessLicenseUrl());
+        syncReq.setDrugLicenseUrl(req.getDrugLicenseUrl());
+        syncReq.setMedicalDeviceClass2Url(req.getMedicalDeviceClass2Url());
+        syncReq.setMedicalDeviceClass3Url(req.getMedicalDeviceClass3Url());
+        syncReq.setProvince(req.getProvince());
+        syncReq.setCity(req.getCity());
+        syncReq.setDistrict(req.getDistrict());
+        syncReq.setPharmacyAddress(req.getPharmacyAddress());
+        syncReq.setContactPerson(req.getContactPerson());
+        syncReq.setContactPhone(req.getContactPhone());
+        syncReq.setBusinessLicenseNo(req.getBusinessLicenseNo());
+        syncReq.setStatus(req.getStatus());
+        syncBusinessLicense(syncReq, userId);
+    }
+
+    private void syncBusinessLicense(SyncInfoRequest request, Long userId) {
         String storeName = (request.getStoreName() != null && !request.getStoreName().isBlank())
                 ? request.getStoreName() : request.getPharmacyName();
 
@@ -584,54 +838,17 @@ public class AuthService {
                 grantLicenseApprovedReward(userId, license.getId());
             }
         } 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());
-            }
-            // 已 APPROVED 的不降级(不覆盖);非 APPROVED 状态可随第三方 status 更新(含 PENDING/REJECTED 互转、升级到 APPROVED)
+            // 已存在记录:仅允许更新审核状态,其他字段保留原值(后续由运营端确认更新)
             boolean upgradedToApproved = false;
             if (!LicenseStatus.APPROVED.name().equals(existing.getReviewStatus())) {
                 existing.setReviewStatus(reviewStatus);
                 upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus);
             }
-            existing.setSyncSource("MINIAPP_SYNC");
             businessLicenseService.updateById(existing);
 
+            log.info("第三方用户{}重新同步入驻信息,已存在记录仅更新状态: licenseId={}, oldStatus={}, newStatus={}",
+                    userId, existing.getId(), existing.getReviewStatus(), reviewStatus);
+
             // 从非APPROVED升级到APPROVED:赠送30天高级会员 + 推送Redis事件
             if (upgradedToApproved) {
                 grantLicenseApprovedReward(userId, existing.getId());
@@ -828,17 +1045,22 @@ public class AuthService {
     }
 
     /** PC 端构建登录响应:
-     * 递增 tokenVersion,踢掉旧 PC */
+     * 递增 tokenVersion,踢掉旧 PC,正常刷新 token */
     private LoginResponse buildLoginResponse(User user) {
-        return buildLoginResponseInternal(user, true, "PC");
+        return buildLoginResponseInternal(user, true, "PC", false);
     }
 
-    /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC */
+    /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC,正常刷新 token */
     private LoginResponse buildMiniAppLoginResponse(User user) {
-        return buildLoginResponseInternal(user, false, "MINIAPP");
+        return buildLoginResponseInternal(user, false, "MINIAPP", false);
     }
 
-    private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion, String loginSource) {
+    /**
+     * 构建登录响应(内部通用方法)
+     * @param skipRefreshToken 不生成/保存新 refreshToken(仅返回 accessToken)
+     */
+    private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion,
+                                                      String loginSource, boolean skipRefreshToken) {
         MDC.put("uid", String.valueOf(user.getId()));
         String roleCode = "USER";
 
@@ -854,10 +1076,17 @@ public class AuthService {
 
         String accessToken = jwtUtil.generateAccessToken(
                 user.getId(), "USER", loginSource, version);
-        String refreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
 
-        user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
-        user.setRefreshTokenStatus("ACTIVE");
+        String refreshToken = null;
+        if (!skipRefreshToken) {
+            // 仅当无 ACTIVE 的 refreshToken 时才生成新的(首次登录),已有则复用
+            // 避免 PC/小程序两端登录互相覆盖 refreshToken 导致另一端无法刷新
+            if (user.getRefreshTokenHash() == null || !"ACTIVE".equals(user.getRefreshTokenStatus())) {
+                refreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
+                user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
+                user.setRefreshTokenStatus("ACTIVE");
+            }
+        }
         userMapper.updateById(user);
 
         return LoginResponse.builder()

+ 156 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/ThirdPartySyncClient.java

@@ -0,0 +1,156 @@
+package com.xuekairui.user.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.user.config.ThirdPartySyncProperties;
+import com.xuekairui.user.dto.SyncShopReportRequest;
+import com.xuekairui.user.dto.ThirdPartySyncResponse;
+import com.xuekairui.user.entity.BusinessLicense;
+import com.xuekairui.user.entity.User;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 我方 → 第三方 同步客户端
+ * <p>调用第三方 POST /api/sync_shop_resource/report_user(query 参数,snake_case)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ThirdPartySyncClient {
+
+    private final ThirdPartySyncProperties properties;
+    private final RestClient restClient;
+    private final ObjectMapper objectMapper;
+
+    /**
+     * 同步用户店铺信息到第三方
+     *
+     * @return 第三方响应(含 token、user_id 等);失败返回 null
+     */
+    public ThirdPartySyncResponse syncToThirdParty(SyncShopReportRequest request) {
+        try {
+            Map<String, String> queryParams = toQueryParams(request);
+            String uri = UriComponentsBuilder.fromPath(properties.getSyncPath())
+                    .queryParams(toMultiValueMap(queryParams))
+                    .build()
+                    .toUriString();
+
+            ThirdPartySyncResponse response = restClient.post()
+                    .uri(uri)
+                    .retrieve()
+                    .body(ThirdPartySyncResponse.class);
+
+            if (response != null && "success".equals(response.getCode())) {
+                log.info("同步到第三方成功: phone={}, thirdUserId={}",
+                        request.getPhone(),
+                        response.getData() != null ? response.getData().getUserId() : null);
+            } else {
+                log.warn("同步到第三方返回非成功: phone={}, code={}, msg={}",
+                        request.getPhone(),
+                        response != null ? response.getCode() : null,
+                        response != null ? response.getMsg() : null);
+            }
+            return response;
+        } catch (Exception e) {
+            log.error("同步到第三方失败: phone={}, error={}", request.getPhone(), e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 使用 Jackson @JsonProperty 将 DTO 序列化为 snake_case 的 query 参数 Map
+     */
+    private Map<String, String> toQueryParams(SyncShopReportRequest req) {
+        Map<String, String> params = new LinkedHashMap<>();
+        JsonNode node = objectMapper.valueToTree(req);
+        node.fields().forEachRemaining(entry -> {
+            if (!entry.getValue().isNull()) {
+                params.put(entry.getKey(), entry.getValue().asText());
+            }
+        });
+        return params;
+    }
+
+    private static org.springframework.util.MultiValueMap<String, String> toMultiValueMap(
+            Map<String, String> map) {
+        org.springframework.util.LinkedMultiValueMap<String, String> multiMap =
+                new org.springframework.util.LinkedMultiValueMap<>();
+        map.forEach(multiMap::add);
+        return multiMap;
+    }
+
+    /**
+     * 根据我方 User 实体构建同步请求
+     */
+    public SyncShopReportRequest buildRequest(User user) {
+        SyncShopReportRequest req = new SyncShopReportRequest();
+        req.setPhone(user.getPhone());
+        req.setContactName(user.getNickname());
+        req.setContactPhone(user.getContactPhone());
+        req.setContactShop(user.getPharmacyName());
+        req.setContactProvince(user.getProvince());
+        req.setContactCity(user.getCity());
+        req.setContactArea(user.getDistrict());
+        req.setContactAddr(user.getPharmacyAddress());
+        req.setAppId(properties.getAppId());
+        req.setUserType("1");
+        req.setOperatorUserid("0");
+        return req;
+    }
+
+    /**
+     * 根据我方 User + BusinessLicense 实体构建完整同步请求
+     */
+    public SyncShopReportRequest buildRequest(User user, BusinessLicense license) {
+        SyncShopReportRequest req = buildRequest(user);
+        if (license != null) {
+            req.setContactShop(license.getStoreName() != null ? license.getStoreName() : req.getContactShop());
+            req.setContactProvince(license.getProvince() != null ? license.getProvince() : req.getContactProvince());
+            req.setContactCity(license.getCity() != null ? license.getCity() : req.getContactCity());
+            req.setContactArea(license.getDistrict() != null ? license.getDistrict() : req.getContactArea());
+            req.setContactAddr(license.getStoreAddress() != null ? license.getStoreAddress() : req.getContactAddr());
+            req.setContactName(license.getContactPerson() != null ? license.getContactPerson() : req.getContactName());
+            req.setContactPhone(license.getContactPhone() != null ? license.getContactPhone() : req.getContactPhone());
+            req.setBusinessLicenseImage(license.getLicenseImageUrl());
+            req.setDrugBusinessLicenseImage(license.getDrugLicenseUrl());
+            req.setTwoMedicalDeviceRegistration(license.getMedicalDeviceClass2Url());
+            req.setThreeMedicalDeviceBusinessLicense(license.getMedicalDeviceClass3Url());
+            // shop_type: 反向映射 terminalType → 第三方编码
+            req.setShopType(mapShopTypeToThirdParty(license.getTerminalType()));
+            // status: 反向映射 reviewStatus → 第三方编码
+            req.setStatus(mapStatusToThirdParty(license.getReviewStatus()));
+        }
+        req.setAppId(properties.getAppId());
+        req.setUserType("1");
+        req.setOperatorUserid("0");
+        return req;
+    }
+
+    /** terminalType → 第三方编码 */
+    private static String mapShopTypeToThirdParty(String terminalType) {
+        if (terminalType == null) return null;
+        return switch (terminalType) {
+            case "SINGLE" -> "1";
+            case "CHAIN" -> "2";
+            case "CLINIC", "COMMUNITY_HEALTH" -> "3";
+            default -> terminalType;
+        };
+    }
+
+    /** reviewStatus → 第三方编码 */
+    private static String mapStatusToThirdParty(String reviewStatus) {
+        if (reviewStatus == null) return "0";
+        return switch (reviewStatus) {
+            case "APPROVED" -> "1";
+            case "REJECTED" -> "2";
+            default -> "0";
+        };
+    }
+}