Procházet zdrojové kódy

管理员后端登录管理调整

liuchengsen před 1 měsícem
rodič
revize
9eddaeb695
40 změnil soubory, kde provedl 4459 přidání a 56 odebrání
  1. 821 0
      docs/小程序/API-小程序.md
  2. 424 0
      docs/小程序/API-联调文档-2026-07-06.md
  3. 340 0
      docs/小程序/PRD-小程序-V1.0.md
  4. 224 0
      docs/小程序/前端对接变更说明-小程序API.md
  5. 142 0
      docs/小程序/小程序-链路.md
  6. 340 0
      docs/小程序/微信小程序接入清单.md
  7. 5 0
      zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java
  8. 19 0
      zhijiayun-gateway/src/main/resources/application.yml
  9. 99 0
      zhijiayun-gateway/src/main/resources/db/migration-v8.sql
  10. 5 1
      zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java
  11. 116 1
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java
  12. 67 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/ContentController.java
  13. 113 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/ProcurementLedgerController.java
  14. 92 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/PurchaseIntentController.java
  15. 34 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/DashboardResponse.java
  16. 18 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java
  17. 27 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/MiniProgramLoginRequest.java
  18. 25 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/MiniProgramPhoneRequest.java
  19. 37 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/RegisterRequest.java
  20. 24 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserGateStatus.java
  21. 8 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/BusinessLicense.java
  22. 69 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/CertificateSyncLog.java
  23. 43 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/ProcurementLedger.java
  24. 78 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/PurchaseIntent.java
  25. 11 2
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java
  26. 94 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/UserCertificate.java
  27. 12 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/CertificateSyncLogMapper.java
  28. 4 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/CrawlerUsageLogMapper.java
  29. 15 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/ProcurementLedgerMapper.java
  30. 25 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/PurchaseIntentMapper.java
  31. 7 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SearchRecordMapper.java
  32. 23 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/UserCertificateMapper.java
  33. 23 21
      zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java
  34. 2 0
      zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java
  35. 512 28
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  36. 141 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/CertificateSyncService.java
  37. 79 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/ExternalUserSyncService.java
  38. 186 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/WechatMiniProgramService.java
  39. 146 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/impl/ExternalUserSyncServiceImpl.java
  40. 9 3
      zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

+ 821 - 0
docs/小程序/API-小程序.md

@@ -0,0 +1,821 @@
+# 智价云小程序 API 接口设计 V1.0
+
+> 定稿日期:2026-06-27  
+> 与桌面端共用后端 · Base URL 示例:`https://api.proprice.kailin.com.cn/v1`  
+> 关联 PRD:[PRD-小程序-V1.0.md](../prd/PRD-小程序-V1.0.md)
+
+---
+
+## 1. 通用约定
+
+### 1.1 请求头
+
+| Header | 说明 |
+|--------|------|
+| `Authorization` | `Bearer {access_token}`,除登录/回调外必填 |
+| `X-Client` | 固定 `mini_program` |
+| `X-Client-Version` | 小程序版本号,如 `1.0.0` |
+| `Content-Type` | `application/json` |
+
+### 1.2 响应格式
+
+```json
+{
+  "code": 0,
+  "message": "ok",
+  "data": {}
+}
+```
+
+| code | 含义 |
+|------|------|
+| 0 | 成功 |
+| 40001 | 参数错误 |
+| 40101 | 未登录 / Token 过期 |
+| 40301 | 无权限(如体验用户访问超限历史) |
+| 40401 | 资源不存在 |
+| 42901 | 请求过于频繁 |
+| 50001 | 服务器错误 |
+
+### 1.3 分页
+
+列表接口统一参数:
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `page` | int | 页码,从 1 开始,默认 1 |
+| `page_size` | int | 每页条数,默认 20,最大 50 |
+
+响应 `data` 结构:
+
+```json
+{
+  "items": [],
+  "total": 100,
+  "page": 1,
+  "page_size": 20,
+  "has_more": true
+}
+```
+
+---
+
+## 2. 数据实体
+
+### 2.1 User(扩展)
+
+在 PRD §10 基础上扩展:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 用户 ID |
+| `phone` | string | 手机号(脱敏返回) |
+| `pharmacy_name` | string | 药店名称 |
+| `region` | object | `{ province, city, district }` |
+| `license_status` | enum | `pending` / `approved` / `rejected` |
+| `license_reject_reason` | string? | 驳回原因 |
+| `member_type` | enum | `trial` / `paid` / `expired` |
+| `trial_end_at` | datetime? | 体验到期时间 |
+| `member_expire_at` | datetime? | 会员到期时间 |
+| `invite_code` | string | 个人邀请码 |
+| `wx_openid` | string | 微信 openid(内部) |
+| `wx_unionid` | string? | 微信 unionid(内部) |
+| `created_at` | datetime | 注册时间 |
+
+### 2.2 UserGateStatus(聚合,非表)
+
+`/users/me` 返回的门禁摘要(**与主 PRD §3.4 一致,各端共用**):
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `can_query_on_desktop` | bool | 执照已通过且绑平台 ≥1 时为 true |
+| `license_status` | enum | 执照状态 |
+| `platform_bound_count` | int | 已绑定平台数 |
+| `gate_messages` | string[] | 待完成事项(引导完成注册/执照/绑平台) |
+
+> 上述门禁**仅影响桌面查价**等等价能力;**不用于裁剪**小程序历史/采购接口返回。
+
+### 2.3 BusinessLicense(新增)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 执照记录 ID |
+| `user_id` | string | 用户 ID |
+| `image_url` | string | 执照图片 URL(私有存储,需签名访问) |
+| `status` | enum | `pending` / `approved` / `rejected` |
+| `reject_reason` | string? | 驳回原因 |
+| `submitted_at` | datetime | 提交时间 |
+| `reviewed_at` | datetime? | 审核时间 |
+| `source_client` | enum | `mini_program` / `windows` / `android` |
+| `reviewer_id` | string? | 运营审核人(Admin) |
+
+审核通过/驳回由 Admin 操作台调用内部接口,与桌面端上传共用队列。
+
+### 2.4 QueryLog(扩展)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | query_id |
+| `user_id` | string | 用户 ID |
+| `keyword` | string | 搜索关键词 |
+| `queried_at` | datetime | 查询时间 |
+| `source_client` | enum | `windows` / `android` / `mini_program` |
+| `result_count` | int | 命中 SKU 数 |
+| `lowest_platform` | string | 最低价平台 |
+| `lowest_price` | decimal | 参考最低价 |
+| `snapshot` | QuerySnapshot | 完整快照 JSON |
+| `related_intent_ids` | string[] | 关联跳转购买 ID |
+
+**QuerySnapshot 结构**:
+
+```json
+{
+  "items": [
+    {
+      "platform": "药帮忙",
+      "platform_id": "yaobangmang",
+      "price": 8.50,
+      "spec": "0.25g×24粒",
+      "manufacturer": "石药集团",
+      "approval_number": "国药准字H13020961",
+      "expiry": "2027-06",
+      "moq": "10盒",
+      "stock_status": "in_stock",
+      "is_lowest": true,
+      "external_url": "https://..."
+    }
+  ]
+}
+```
+
+### 2.5 PurchaseIntent(新增)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | intent_id |
+| `user_id` | string | 用户 ID |
+| `query_id` | string? | 关联 QueryLog |
+| `drug_name` | string | 药品名称 |
+| `spec` | string | 规格 |
+| `manufacturer` | string? | 厂家 |
+| `platform` | string | 药师帮 / 药帮忙 / 1药城 |
+| `platform_id` | string | 平台标识 |
+| `reference_price` | decimal | 跳转时参考价 |
+| `clicked_at` | datetime | 跳转时间 |
+| `source_client` | enum | 产生端 |
+| `external_url` | string? | 跳转 URL(内部存储,小程序不返回) |
+
+### 2.6 ProcurementLedger(V1.1 新增)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | ledger_id |
+| `user_id` | string | 用户 ID |
+| `intent_id` | string? | 关联 PurchaseIntent |
+| `query_id` | string? | 关联 QueryLog |
+| `drug_name` | string | 药品名称 |
+| `spec` | string | 规格 |
+| `qty` | decimal | 数量 |
+| `unit_price` | decimal | 单价 |
+| `total` | decimal | 总价 |
+| `platform` | string? | 平台 |
+| `order_note` | string? | 备注 |
+| `status` | enum | `ordered` / `received` |
+| `created_at` | datetime | 录入时间 |
+
+### 2.7 Order(扩展)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 订单 ID |
+| `order_no` | string | 业务订单号 |
+| `user_id` | string | 用户 ID |
+| `package_type` | enum | `monthly` / `quarterly` / `yearly` |
+| `amount` | decimal | 应付金额(元) |
+| `pay_channel` | enum | `wechat_mp` / `wechat_qr` / `alipay` |
+| `source_client` | enum | `mini_program` / `windows` / `android` |
+| `status` | enum | `pending` / `paid` / `failed` / `closed` |
+| `paid_at` | datetime? | 支付时间 |
+| `member_expire_at` | datetime? | 开通后会员到期 |
+| `created_at` | datetime | 创建时间 |
+
+### 2.8 MembershipQuota(聚合)
+
+`/users/me` 返回配额摘要:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `daily_query_used` | int | 桌面今日已用查询次数 |
+| `daily_query_limit` | int | 桌面每日上限(体验 20,会员 -1 表示不限) |
+| `watchlist_used` | int | 本月已关注品种数 |
+| `watchlist_limit` | int | 关注上限 |
+
+> 小程序历史/采购参考**无** `history_visible_*` 配额字段;列表接口不对体验用户做裁剪。
+
+---
+
+## 3. 认证接口
+
+### 3.1 小程序登录
+
+**POST** `/auth/mp/login`
+
+请求:
+
+```json
+{
+  "code": "wx.login 返回的 code",
+  "phone_code": "getPhoneNumber 返回的 code"
+}
+```
+
+响应 `data`:
+
+```json
+{
+  "access_token": "eyJ...",
+  "expires_in": 7200,
+  "refresh_token": "rf_...",
+  "is_registered": true,
+  "user": { /* User 摘要,is_registered=true 时返回 */ }
+}
+```
+
+| 场景 | 行为 |
+|------|------|
+| 手机号已注册 | 绑定/更新 `wx_openid`,返回 token + user |
+| 手机号未注册 | `is_registered: false`,前端跳转 `mp-register` |
+
+### 3.2 新用户注册
+
+**POST** `/auth/register`
+
+```json
+{
+  "invite_code": "PX8K2M",
+  "pharmacy_name": "康宁大药房",
+  "region": {
+    "province": "广东省",
+    "city": "广州市",
+    "district": "天河区"
+  }
+}
+```
+
+需已登录(微信授权 + 手机号已绑定)。成功后创建 User,`license_status: null`(未上传),返回更新后的 user。
+
+### 3.3 刷新 Token
+
+**POST** `/auth/refresh`
+
+```json
+{ "refresh_token": "rf_..." }
+```
+
+### 3.4 退出登录
+
+**POST** `/auth/logout`
+
+清除服务端 session(可选);小程序端清除本地 token。
+
+---
+
+## 4. 用户接口
+
+### 4.1 获取当前用户
+
+**GET** `/users/me`
+
+响应 `data`:
+
+```json
+{
+  "user": { /* User */ },
+  "gate": { /* UserGateStatus */ },
+  "quota": { /* MembershipQuota */ },
+  "platform_bindings": [
+    {
+      "platform_id": "yaobangmang",
+      "platform_name": "药帮忙",
+      "status": "bound",
+      "last_validated_at": "2026-06-27T10:00:00Z"
+    }
+  ]
+}
+```
+
+> 不返回平台账号密码。
+
+### 4.2 首页概览统计
+
+**GET** `/users/me/dashboard`
+
+响应 `data`:
+
+```json
+{
+  "today_query_count": 8,
+  "today_purchase_intent_count": 3,
+  "total_query_count": 156,
+  "recent_queries": [ /* QueryLog 摘要,最多 3 条 */ ]
+}
+```
+
+---
+
+## 5. 营业执照接口
+
+### 5.1 获取执照状态
+
+**GET** `/licenses/current`
+
+响应 `data`:
+
+```json
+{
+  "status": "pending",
+  "reject_reason": null,
+  "submitted_at": "2026-06-27T15:30:00Z",
+  "reviewed_at": null,
+  "image_url": "https://...signed...",
+  "source_client": "mini_program"
+}
+```
+
+| status | 说明 |
+|--------|------|
+| `null` / 404 | 未上传,前端跳转 `mp-license-upload` |
+| `pending` | 审核中 |
+| `approved` | 已通过 |
+| `rejected` | 已驳回,含 `reject_reason` |
+
+### 5.2 上传营业执照
+
+**POST** `/licenses/upload`
+
+`Content-Type: multipart/form-data`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `file` | file | JPG/PNG,≤ 5MB |
+
+响应 `data`:
+
+```json
+{
+  "id": "lic_abc",
+  "status": "pending",
+  "submitted_at": "2026-06-27T15:30:00Z"
+}
+```
+
+行为:
+- 新上传或驳回后重传:创建新记录或更新,`status` → `pending`
+- 同步更新 `User.license_status` → `pending`
+- 审核中不可重复提交(返回 `40903`)
+
+### 5.3 获取执照图片(签名 URL)
+
+**GET** `/licenses/current/image-url`
+
+返回短期有效的 signed URL,供小程序预览已提交执照。
+
+---
+
+## 6. 平台绑定接口
+
+与桌面端共用 `PlatformBinding` 实体;任一端绑定/解绑,各端同步。
+
+### 6.1 平台列表
+
+**GET** `/platform-bindings`
+
+响应 `data.items[]`:
+
+```json
+{
+  "platform_id": "yaobangmang",
+  "platform_name": "药帮忙",
+  "status": "bound",
+  "account_masked": "138****5678",
+  "last_validated_at": "2026-06-27T10:00:00Z"
+}
+```
+
+未绑定的平台也返回,`status: "unbound"`。
+
+### 6.2 绑定平台
+
+**POST** `/platform-bindings`
+
+```json
+{
+  "platform_id": "yaobangmang",
+  "account": "user@example.com",
+  "password": "******",
+  "source_client": "mini_program"
+}
+```
+
+密码加密传输,服务端加密存储;响应不含明文密码。
+
+### 6.3 解绑平台
+
+**DELETE** `/platform-bindings/{platform_id}`
+
+至少保留 0 个绑定;若解绑后导致 `can_query_on_desktop=false`,与主 PRD 一致。
+
+### 6.4 验证绑定
+
+**POST** `/platform-bindings/{platform_id}/validate`
+
+触发凭证有效性校验,更新 `last_validated_at`。
+
+---
+
+## 7. 查询历史接口
+
+### 7.1 查询历史列表
+
+**GET** `/queries`
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `keyword` | string? | 关键词搜索 |
+| `days` | int? | 可选时间筛选:7 / 30 / 90(用户主动筛选,非会员限制) |
+| `source_client` | string? | 来源端筛选 |
+
+响应 `data.items[]` 摘要字段:
+
+```json
+{
+  "id": "q_abc123",
+  "keyword": "阿莫西林胶囊",
+  "queried_at": "2026-06-27T14:32:00Z",
+  "source_client": "windows",
+  "result_count": 3,
+  "lowest_platform": "药帮忙",
+  "lowest_price": 8.50
+}
+```
+
+**不做会员级裁剪**:体验用户与付费会员返回相同全量数据(分页仍按 `page` / `page_size`)。
+
+### 7.2 查询快照详情
+
+**GET** `/queries/{id}`
+
+响应 `data`:完整 QueryLog + `snapshot` + `related_intents[]`
+
+---
+
+## 8. 采购参考接口
+
+### 8.1 跳转购买记录列表
+
+**GET** `/purchase-intents`
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `keyword` | string? | 药品名搜索 |
+| `platform_id` | string? | 平台筛选 |
+| `days` | int? | 可选时间筛选(用户主动筛选,非会员限制) |
+
+**不做会员级裁剪**:体验用户与付费会员返回相同全量数据。
+
+响应 `data.items[]`:
+
+```json
+{
+  "id": "pi_xyz789",
+  "query_id": "q_abc123",
+  "drug_name": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "manufacturer": "石药集团",
+  "platform": "药帮忙",
+  "platform_id": "yaobangmang",
+  "reference_price": 8.50,
+  "clicked_at": "2026-06-27T14:35:00Z",
+  "source_client": "windows"
+}
+```
+
+### 8.2 跳转购买详情
+
+**GET** `/purchase-intents/{id}`
+
+含关联 `query_summary`(若有 query_id)。
+
+### 8.3 写入跳转记录(桌面/Android 调用,小程序只读)
+
+**POST** `/purchase-intents`
+
+> 小程序 MVP **不暴露**此接口;由 Windows/Android 在点击「去购买」时调用。
+
+```json
+{
+  "query_id": "q_abc123",
+  "drug_name": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "manufacturer": "石药集团",
+  "platform_id": "yaobangmang",
+  "reference_price": 8.50,
+  "source_client": "windows",
+  "external_url": "https://..."
+}
+```
+
+### 8.4 写入查询快照(桌面/Android 调用)
+
+**POST** `/queries`
+
+> 小程序 MVP **不暴露**写接口。
+
+```json
+{
+  "keyword": "阿莫西林胶囊",
+  "source_client": "windows",
+  "result_count": 3,
+  "lowest_platform": "药帮忙",
+  "lowest_price": 8.50,
+  "snapshot": { "items": [ /* ... */ ] }
+}
+```
+
+---
+
+## 8. 手动采购台账(V1.1)
+
+### 8.1 列表
+
+**GET** `/procurement-ledgers`
+
+仅付费会员可用;体验用户返回 `40301`。
+
+### 8.2 创建
+
+**POST** `/procurement-ledgers`
+
+```json
+{
+  "intent_id": "pi_xyz789",
+  "query_id": "q_abc123",
+  "drug_name": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "qty": 10,
+  "unit_price": 8.50,
+  "total": 85.00,
+  "platform": "药帮忙",
+  "order_note": "6月补货",
+  "status": "ordered"
+}
+```
+
+### 8.3 更新 / 删除
+
+**PATCH** `/procurement-ledgers/{id}`  
+**DELETE** `/procurement-ledgers/{id}`
+
+---
+
+## 9. 会员与支付接口
+
+### 9.1 获取套餐列表
+
+**GET** `/membership/plans`
+
+响应 `data.plans[]`:
+
+```json
+{
+  "package_type": "yearly",
+  "name": "年卡会员",
+  "price": 899.00,
+  "original_price": 999.00,
+  "duration_days": 365,
+  "badge": "最划算",
+  "features": ["不限查询", "1999关注/月"]
+}
+```
+
+### 9.2 创建会员订单
+
+**POST** `/membership/orders`
+
+```json
+{
+  "package_type": "yearly",
+  "source_client": "mini_program"
+}
+```
+
+响应 `data`:
+
+```json
+{
+  "order_id": "ord_123",
+  "order_no": "202606271430001",
+  "amount": 899.00,
+  "status": "pending",
+  "wechat_pay": {
+    "timeStamp": "1719492600",
+    "nonceStr": "abc123",
+    "package": "prepay_id=wx...",
+    "signType": "RSA",
+    "paySign": "..."
+  }
+}
+```
+
+小程序调用 `wx.requestPayment(wechat_pay)`。
+
+### 9.3 查询订单状态
+
+**GET** `/membership/orders/{order_no}`
+
+用于支付后轮询:
+
+```json
+{
+  "order_no": "202606271430001",
+  "status": "paid",
+  "paid_at": "2026-06-27T14:30:05Z",
+  "member_expire_at": "2027-06-27T14:30:05Z"
+}
+```
+
+### 9.4 微信支付回调
+
+**POST** `/payments/wechat/notify`
+
+微信服务器回调,验签后更新 Order 状态及 User 会员字段。不对小程序暴露。
+
+---
+
+## 10. 邀请与体验券
+
+### 10.1 邀请统计
+
+**GET** `/invites/stats`
+
+```json
+{
+  "invite_code": "PX8K2M",
+  "invited_count": 5,
+  "activated_count": 3,
+  "total_reward_days": 21,
+  "records": [
+    {
+      "invitee_phone_masked": "138****1234",
+      "status": "activated",
+      "reward_days": 7,
+      "activated_at": "2026-06-20T10:00:00Z"
+    }
+  ]
+}
+```
+
+### 10.2 体验券核销
+
+**POST** `/coupons/redeem`
+
+```json
+{ "code": "TRIAL-ABCD-1234" }
+```
+
+成功响应:
+
+```json
+{
+  "days_added": 15,
+  "trial_end_at": "2026-07-12T00:00:00Z",
+  "member_type": "trial"
+}
+```
+
+---
+
+## 11. 内容与配置
+
+### 11.1 帮助与 FAQ
+
+**GET** `/content/help`
+
+返回 FAQ 列表、合规文案、桌面下载链接。
+
+### 11.2 桌面下载链接
+
+**GET** `/content/download`
+
+```json
+{
+  "windows_url": "https://proprice.kailin.com.cn/download/windows",
+  "official_site_url": "https://proprice.kailin.com.cn",
+  "mini_program_appid": "wx...",
+  "version": "1.0.0",
+  "release_notes": "..."
+}
+```
+
+---
+
+## 12. 错误码补充
+
+| code | message 示例 | 场景 |
+|------|--------------|------|
+| 40302 | ~~体验用户历史记录超出可见范围~~ | 已废弃:历史不做会员裁剪 |
+| 40303 | 请升级会员后使用采购台账 | V1.1 台账 |
+| 40304 | ~~请先上传营业执照~~ | 已废弃:小程序历史/采购不因执照状态拦截 |
+| 40402 | 用户未注册 | 登录后业务接口 |
+| 40901 | 体验券已使用或无效 | 核销失败 |
+| 40902 | 订单已支付或已关闭 | 重复支付 |
+| 40903 | 营业执照审核中,请勿重复提交 | 重复上传 |
+| 41301 | 图片超过 5MB 或格式不支持 | 执照上传 |
+
+---
+
+## 13. 接口清单汇总
+
+| 方法 | 路径 | 调用端 | MVP |
+|------|------|--------|-----|
+| POST | `/auth/mp/login` | 小程序 | ✓ |
+| POST | `/auth/register` | 小程序 | ✓ |
+| POST | `/auth/refresh` | 小程序 | ✓ |
+| GET | `/users/me` | 小程序 | ✓ |
+| GET | `/users/me/dashboard` | 小程序 | ✓ |
+| GET | `/licenses/current` | 小程序 | ✓ |
+| POST | `/licenses/upload` | 小程序 | ✓ |
+| GET | `/licenses/current/image-url` | 小程序 | ✓ |
+| GET | `/platform-bindings` | 小程序 | ✓ |
+| POST | `/platform-bindings` | 小程序 | ✓ |
+| DELETE | `/platform-bindings/{platform_id}` | 小程序 | ✓ |
+| POST | `/platform-bindings/{platform_id}/validate` | 小程序 | ✓ |
+| GET | `/queries` | 小程序 | ✓ |
+| GET | `/queries/{id}` | 小程序 | ✓ |
+| POST | `/queries` | 桌面/Android | ✓ |
+| GET | `/purchase-intents` | 小程序 | ✓ |
+| GET | `/purchase-intents/{id}` | 小程序 | ✓ |
+| POST | `/purchase-intents` | 桌面/Android | ✓ |
+| GET | `/membership/plans` | 小程序 | ✓ |
+| POST | `/membership/orders` | 小程序 | ✓ |
+| GET | `/membership/orders/{order_no}` | 小程序 | ✓ |
+| POST | `/payments/wechat/notify` | 微信服务器 | ✓ |
+| GET | `/invites/stats` | 小程序 | ✓ |
+| POST | `/coupons/redeem` | 小程序 | ✓ |
+| GET | `/content/help` | 小程序 | ✓ |
+| GET | `/content/download` | 小程序 | ✓ |
+| GET | `/procurement-ledgers` | 小程序 | V1.1 |
+| POST | `/procurement-ledgers` | 小程序 | V1.1 |
+
+---
+
+## 14. 数据库变更建议
+
+```sql
+-- User 扩展
+ALTER TABLE users ADD COLUMN wx_openid VARCHAR(64) NULL;
+ALTER TABLE users ADD COLUMN wx_unionid VARCHAR(64) NULL;
+CREATE UNIQUE INDEX idx_users_wx_openid ON users(wx_openid);
+
+-- BusinessLicense 新表
+CREATE TABLE business_licenses (
+  id VARCHAR(32) PRIMARY KEY,
+  user_id VARCHAR(32) NOT NULL,
+  image_url TEXT NOT NULL,
+  status VARCHAR(16) NOT NULL DEFAULT 'pending',
+  reject_reason TEXT NULL,
+  submitted_at TIMESTAMP NOT NULL,
+  reviewed_at TIMESTAMP NULL,
+  source_client VARCHAR(32) NOT NULL,
+  reviewer_id VARCHAR(32) NULL,
+  INDEX idx_bl_user_status (user_id, status),
+  INDEX idx_bl_pending (status, submitted_at)
+);
+
+-- QueryLog 扩展
+ALTER TABLE query_logs ADD COLUMN source_client VARCHAR(32) NOT NULL DEFAULT 'windows';
+ALTER TABLE query_logs ADD COLUMN result_count INT NOT NULL DEFAULT 0;
+ALTER TABLE query_logs ADD COLUMN lowest_platform VARCHAR(32) NULL;
+ALTER TABLE query_logs ADD COLUMN lowest_price DECIMAL(10,2) NULL;
+ALTER TABLE query_logs ADD COLUMN snapshot JSON NOT NULL;
+
+-- PurchaseIntent 新表
+CREATE TABLE purchase_intents (
+  id VARCHAR(32) PRIMARY KEY,
+  user_id VARCHAR(32) NOT NULL,
+  query_id VARCHAR(32) NULL,
+  drug_name VARCHAR(128) NOT NULL,
+  spec VARCHAR(64) NOT NULL,
+  manufacturer VARCHAR(128) NULL,
+  platform_id VARCHAR(32) NOT NULL,
+  reference_price DECIMAL(10,2) NOT NULL,
+  clicked_at TIMESTAMP NOT NULL,
+  source_client VARCHAR(32) NOT NULL,
+  external_url TEXT NULL,
+  INDEX idx_pi_user_clicked (user_id, clicked_at DESC),
+  INDEX idx_pi_query (query_id)
+);
+
+-- Order 扩展
+ALTER TABLE orders ADD COLUMN source_client VARCHAR(32) NOT NULL DEFAULT 'windows';
+```

+ 424 - 0
docs/小程序/API-联调文档-2026-07-06.md

@@ -0,0 +1,424 @@
+# 智价云小程序后端 API 联调文档
+
+> 生成日期:2026-07-06
+> Base URL:`http://localhost:8004`(开发)/ `https://api.proprice.kailin.com.cn`(生产)
+> 认证方式:`Authorization: Bearer {accessToken}`(标注"公开"的除外)
+
+---
+
+## 1. 认证接口
+
+### 1.1 小程序登录
+
+```
+POST /api/auth/wechat/miniapp/login
+```
+
+**说明**:微信小程序 `wx.login()` 后调用,自动处理新老用户四种场景。
+
+请求:
+```json
+{
+  "jsCode": "wx.login()返回的code",
+  "phoneCode": "getPhoneNumber返回的code(可选)",
+  "inviteCode": "邀请码(可选)"
+}
+```
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "userId": "2070346651500961794",
+    "accessToken": "eyJ...",
+    "refreshToken": "eyJ...",
+    "expiresIn": 3600,
+    "newUser": false,
+    "needBindPhone": false,
+    "role": "USER",
+    "userInfo": {
+      "id": "2070346651500961794",
+      "phone": "138****1234",
+      "nickname": "药店用户",
+      "avatar": null,
+      "pharmacyName": null,
+      "province": null,
+      "city": null,
+      "district": null,
+      "wechatBound": true,
+      "loginSource": "MINIAPP",
+      "membershipLevel": "PLUS",
+      "membershipLevelName": "普通会员",
+      "createTime": "2026-07-06T12:00:00"
+    }
+  }
+}
+```
+
+`needBindPhone=true` 时:前端引导用户点击 `getPhoneNumber` 按钮,拿到 `phoneCode` 后调用 1.2。
+
+---
+
+### 1.2 小程序绑定手机号
+
+```
+POST /api/auth/wechat/miniapp/bind-phone
+```
+
+请求:
+```json
+{
+  "tempToken": "1.1 返回的tempToken",
+  "phoneCode": "getPhoneNumber按钮返回的code",
+  "inviteCode": "邀请码(可选)"
+}
+```
+
+响应:同 1.1,`needBindPhone=false`,含正式 token。
+
+---
+
+### 1.3 独立注册(填写药店信息)
+
+```
+POST /api/auth/register
+```
+
+**说明**:1.1 返回 `newUser=true` 或 `needBindPhone` 完成后跳转注册页,填写药店信息提交。
+
+请求:
+```json
+{
+  "tempToken": "登录时返回的tempToken",
+  "pharmacyName": "康宁大药房",
+  "province": "广东省",
+  "city": "广州市",
+  "district": "天河区",
+  "inviteCode": "PX8K2M(可选)"
+}
+```
+
+响应:同 1.1,含正式 token,`userInfo.pharmacyName` 已填充。
+
+---
+
+### 1.4 Token 刷新
+
+```
+POST /api/auth/refresh
+```
+
+请求:
+```json
+{ "refreshToken": "eyJ..." }
+```
+
+响应:同 1.1 格式(不含 userInfo)。
+
+---
+
+### 1.5 心跳保活
+
+```
+POST /api/auth/heartbeat
+```
+
+Header:`Authorization: Bearer {accessToken}`(无 body)
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "userId": "2070346651500961794",
+    "accessToken": "eyJ...(新)",
+    "refreshToken": "eyJ...(新)",
+    "expiresIn": 3600,
+    "role": "USER"
+  }
+}
+```
+
+> 建议间隔 = accessToken 有效期的 80%(约 48 分钟),过期 token 也可调用。
+
+---
+
+### 1.6 登出
+
+```
+POST /api/auth/logout
+```
+
+Header:`Authorization: Bearer {accessToken}`(无 body)
+
+响应:`{"code": 200, "message": "操作成功"}`
+
+---
+
+## 2. 用户信息接口
+
+### 2.1 用户信息(旧接口,PC 端用,不变)
+
+```
+GET /api/auth/user/info
+```
+
+响应:与 1.1 `userInfo` 一致。
+
+---
+
+### 2.2 聚合用户信息(小程序"我的"页面)
+
+```
+GET /api/auth/user/me
+```
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "id": "2070346651500961794",
+    "phone": "138****1234",
+    "nickname": "药店用户",
+    "pharmacyName": "康宁大药房",
+    "province": "广东省",
+    "city": "广州市",
+    "district": "天河区",
+    "membershipLevel": "PLUS",
+    "membershipLevelName": "普通会员",
+    "gate": {
+      "canQueryOnDesktop": false,
+      "licenseStatus": "PENDING",
+      "platformBoundCount": 1,
+      "gateMessages": ["请先上传营业执照并通过审核"]
+    },
+    "quota": {
+      "dailyQueryUsed": 8,
+      "dailyQueryLimit": 20
+    },
+    "platformBindings": [
+      {
+        "platformCode": "yaobangmang",
+        "platformName": "药帮忙",
+        "status": "bound",
+        "lastVerifyTime": "2026-06-27T10:00:00"
+      }
+    ]
+  }
+}
+```
+
+---
+
+### 2.3 首页概览
+
+```
+GET /api/auth/user/dashboard
+```
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "todayQueryCount": 8,
+    "todayPurchaseIntentCount": 0,
+    "totalQueryCount": 156,
+    "recentQueries": [
+      {
+        "drugName": "阿莫西林胶囊",
+        "searchedAt": "2026-07-06T14:00:00",
+        "lowestPrice": "有报价"
+      }
+    ]
+  }
+}
+```
+
+---
+
+## 3. 查询历史
+
+### 3.1 历史列表
+
+```
+GET /api/search/records?page=1&pageSize=20
+```
+
+### 3.2 历史详情
+
+```
+GET /api/search/records/{id}
+```
+
+---
+
+## 4. 跳转购买记录
+
+### 4.1 列表
+
+```
+GET /api/purchase-intents?page=1&pageSize=20
+```
+
+响应 `data.records[]`:
+```json
+{
+  "id": "123456",
+  "userId": "2070346651500961794",
+  "queryId": "789",
+  "drugName": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "manufacturer": "石药集团",
+  "platformCode": "yaobangmang",
+  "platformName": "药帮忙",
+  "referencePrice": 8.50,
+  "sourceClient": "WINDOWS",
+  "clickedAt": "2026-07-06T14:35:00"
+}
+```
+
+### 4.2 详情
+
+```
+GET /api/purchase-intents/{id}
+```
+
+### 4.3 写入(桌面端调用)
+
+```
+POST /api/purchase-intents
+```
+
+```json
+{
+  "queryId": "789",
+  "drugName": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "manufacturer": "石药集团",
+  "platformCode": "yaobangmang",
+  "platformName": "药帮忙",
+  "referencePrice": 8.50,
+  "externalUrl": "https://...",
+  "sourceClient": "WINDOWS"
+}
+```
+
+---
+
+## 5. 采购台账
+
+### 5.1 列表
+
+```
+GET /api/procurement-ledgers?page=1&pageSize=20
+```
+
+### 5.2 创建
+
+```
+POST /api/procurement-ledgers
+```
+
+```json
+{
+  "intentId": "123456(可选)",
+  "queryId": "789(可选)",
+  "drugName": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "qty": 10,
+  "unitPrice": 8.50,
+  "total": 85.00,
+  "platform": "药帮忙",
+  "orderNote": "6月补货",
+  "status": "ordered"
+}
+```
+
+### 5.3 更新
+
+```
+PATCH /api/procurement-ledgers/{id}
+```
+
+```json
+{
+  "status": "received",
+  "qty": 10,
+  "unitPrice": 8.50,
+  "total": 85.00,
+  "orderNote": "已到货"
+}
+```
+
+### 5.4 删除
+
+```
+DELETE /api/procurement-ledgers/{id}
+```
+
+---
+
+## 6. 内容接口(公开)
+
+### 6.1 帮助 FAQ
+
+```
+GET /api/content/help
+```
+
+### 6.2 桌面下载链接
+
+```
+GET /api/content/download
+```
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "windowsUrl": "https://proprice.kailin.com.cn/download/windows",
+    "officialSiteUrl": "https://proprice.kailin.com.cn",
+    "version": "1.0.0",
+    "releaseNotes": "1.0.0 版本发布"
+  }
+}
+```
+
+---
+
+## 7. 已有接口(不变,仅列路径)
+
+| 接口 | 路径 |
+|------|------|
+| 发送验证码 | `POST /api/auth/sms/send` |
+| 验证码登录 | `POST /api/auth/sms/login` |
+| 密码登录 | `POST /api/auth/password/login` |
+| 微信扫码登录(PC) | `POST /api/auth/wechat/login` |
+| 绑定微信手机号 | `POST /api/auth/wechat/bind` |
+| 设置密码 | `POST /api/auth/password/set` |
+| 修改手机号 | `POST /api/auth/phone/change` |
+| 营业执照上传 | `POST /api/business-license/upload-file` |
+| 营业执照查询 | `GET /api/business-license/info` |
+| 平台列表 | `GET /api/platform-account/list` |
+| 绑定平台 | `POST /api/platform-account/bind` |
+| 解绑平台 | `DELETE /api/platform-account/unbind/{platformCode}` |
+| 验证平台 | `POST /api/platform-account/verify/{platformCode}` |
+| 会员套餐 | `GET /api/payment/plans` |
+| 创建订单 | `POST /api/payment/orders` |
+| 订单状态 | `GET /api/payment/orders/{orderNo}` |
+| 邀请统计 | `GET /api/invite/stats` |
+| 优惠券核销 | `POST /api/coupons/redeem` |
+
+---
+
+## 8. Token 设备策略说明
+
+| 端 | 策略 | 行为 |
+|----|------|------|
+| PC(SMS/密码/微信扫码) | `tokenVersion` 精确匹配 | 只能 1 台 PC 在线,新登录踢旧 |
+| 小程序(微信授权) | `tokenVersion` 范围匹配 `< 2` | 与 PC 共存,不互踢 |
+| 旧版本 token | 精确匹配(向前兼容) | 无 `loginSource` claim 时走 PC 逻辑 |

+ 340 - 0
docs/小程序/PRD-小程序-V1.0.md

@@ -0,0 +1,340 @@
+# 智价云(药店版)微信小程序 — 产品需求文档 V1.0
+
+> 定稿日期:2026-06-27 · 状态:**已定稿**  
+> 基于 [PRD-摘要.md](PRD-摘要.md) 延伸 · 伴侣型移动入口
+
+---
+
+## 1. 产品定位
+
+### 1.1 一句话
+
+智价云小程序是面向药店用户的**移动伴侣端**:在桌面软件完成重度比价采购的同时,用手机随时查看**查询历史与采购参考记录**,并完成**会员开通/续费、邀请拉新**等增长动作;数据与桌面端同一账号实时同步。
+
+### 1.2 与现有端侧关系
+
+| 端 | 角色 | 核心能力 |
+|----|------|----------|
+| Windows 桌面 | **主工作台** | 聚合比价、关注、跳转购买、产生历史数据 |
+| **微信小程序** | **多端入口之一** | 完整注册、执照提交与状态、平台绑定、查历史/采购、会员/邀请 |
+| Android APP | 完整移动版(P1,**暂缓**) | 当前版本不纳入范围 |
+
+**小程序不做**:实时并行查价、扫码搜药、关注管理(查价与关注仍在桌面端完成)。
+
+**多端原则**:小程序是注册与资料提交的**入口之一**,账号、执照、平台绑定、会员、历史数据**各端同步**,权益规则**统一沿用主 PRD**,不在小程序侧单独设计裁剪逻辑。
+
+### 1.3 价值主张
+
+- **随时复盘**:外出或离店时快速翻看「上次查过什么、当时各平台什么价、最终跳去哪买了」
+- **支付便利**:微信生态内原生调起微信支付,比桌面扫码更顺畅
+- **私域传播**:一键分享邀请码/链接到微信好友与群
+- **账号一体**:同一手机号登录,桌面产生的数据小程序秒级可见
+- **多端同权**:注册、执照、绑平台、会员、历史数据各端一致,小程序不单独设计权益
+
+---
+
+## 2. 目标用户与典型场景
+
+| 角色 | 场景 | 小程序动作 |
+|------|------|------------|
+| 药店老板 | 晚上复盘今日采购决策 | 浏览今日查询历史 + 跳转购买记录 |
+| 采购员 | 店内没开电脑,想确认上周某药查价结果 | 搜索历史关键词 → 打开查询快照 |
+| 体验到期用户 | 收到到期提醒(订阅消息) | 打开小程序 → 会员中心 → 微信支付续费 |
+| KOL/老用户 | 微信群里推荐智价云 | 分享邀请海报/链接 → 查看激活统计 |
+| 新入驻药店 | 微信内收到邀请 | 小程序完成注册 → 上传执照 → 绑定平台 |
+| 被驳回用户 | 执照照片不清晰 | 小程序查看驳回原因 → 重新拍摄上传 |
+
+---
+
+## 3. 功能范围
+
+### 3.1 MVP 功能(首期上线)
+
+| 模块 | 功能点 | 说明 |
+|------|--------|------|
+| **登录与账号** | 微信授权 + 手机号绑定 | 与桌面端同一 `User` 账号 |
+| **新用户注册** | 邀请码 + 药店名称 + 省市区 | 未注册用户可在小程序完成,与桌面端共用账号 |
+| **营业执照** | 拍照/相册上传、审核状态查询、驳回重传 | **资料提交入口 + 状态查询**;审核规则与主 PRD 一致 |
+| **平台绑定** | 药师帮 / 药帮忙 / 1药城 账号绑定与管理 | 与桌面端共用 `PlatformBinding`,各端同步 |
+| **首页概览** | 会员状态条、今日查询次数、快捷入口 | 只读展示配额,查询仍须桌面端 |
+| **查询历史** | 列表 + 详情 + 再次查询引导 | 同步桌面 `QueryLog`;详情含**完整查询快照** |
+| **采购参考** | 跳转购买记录 | 同步桌面/各端产生的 `PurchaseIntent` |
+| **会员中心** | 权益对比、套餐选择、微信支付 | 复用 PRD 体验/付费两档权益 |
+| **支付结果** | 成功/失败/待支付 | 支付回调更新 `member_expire_at` |
+| **邀请中心** | 邀请码、复制、微信分享、激活统计 | 一级邀请,奖励会员延期 |
+| **体验券核销** | 输入券码 | 与桌面端规则一致 |
+| **我的** | 药店信息、执照状态、平台账号管理 | 执照与绑平台均为可管理入口,非只读 |
+| **帮助与合规** | 固定文案 + 下载桌面端引导 + **官网入口** | 统一合规声明;链至 `proprice.kailin.com.cn` |
+
+### 3.2 分期功能
+
+| 版本 | 功能 | 优先级说明 |
+|------|------|------------|
+| **V1.1** | 手动采购台账 | 用户录入已下单/到货明细,与查询快照关联 |
+| **V1.2** | 订阅消息 | 体验到期、会员到期、降价提醒 |
+| **V2.0** | B2B 平台订单同步 | 从药师帮/药帮忙/1药城拉取真实订单,单独评估 |
+
+### 3.3 明确不做
+
+- 小程序内实时聚合查价、扫码搜药、关注管理(引导打开桌面软件)
+- 药品交易、支付、开票
+- 二级分销、现金返利
+- 相对主 PRD / 桌面端的**独立权益或门禁规则**(小程序不单独设计)
+
+### 3.4 营业执照(MVP)
+
+小程序职责:**资料提交入口 + 审核状态查询**。审核流程、通过/驳回规则与主 PRD §3.2 及桌面端**完全一致**。
+
+| 步骤 | 小程序行为 |
+|------|------------|
+| 上传 | `mp-license-upload`:拍照或相册选择,预览后提交 |
+| 待审核 | `mp-license-pending`:展示状态与预计时效;**可正常浏览历史/采购** |
+| 通过 | 状态各端同步;不影响小程序历史/采购可见性 |
+| 驳回 | `mp-license-rejected`:展示驳回原因 + 重新上传 |
+| 查看 | 「我的 → 营业执照」查看状态与已提交图片 |
+
+> **审核对各端查价/会员的影响**与主 PRD 一致(执照通过 + 绑平台后方可桌面查价);**不因审核状态在小程序侧单独限制历史/采购记录**。
+
+运营审核仍在 Admin 操作台人工处理,与桌面端上传无差别。
+
+### 3.5 平台绑定(MVP)
+
+与主 PRD §3.3 一致,小程序提供完整绑定流程:
+
+| 步骤 | 页面 |
+|------|------|
+| 引导 | `mp-onboard-bind`:三平台卡片,至少绑 1 个 |
+| 列表 | `mp-platform-list`:已绑 / 未绑状态 |
+| 表单 | `mp-platform-bind`:账号 + 密码,加密提交 |
+
+注册完整路径:`mp-register` → `mp-license-upload` → `mp-onboard-bind` → `mp-platform-bind` → 进入主界面。
+
+---
+
+## 4. 采购信息定义
+
+### 4.1 查询结果快照(MVP)
+
+每次桌面端成功查询,后端保存完整快照,小程序只读展示。
+
+| 字段 | 说明 |
+|------|------|
+| `query_id` | 唯一 ID |
+| `keyword` | 搜索关键词 |
+| `queried_at` | 查询时间 |
+| `source_client` | `windows` / `android` / `mini_program`(只读来源) |
+| `result_count` | 命中 SKU 数 |
+| `snapshot` | JSON:各平台报价列表 |
+| `lowest_platform` | 最低价平台 |
+| `lowest_price` | 参考最低价 |
+
+**小程序展示**:历史列表 → 详情页复现卡片式报价对比(只读);若有关联跳转记录,显示「已在 XX 平台购买」标记。
+
+### 4.2 跳转购买记录(MVP)
+
+用户在任何端点击「去 [平台] 购买」时写入。
+
+| 字段 | 说明 |
+|------|------|
+| `intent_id` | 唯一 ID |
+| `query_id` | 关联查询(可空) |
+| `drug_name` / `spec` / `manufacturer` | 药品信息 |
+| `platform` | 药师帮 / 药帮忙 / 1药城 |
+| `reference_price` | 跳转时参考价 |
+| `clicked_at` | 跳转时间 |
+| `source_client` | 产生端 |
+
+### 4.3 手动采购台账(V1.1)
+
+| 字段 | 说明 |
+|------|------|
+| `ledger_id` | 唯一 ID |
+| `intent_id` / `query_id` | 可选关联 |
+| `drug_name`, `spec`, `qty`, `unit_price`, `total` | 采购明细 |
+| `platform`, `order_note` | 平台与备注 |
+| `status` | 已下单 / 已到货 |
+| `created_at` | 录入时间 |
+
+### 4.4 平台订单同步(V2.0 预研)
+
+- 依赖各 B2B 平台 API 或授权协议,**不纳入 MVP**
+- 采购 Tab 预留「平台同步订单」入口位
+
+---
+
+## 5. 会员与权益
+
+### 5.1 统一权益(沿用主 PRD,不单独设计)
+
+小程序**不单独定义**历史、采购、会员等级规则,**与桌面客户端保持一致**:
+
+| 能力 | 规则来源 |
+|------|----------|
+| 体验 / 付费会员、查询次数、关注配额 | 主 PRD §4 |
+| 查询历史、采购参考记录 | 与桌面端**完全一致**,无条数/时间裁剪 |
+| 执照审核对查价的影响 | 主 PRD §3.2、§3.4(通过 + 绑平台后可查价) |
+| 执照审核对小程序历史/采购 | **不限制**;审核仅按主 PRD 影响各端查价等会员级能力 |
+
+> 小程序首页可只读展示桌面端配额(如今日查询 x/20),本身不消耗查询次数。
+
+### 5.2 小程序仅承担的差异能力
+
+| 能力 | 说明 |
+|------|------|
+| 执照 | 资料提交 + 状态查询(见 §3.4) |
+| 支付 | 微信支付开通/续费会员 |
+| 邀请 / 体验券 | 与桌面端规则一致 |
+| 手动采购台账(V1.1) | 仅付费会员(与前期设计一致,属增值功能非历史裁剪) |
+
+### 5.3 套餐与支付
+
+- 套餐:月 / 季 / 年(运营后台可配,MVP 占位 ¥99 / ¥269 / ¥899)
+- 支付方式:**微信支付**(`wx.requestPayment`)
+- 支付宝:小程序内不支持,引导桌面端扫码
+- 支付成功后:`member_type` → 付费会员,`member_expire_at` 延长;桌面端实时刷新
+
+### 5.4 邀请与体验券
+
+- **邀请**:每人永久邀请码;激活 = 被邀请人执照通过 + 绑平台 ≥1;奖励邀请人会员 +7 天(可配)
+- **体验券**:输入券码延长体验期
+- **分享**:`onShareAppMessage` 携带邀请码 → H5 落地页或小程序注册引导页
+
+---
+
+## 6. 信息架构与页面清单
+
+页面 ID 前缀 **`mp-`**,MVP 共 **20 屏**。
+
+### 6.1 底部 Tab(3 Tab)
+
+```
+历史 | 采购 | 我的
+```
+
+### 6.2 完整页面列表
+
+| ID | 页面名 | 入口 |
+|----|--------|------|
+| `mp-login` | 登录 | 首次打开 |
+| `mp-register` | 新用户注册 | 未注册手机号 |
+| `mp-license-upload` | 上传营业执照 | 注册后 / 我的 / 门禁拦截 |
+| `mp-license-pending` | 审核中 | 提交执照后 |
+| `mp-license-rejected` | 审核驳回 | 被驳回时 |
+| `mp-onboard-bind` | 绑定平台引导 | 注册流程 / 我的 |
+| `mp-platform-list` | 平台账号管理 | 我的 |
+| `mp-platform-bind` | 绑定平台表单 | 平台列表 |
+| `mp-home` | 首页概览 | 登录后(可选,默认进历史 Tab) |
+| `mp-history-list` | 查询历史 | Tab |
+| `mp-history-detail` | 查询快照详情 | 历史列表 |
+| `mp-purchase-list` | 采购参考 | Tab |
+| `mp-purchase-detail` | 采购记录详情 | 采购列表 |
+| `mp-membership` | 会员中心 | 我的 |
+| `mp-payment-result` | 支付结果 | 支付回调 |
+| `mp-invite-center` | 邀请好友 | 我的 |
+| `mp-coupon-redeem` | 体验券核销 | 我的 |
+| `mp-profile` | 我的 | Tab |
+| `mp-help` | 帮助与合规 | 我的 |
+
+### 6.3 视觉规范
+
+沿用 [design/DESIGN.md](../design/DESIGN.md):
+- 主色 `#002FA7`、正红 `#E60012`、医药绿 `#00A870`
+- 合规页脚:「数据来源于您本人绑定的 B2B 平台账号,智价云不提供药品交易服务。」
+
+---
+
+## 7. 数据同步方案
+
+- 登录态:JWT,小程序 `wx.storage` 持久化
+- **服务端为唯一数据源**:任一端写入(注册、执照、绑平台、查询、跳转)→ 各端读取
+- 刷新:列表下拉刷新;详情进入时拉最新;支付后轮询会员状态(MVP)
+
+详见 [api/API-小程序.md](../api/API-小程序.md)
+
+---
+
+## 8. 门禁与权限
+
+**原则**:与主 PRD §3.4 一致,**不在小程序侧独立设计门禁**;执照审核按主 PRD 影响各端**查价**等能力,**不限制**小程序内历史/采购可见性。
+
+| 状态 | 小程序行为 |
+|------|------------|
+| 未注册 | 引导完成完整注册:邀请码 → 药店信息 → 执照 → 绑平台 |
+| 已注册,执照未提交 / 审核中 / 驳回 | 提供执照**提交或状态查询**入口;**历史/采购正常可见** |
+| 已注册,执照通过,未绑平台 | 引导 `mp-onboard-bind` 完成绑定;**历史/采购正常可见** |
+| 已登录 | 历史、采购、会员、邀请、体验券全功能(除查价) |
+
+> 桌面端「不可查价」门禁(执照未通过、未绑平台)在小程序以**状态展示 + 引导完成资料**呈现,不阻断历史/采购浏览。
+
+---
+
+## 9. 关键产品决策(§13 定稿)
+
+| # | 决策项 | 定稿结论 |
+|---|--------|----------|
+| 1 | 历史记录权益 | **与客户端保持一致**,不单独设计;无条数/时间裁剪 |
+| 2 | 执照审核 | **仅影响各端会员级能力**(与主 PRD 一致,如桌面查价门禁);小程序增加**资料提交 + 状态查询**;**审核中可见**历史/采购 |
+| 3 | 注册路径 | 未注册用户**可在小程序完成完整注册**(邀请码 + 执照 + 绑平台);小程序是多端**入口之一**,信息各端同步 |
+| 4 | Android APP | **暂缓**,当前文档不定义与 Android 关系 |
+| 5 | 官网联动 | **是**,[proprice.kailin.com.cn](https://proprice.kailin.com.cn) 增加「打开小程序」+ 下载桌面引导 |
+
+---
+
+## 10. 官网联动(已定稿)
+
+在 `proprice.kailin.com.cn` 增加:
+
+| 入口 | 实现 |
+|------|------|
+| 打开小程序 | 微信内 `<wx-open-launch-weapp>` 或小程序码 |
+| 下载桌面版 | Windows 安装包下载链接 |
+| 帮助说明 | 小程序 vs 桌面版能力说明(查价在桌面,移动侧重历史/注册/会员) |
+
+---
+
+## 11. 非功能需求
+
+| 项 | 要求 |
+|----|------|
+| 性能 | 历史列表首屏 ≤ 2s;分页 20 条/页 |
+| 安全 | 平台绑定密码加密传输与存储;敏感接口 HTTPS + Token |
+| 合规 | 全页合规提示;禁用「全网最低保证」等用语 |
+| 微信审核 | 类目:工具 > 信息查询;不含药品销售表述 |
+| 离线 | 不支持;无网络友好提示 |
+| 图片上传 | 执照单张 ≤ 5MB;支持 JPG/PNG;`wx.chooseMedia` 拍照或相册 |
+
+---
+
+## 12. 成功指标(上线 30 天)
+
+| 指标 | 目标 |
+|----|------|
+| 桌面活跃用户中绑定小程序比例 | ≥ 40% |
+| 小程序 DAU / 桌面 DAU | ≥ 25% |
+| 会员支付从小程序完成占比 | ≥ 30% |
+| 历史详情页人均访问次数/周 | ≥ 3 |
+| 邀请分享从小程序发起占比 | ≥ 50% |
+| 小程序完整注册占比(含绑平台) | ≥ 30% |
+
+---
+
+## 13. 实施分期
+
+| 阶段 | 交付 | 工期参考 |
+|------|------|----------|
+| **P0** | 后端 API(账号、执照上传、QueryLog 扩展、PurchaseIntent、会员支付) | 与桌面端后端统一建设 |
+| **P1** | 小程序 MVP 20 屏 + 完整注册 + 执照 + 绑平台 + 微信支付 | 4–6 周 |
+| **P2** | 手动采购台账 + 订阅消息 | 2–3 周 |
+| **P3** | 平台订单同步预研 | 单独立项 |
+
+---
+
+## 14. 关联文档
+
+| 文档 | 路径 |
+|------|------|
+| 主 PRD 摘要 | [PRD-摘要.md](PRD-摘要.md) |
+| API 接口设计 | [api/API-小程序.md](../api/API-小程序.md) |
+| Stitch 出图 Prompt | [stitch-prompts/batch-6-miniprogram-全量.md](../stitch-prompts/batch-6-miniprogram-全量.md) |
+| 微信接入清单 | [docs/微信小程序接入清单.md](../docs/微信小程序接入清单.md) |
+| 演示链路 | [prototype-flows/小程序-链路.md](../prototype-flows/小程序-链路.md) |

+ 224 - 0
docs/小程序/前端对接变更说明-小程序API.md

@@ -0,0 +1,224 @@
+# 小程序后端 API — 前端对接变更说明
+
+> 日期:2026-07-06
+> 原则:新增端点,不动老的。PC/Web 端不受影响。
+
+---
+
+## 1. 路径映射表(设计文档 → 实际后端)
+
+| 设计文档路径 | 实际后端路径 | 说明 |
+|-------------|-------------|------|
+| `POST /auth/mp/login` | `POST /api/auth/wechat/miniapp/login` | 已实现,body 用 `jsCode` 替代 `code` |
+| `POST /auth/register` | `POST /api/auth/register` | **新增**,独立注册接口 |
+| `POST /auth/refresh` | `POST /api/auth/refresh` | 已有,不变 |
+| `POST /auth/logout` | `POST /api/auth/logout` | **新增** |
+| `GET /users/me` | `GET /api/auth/user/me` | **新增**(聚合 user+gate+quota+platforms) |
+| `GET /users/me/dashboard` | `GET /api/auth/user/dashboard` | **新增** |
+| `GET /licenses/current` | `GET /api/business-license/info` | 已有,响应字段见下方对比 |
+| `POST /licenses/upload` | `POST /api/business-license/upload-file` | 已有,multipart |
+| `GET /platform-bindings` | `GET /api/platform-accounts` | 已有 |
+| `POST /platform-bindings` | `POST /api/platform-accounts` | 已有 |
+| `DELETE /platform-bindings/{id}` | `DELETE /api/platform-account/unbind/{platformCode}` | 已有,路径不同 |
+| `GET /queries` | `GET /api/search/records` | 已有 |
+| `GET /queries/{id}` | `GET /api/search/records/{id}` | 已有 |
+| `GET /membership/plans` | `GET /api/payment/plans` | 已有(公开) |
+| `POST /membership/orders` | `POST /api/payment/orders` | 已有 |
+| `GET /membership/orders/{no}` | `GET /api/payment/orders/{no}` | 已有 |
+| `POST /coupons/redeem` | `POST /api/coupons/redeem` | 已有 |
+| `GET /invites/stats` | `GET /api/invite/stats` | 已有 |
+| `GET /purchase-intents` | `GET /api/purchase-intents` | **新增** |
+| `GET /purchase-intents/{id}` | `GET /api/purchase-intents/{id}` | **新增** |
+| `POST /purchase-intents` | `POST /api/purchase-intents` | **新增**(桌面端写) |
+| `GET /content/help` | `GET /api/content/help` | **新增**(公开) |
+| `GET /content/download` | `GET /api/content/download` | **新增**(公开) |
+
+---
+
+## 2. 新增接口详情
+
+### 2.1 独立注册 — `POST /api/auth/register`
+
+**场景**:微信登录后 `isRegistered=false`,跳转注册页填写信息。
+
+请求(需认证,带 tempToken):
+```json
+{
+  "tempToken": "eyJ...",
+  "inviteCode": "PX8K2M",
+  "pharmacyName": "康宁大药房",
+  "province": "广东省",
+  "city": "广州市",
+  "district": "天河区"
+}
+```
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "userId": "123456",
+    "accessToken": "eyJ...",
+    "refreshToken": "rf_...",
+    "expiresIn": 3600,
+    "userInfo": { /* UserInfoResponse */ }
+  }
+}
+```
+
+### 2.2 聚合用户信息 — `GET /api/auth/user/me`
+
+**场景**:我的页面,一次拿到用户信息 + 门禁 + 配额 + 平台绑定。
+
+> 旧接口 `GET /api/auth/user/info` **保持不变**。
+
+响应新增字段(相比 `/info`):
+```json
+{
+  "code": 200,
+  "data": {
+    /* ...原有 UserInfoResponse 字段... */
+    "gate": {
+      "canQueryOnDesktop": false,
+      "licenseStatus": "PENDING",
+      "platformBoundCount": 2,
+      "gateMessages": ["请先上传营业执照", "请至少绑定1个B2B平台"]
+    },
+    "quota": {
+      "dailyQueryUsed": 8,
+      "dailyQueryLimit": 20,
+      "watchlistUsed": 5,
+      "watchlistLimit": 30
+    },
+    "platformBindings": [
+      {
+        "platformCode": "yaobangmang",
+        "platformName": "药帮忙",
+        "status": "bound",
+        "lastVerifyTime": "2026-06-27T10:00:00"
+      }
+    ]
+  }
+}
+```
+
+### 2.3 首页概览 — `GET /api/auth/user/dashboard`
+
+**场景**:首页统计卡片。
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "todayQueryCount": 8,
+    "todayPurchaseIntentCount": 0,
+    "totalQueryCount": 156,
+    "recentQueries": [
+      { "drugName": "阿莫西林胶囊", "searchedAt": "...", "lowestPrice": 8.50 }
+    ]
+  }
+}
+```
+
+### 2.4 登出 — `POST /api/auth/logout`
+
+**场景**:清除服务端 refresh token,小程序端清除本地 token。
+
+请求:无需 body(通过 Authorization header 认证)。
+
+响应:`{"code": 200, "message": "操作成功"}`
+
+### 2.5 解绑平台 — `DELETE /api/platform-accounts/{platformCode}`
+
+**场景**:平台管理页面删除已绑定的平台。
+
+响应:`{"code": 200, "message": "操作成功"}`
+
+---
+
+## 3. 请求体字段差异
+
+| 端 | 字段 | 说明 |
+|----|------|------|
+| 小程序登录 | `jsCode` | 对应 `wx.login()` 返回值 |
+| 设计文档 | `code` | 文档用名,实际用 `jsCode` |
+
+---
+
+## 4. PC/Web 端影响
+
+**无影响。** 所有新增端点均为独立的 Controller 方法,不修改已有的:
+
+- `GET /api/auth/user/info` — 保持不变
+- `POST /api/auth/sms/login` — 保持不变
+- `POST /api/auth/password/login` — 保持不变
+- `POST /api/auth/wechat/login` — 保持不变
+- `POST /api/auth/refresh` — 保持不变
+- `POST /api/auth/heartbeat` — 保持不变
+
+---
+
+## 5. 新增接口详情(续)
+
+### 5.1 跳转购买记录列表 — `GET /api/purchase-intents`
+
+查询参数:`page=1&pageSize=20`
+
+响应 `data`(分页):
+```json
+{
+  "records": [
+    {
+      "id": "123456",
+      "queryId": "789",
+      "drugName": "阿莫西林胶囊",
+      "spec": "0.25g×24粒",
+      "manufacturer": "石药集团",
+      "platformCode": "yaobangmang",
+      "platformName": "药帮忙",
+      "referencePrice": 8.50,
+      "sourceClient": "WINDOWS",
+      "clickedAt": "2026-06-27T14:35:00"
+    }
+  ],
+  "total": 10,
+  "current": 1,
+  "size": 20
+}
+```
+
+### 5.2 跳转购买详情 — `GET /api/purchase-intents/{id}`
+
+返回单条记录(同上结构)。
+
+### 5.3 写入跳转记录 — `POST /api/purchase-intents`(桌面端调用)
+
+```json
+{
+  "queryId": "789",
+  "drugName": "阿莫西林胶囊",
+  "spec": "0.25g×24粒",
+  "manufacturer": "石药集团",
+  "platformCode": "yaobangmang",
+  "platformName": "药帮忙",
+  "referencePrice": 8.50,
+  "externalUrl": "https://...",
+  "sourceClient": "WINDOWS"
+}
+```
+
+### 5.4 帮助 FAQ — `GET /api/content/help`(公开)
+
+### 5.5 桌面下载链接 — `GET /api/content/download`(公开)
+
+---
+
+## 6. 待实现(V1.1)
+
+| 接口 | 优先级 | 说明 |
+|------|--------|------|
+| `GET /licenses/current/image-url` | P1 | 已可通过 upload 返回值获取 |
+| `POST /platform-bindings/{id}/validate` | P1 | 平台验证逻辑复杂 |
+| `/procurement-ledgers/*` | V1.1 | 手动采购台账,下一版本 |

+ 142 - 0
docs/小程序/小程序-链路.md

@@ -0,0 +1,142 @@
+# 小程序 Instant Prototype 链路
+
+> 8 条可演示主路径 · 页面 ID 前缀 `mp-`  
+> 关联 Prompt:[batch-6-miniprogram-全量.md](../stitch-prompts/batch-6-miniprogram-全量.md)
+
+---
+
+## 链路 6A:老用户查看查价历史
+
+```
+mp-login(已注册,自动登录)
+  → mp-history-list(历史 Tab)
+  → mp-history-detail(点击 阿莫西林胶囊)
+  → mp-purchase-detail(点击关联采购记录)
+  → mp-history-detail(返回)
+```
+
+**演示要点**:
+- 列表展示时间、关键词、最低价、来源端
+- 详情页只读报价卡片
+- 不因执照审核状态阻断历史浏览
+
+---
+
+## 链路 6B:体验到期续费会员
+
+```
+mp-profile(我的 Tab)
+  → mp-membership(会员中心)
+  → [微信支付调起]
+  → mp-payment-result(支付成功)
+  → mp-profile(会员徽章变为付费会员)
+```
+
+**演示要点**:
+- 权益对比与主 PRD 一致(桌面查询/关注配额)
+- 年卡 ¥899 + 微信支付
+
+---
+
+## 链路 6C:邀请好友拉新
+
+```
+mp-profile
+  → mp-invite-center
+  → [分享给微信好友]
+  → (好友端)mp-login → mp-register → … 完整注册
+```
+
+---
+
+## 链路 6D:新用户小程序完整注册
+
+```
+mp-login
+  → [授权手机号,未注册]
+  → mp-register(邀请码 + 药店信息)
+  → mp-license-upload(拍照/相册)
+  → mp-license-pending(可选,提交后)
+  → mp-onboard-bind(三平台引导)
+  → mp-platform-bind(绑定药师帮)
+  → mp-history-list(进入主界面)
+```
+
+**演示要点**:
+- 小程序作为**完整注册入口**,与桌面端信息同步
+- 执照审核中仍可进入历史 Tab 浏览(若有数据)
+
+---
+
+## 链路 6D2:执照驳回重新上传
+
+```
+mp-profile → 营业执照
+  → mp-license-rejected
+  → mp-license-upload
+  → mp-license-pending
+```
+
+**演示要点**:驳回不阻断历史/采购浏览
+
+---
+
+## 链路 6E:体验券核销
+
+```
+mp-profile → mp-coupon-redeem → [核销] → mp-profile
+```
+
+---
+
+## 链路 6F:平台账号管理
+
+```
+mp-profile
+  → mp-platform-list
+  → mp-platform-bind(新增或修改绑定)
+  → mp-platform-list(状态已同步)
+```
+
+---
+
+## 链路 6G:审核中浏览历史
+
+```
+mp-login(执照 pending)
+  → mp-license-pending
+  → [点击「先去浏览历史记录」]
+  → mp-history-list(正常展示)
+```
+
+**演示要点**:审核仅影响桌面查价门禁,不单独限制小程序历史
+
+---
+
+## 与桌面链路的关系
+
+| 桌面链路 | 小程序链路 | 数据关系 |
+|----------|------------|----------|
+| 链路 1:注册 → 执照 → 绑平台 | 链路 6D | **同一后端**,任一端完成均可 |
+| 链路 2:查价 → 历史 | 链路 6A | QueryLog 各端只读一致 |
+| 链路 3:会员/邀请 | 链路 6B / 6C | 同一会员与邀请体系 |
+
+---
+
+## 官网 → 小程序
+
+```
+proprice.kailin.com.cn
+  → [打开小程序] wx-open-launch-weapp / 小程序码
+  → mp-login
+  → 链路 6A 或 6D
+```
+
+---
+
+## Stitch 串联顺序建议
+
+1. 注册链:`mp-login` → `mp-register` → `mp-license-*` → `mp-onboard-bind` → `mp-platform-*`
+2. 主框架:`mp-profile`、`mp-history-list`、Tab 结构
+3. 数据浏览:`mp-history-detail`、`mp-purchase-*`
+4. 增长:`mp-membership`、`mp-invite-center`、`mp-help`

+ 340 - 0
docs/小程序/微信小程序接入清单.md

@@ -0,0 +1,340 @@
+# 智价云小程序 — 微信接入清单
+
+> 定稿日期:2026-06-27  
+> 用于 P1 开发启动前的账号与资质准备  
+> 关联 PRD:[PRD-小程序-V1.0.md](../prd/PRD-小程序-V1.0.md)
+
+---
+
+## 1. 准备事项总览
+
+| 序号 | 事项 | 负责方 | 预计周期 | 状态 |
+|------|------|--------|----------|------|
+| 1 | 微信小程序注册 | 运营/法务 | 1–3 工作日 | ☐ |
+| 2 | 小程序类目与资质 | 运营/法务 | 3–7 工作日 | ☐ |
+| 3 | 微信认证(企业) | 运营/财务 | 1–5 工作日 | ☐ |
+| 4 | 微信支付商户号 | 财务 | 5–10 工作日 | ☐ |
+| 5 | 小程序与商户号绑定 | 技术 | 1 工作日 | ☐ |
+| 6 | 服务器域名配置 | 技术 | 1 工作日 | ☐ |
+| 7 | 隐私协议与用户协议 | 法务 | 3 工作日 | ☐ |
+| 8 | 订阅消息模板申请 | 运营 | 1–3 工作日 | ☐ |
+
+---
+
+## 2. 微信小程序注册
+
+### 2.1 注册入口
+
+- 平台:[微信公众平台](https://mp.weixin.qq.com/)
+- 类型:**小程序**(非订阅号/服务号)
+- 主体:与智价云运营主体一致(建议与 `proprice.kailin.com.cn` 备案主体相同)
+
+### 2.2 基本信息
+
+| 项 | 建议值 |
+|----|--------|
+| 小程序名称 | 智价云 |
+| 简介 | 药店 B2B 采购查价记录与会员服务 |
+| 头像 | 沿用 ProPrice 品牌 Logo(克兰茵蓝 #002FA7) |
+| 服务类目 | 见 §3 |
+
+### 2.3 管理员
+
+- 绑定管理员微信(需实名)
+- 记录 AppID、AppSecret(仅后端使用,**不可写入小程序前端代码**)
+
+---
+
+## 3. 类目与资质
+
+### 3.1 推荐类目(首选)
+
+| 一级类目 | 二级类目 | 说明 |
+|----------|----------|------|
+| 工具 | 信息查询 | 查价记录查询、采购参考信息展示 |
+
+### 3.2 备选类目
+
+| 一级类目 | 二级类目 | 说明 |
+|----------|----------|------|
+| 商业服务 | 软件/建站/技术开发 | 若信息查询类目审核受限 |
+
+### 3.3 需准备的资质材料
+
+| 材料 | 用途 |
+|------|------|
+| 营业执照 | 主体认证 |
+| ICP 备案号 | 关联 `proprice.kailin.com.cn` |
+| 软件著作权或产品说明 | 部分类目可能要求 |
+| 不涉及药品销售的声明函 | 审核补充说明 |
+
+### 3.4 审核注意事项
+
+**必须避免**的表述:
+- 药品销售、在线购药、自营药品
+- 「全网最低」「保证最低价」等绝对化用语
+- 未授权爬取、破解登录等描述
+
+**必须包含**的表述:
+> 数据来源于您本人绑定的 B2B 平台账号,智价云不提供药品交易服务。
+
+**功能描述建议**:
+- 展示用户在桌面软件产生的**查价历史与采购参考记录**
+- 会员开通与续费
+- 邀请好友使用智价云桌面软件
+
+---
+
+## 4. 微信认证
+
+| 项 | 说明 |
+|----|------|
+| 认证类型 | 企业认证 |
+| 费用 | 300 元/年(以微信官方为准) |
+| 收益 | 获取手机号组件、微信支付、更高 API 配额 |
+| 对公账户 | 认证需对公打款或法人扫脸 |
+
+**必须完成认证**,否则无法使用:
+- `getPhoneNumber` 获取手机号
+- `wx.requestPayment` 微信支付
+
+---
+
+## 5. 微信支付商户号
+
+### 5.1 申请入口
+
+- [微信支付商户平台](https://pay.weixin.qq.com/)
+- 或使用小程序后台「微信支付」→「开通」引导
+
+### 5.2 申请材料
+
+| 材料 | 说明 |
+|------|------|
+| 营业执照 | 与小程序主体一致 |
+| 法人身份证 | 正反面 |
+| 对公银行账户 | 结算账户 |
+| 经营场景说明 | B2B 采购信息查询工具会员服务 |
+| 小程序 AppID | 与小程序绑定 |
+
+### 5.3 费率与结算
+
+- 默认费率约 0.6%(以签约为准)
+- 结算周期 T+1(以商户协议为准)
+- 商品名称建议:`智价云会员-年卡` / `智价云会员-月卡`
+
+### 5.4 小程序绑定
+
+1. 商户平台 → 产品中心 → AppID 账号管理 → 关联小程序 AppID
+2. 小程序后台 → 微信支付 → 确认绑定
+3. 后端配置:商户号 mchid、APIv3 密钥、商户证书
+
+---
+
+## 6. 服务器与域名配置
+
+### 6.1 小程序后台 → 开发 → 开发管理 → 服务器域名
+
+| 类型 | 域名示例 | 用途 |
+|------|----------|------|
+| request 合法域名 | `https://api.proprice.kailin.com.cn` | API 请求 |
+| uploadFile | 同上 | **营业执照图片上传** |
+| downloadFile | `https://proprice.kailin.com.cn` | 下载桌面安装包 |
+| 业务域名 | `https://proprice.kailin.com.cn` | web-view 打开 H5 邀请页 |
+
+### 6.2 SSL 证书
+
+- 全站 HTTPS,TLS 1.2+
+- 证书与 API 域名一致
+
+### 6.3 后端环境变量
+
+```
+WECHAT_MP_APPID=
+WECHAT_MP_SECRET=
+WECHAT_MCH_ID=
+WECHAT_MCH_API_V3_KEY=
+WECHAT_MCH_CERT_PATH=
+WECHAT_MCH_KEY_PATH=
+WECHAT_NOTIFY_URL=https://api.proprice.kailin.com.cn/v1/payments/wechat/notify
+```
+
+### 6.4 营业执照上传配置
+
+**app.json 权限声明**:
+
+```json
+{
+  "permission": {
+    "scope.camera": {
+      "desc": "用于拍摄上传营业执照"
+    }
+  }
+}
+```
+
+**接口**:使用 `wx.chooseMedia`(拍照/相册),上传至 `POST /licenses/upload`。
+
+**存储**:执照图片存私有对象存储(OSS/COS),仅通过签名 URL 访问;不可公开 CDN。
+
+---
+
+## 7. 隐私与合规
+
+### 7.1 必需协议页面
+
+| 协议 | 路径建议 | 说明 |
+|------|----------|------|
+| 用户协议 | `/pages/agreement/user` 或 H5 | 注册/登录勾选 |
+| 隐私政策 | `/pages/agreement/privacy` 或 H5 | 说明收集手机号、openid |
+| 会员服务协议 | 会员中心链接 | 支付前勾选 |
+
+### 7.2 隐私保护指引(小程序后台填写)
+
+需声明收集的用户信息:
+
+| 信息类型 | 用途 | 是否必需 |
+|----------|------|----------|
+| 手机号 | 账号匹配、登录 | 是 |
+| 微信 openid | 登录态、支付 | 是 |
+| 查价历史 | 展示用户数据 | 是(用户自有数据) |
+| 相册/相机 | 营业执照拍照上传 | 是(注册与资质) |
+
+### 7.3 用户隐私保护弹窗
+
+- 首次打开需弹出《用户隐私保护指引》
+- 使用官方 `__usePrivacyCheck__` 机制(基础库 2.32.3+)
+
+---
+
+## 8. 订阅消息(V1.2 预备)
+
+### 8.1 建议申请的模板
+
+| 场景 | 模板类型 | 触发条件 |
+|------|----------|----------|
+| 体验即将到期 | 到期提醒 | 体验剩余 3 天 |
+| 会员即将到期 | 到期提醒 | 会员剩余 7 天 |
+| 支付成功 | 支付结果 | 会员开通成功 |
+
+### 8.2 申请路径
+
+小程序后台 → 功能 → 订阅消息 → 选用公共模板或申请类目模板
+
+### 8.3 用户授权
+
+- 首次触发前调用 `wx.requestSubscribeMessage`
+- 用户拒绝时不阻断主流程
+
+---
+
+## 9. 分享与引流
+
+### 9.1 官网 proprice.kailin.com.cn(已定稿)
+
+| 入口 | 实现 | 状态 |
+|------|------|------|
+| 打开小程序 | `<wx-open-launch-weapp>`(需服务号关联)或页面展示小程序码 | ☐ |
+| 下载 Windows 版 | `/download/windows` 直链 | ☐ |
+| 能力说明 | 查价用桌面;小程序负责注册/执照/绑平台/历史/会员 | ☐ |
+
+### 9.2 分享给好友
+
+```javascript
+onShareAppMessage() {
+  return {
+    title: '智价云 — 药店采购查价好帮手',
+    path: '/pages/invite/index?code=PX8K2M',
+    imageUrl: '/assets/share-cover.png'
+  }
+}
+```
+
+### 9.3 官网联动(已定稿)
+
+在 `proprice.kailin.com.cn` 增加「打开小程序」与「下载桌面版」双 CTA,与 PRD §10 一致。
+
+### 9.4 小程序码
+
+- 路径:`pages/index/index?from=official`
+- 用于官网、宣传物料、客服话术
+
+---
+
+## 10. 开发调试
+
+### 10.1 开发者工具
+
+- [微信开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html)
+- 导入项目,填写 AppID
+
+### 10.2 体验版与审核版
+
+| 版本 | 用途 |
+|------|------|
+| 开发版 | 日常开发 |
+| 体验版 | 内部测试,需添加体验成员 |
+| 审核版 | 提交微信审核 |
+| 正式版 | 全量发布 |
+
+### 10.3 审核提交材料
+
+| 材料 | 说明 |
+|------|------|
+| 测试账号 | 已注册手机号 + 说明登录步骤 |
+| 功能说明 | 伴侣型,查价在桌面端完成 |
+| 截图 | 历史、采购、会员、邀请 4 个核心页面 |
+| 演示视频(可选) | 30 秒展示数据同步 |
+
+---
+
+## 11. 上线检查清单
+
+### 11.1 功能
+
+- [ ] 微信登录 + 手机号授权正常
+- [ ] 小程序内完整注册(邀请码 + 执照 + 绑平台)与桌面数据一致
+- [ ] 执照审核中仍可浏览历史/采购
+- [ ] 平台绑定/解绑与桌面同步
+- [ ] 查询历史列表/详情与桌面数据一致
+- [ ] 采购记录列表/详情正常
+- [ ] 微信支付全流程(下单 → 支付 → 回调 → 会员生效)
+- [ ] 邀请码复制与分享
+- [ ] 体验券核销
+- [ ] 体验用户与付费会员历史/采购记录可见范围一致(无条数限制)
+- [ ] 合规文案全页面可见
+
+### 11.2 安全
+
+- [ ] AppSecret、商户密钥仅在后端
+- [ ] API 全 HTTPS
+- [ ] Token 过期自动刷新
+- [ ] 支付回调验签
+
+### 11.3 合规
+
+- [ ] 隐私政策已发布并在后台配置
+- [ ] 用户协议已发布
+- [ ] 不涉及药品销售描述
+- [ ] 平台账号密码不在小程序出现
+
+---
+
+## 12. 联系人模板
+
+| 角色 | 姓名 | 联系方式 | 职责 |
+|------|------|----------|------|
+| 小程序管理员 | | | 后台配置、审核提交 |
+| 微信支付对接 | | | 商户号、结算 |
+| 后端开发 | | | API、回调、域名 |
+| 法务 | | | 协议、类目资质 |
+
+---
+
+## 13. 参考链接
+
+- [微信小程序注册流程](https://developers.weixin.qq.com/miniprogram/product/registration.html)
+- [小程序类目](https://developers.weixin.qq.com/miniprogram/product/material/)
+- [获取手机号](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html)
+- [微信支付开发文档](https://pay.weixin.qq.com/docs/merchant/products/mini-program-payment/development.html)
+- [用户隐私保护指引](https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/)

+ 5 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java

@@ -42,6 +42,11 @@ public enum ErrorCode {
     WECHAT_CODE_INVALID(1201, "微信授权码无效"),
     WECHAT_BIND_REQUIRED(1202, "请先绑定手机号"),
     WECHAT_LOGIN_FAILED(1203, "微信登录失败,请重试"),
+    WECHAT_MINIAPP_CODE_INVALID(1204, "小程序授权码无效"),
+    WECHAT_MINIAPP_PHONE_FAILED(1205, "获取小程序手机号失败"),
+    EXTERNAL_SYSTEM_ERROR(1206, "外部系统错误"),
+    CERTIFICATE_SYNC_FAILED(1207, "证书同步失败"),
+    CERTIFICATE_ALREADY_EXISTS(1208, "证书记录已存在"),
 
     // ========== Token 1301-1399 ==========
     TOKEN_EXPIRED(1301, "Token已过期"),

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

@@ -37,6 +37,25 @@ wechat:
     qr-connect:
       redirect-uri: https://your-domain.com/api/auth/wechat/callback
       scope: snsapi_login
+  # 微信小程序配置
+  miniapp:
+    app-id: ${WECHAT_MINIAPP_APP_ID:}
+    app-secret: ${WECHAT_MINIAPP_APP_SECRET:}
+
+# 外部系统(小程序后台)API 配置
+external-system:
+  miniapp:
+    base-url: ${EXTERNAL_MINIAPP_API_URL:}
+    api-key: ${EXTERNAL_MINIAPP_API_KEY:}
+    connect-timeout: 5000
+    read-timeout: 10000
+
+# 应用下载与站点配置
+app:
+  download:
+    windows-url: ${APP_DOWNLOAD_WINDOWS_URL:https://proprice.kailin.com.cn/download/windows}
+    official-site: ${APP_OFFICIAL_SITE:https://proprice.kailin.com.cn}
+    version: ${APP_VERSION:1.0.0}
 
 # 阿里云短信(公共模板,各环境可覆盖 key)
 sms:

+ 99 - 0
zhijiayun-gateway/src/main/resources/db/migration-v8.sql

@@ -0,0 +1,99 @@
+-- ============================================
+-- 迁移 v8:微信小程序登录 + 统一证书表 + 外部系统同步 + 采购跳转记录 + 采购台账 + PC/移动端隔离
+-- ============================================
+
+-- 1. t_user 扩展字段(小程序 + 外部系统关联)
+ALTER TABLE `t_user`
+    ADD COLUMN `wechat_mini_open_id` VARCHAR(64) DEFAULT NULL COMMENT '微信小程序OpenID' AFTER `wechat_open_id`,
+    ADD COLUMN `external_user_id` VARCHAR(64) DEFAULT NULL COMMENT '外部系统(小程序后台)用户ID' AFTER `wechat_mini_open_id`,
+    ADD COLUMN `external_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间' AFTER `external_user_id`;
+
+-- 2. t_business_license 扩展字段(同步来源,历史数据标记为 MANUAL)
+ALTER TABLE `t_business_license`
+    ADD COLUMN `sync_source` VARCHAR(20) DEFAULT 'MANUAL' COMMENT '来源:MANUAL-手动上传 MINIAPP_SYNC-外部系统同步' AFTER `show_verified_badge`,
+    ADD COLUMN `external_id` VARCHAR(64) DEFAULT NULL COMMENT '外部系统记录ID' AFTER `sync_source`;
+
+-- 3. 统一用户证书表(营业执照 / 药品经营许可证 / 医疗器械备案凭证)
+--    设计理念:证书的核心是图片,用于验证用户真实性和资质;
+--    额外信息(编号、有效期等)通过 OCR 或人工审核时补充到 ocr_data JSON 字段。
+--    注意:t_business_license 为历史表,此后新证书数据统一使用本表。
+CREATE TABLE IF NOT EXISTS `t_user_certificate` (
+    `id`                    BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`               BIGINT        NOT NULL COMMENT '用户ID',
+    `certificate_type`      VARCHAR(30)   NOT NULL COMMENT '证书类型:BUSINESS_LICENSE/PHARMACY_LICENSE/MEDICAL_DEVICE_FILING',
+    `image_url`             VARCHAR(500)  DEFAULT NULL COMMENT '证书图片URL(核心字段)',
+    `ocr_data`              TEXT          DEFAULT NULL COMMENT 'OCR识别结果或人工审核信息(JSON,可为NULL)',
+    `review_status`         VARCHAR(20)   DEFAULT 'PENDING' COMMENT '审核状态:PENDING/APPROVED/REJECTED',
+    `reviewer_id`           BIGINT        DEFAULT NULL COMMENT '审核人ID',
+    `review_time`           DATETIME      DEFAULT NULL COMMENT '审核时间',
+    `reject_reason`         VARCHAR(500)  DEFAULT NULL COMMENT '驳回原因',
+    `sync_source`           VARCHAR(20)   DEFAULT 'MANUAL' COMMENT '来源:MANUAL-手动上传 MINIAPP_SYNC-外部系统同步',
+    `external_id`           VARCHAR(64)   DEFAULT NULL COMMENT '外部系统记录ID',
+    `remark`                VARCHAR(500)  DEFAULT NULL COMMENT '备注',
+    `create_time`           DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`           DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_cert_type` (`user_id`, `certificate_type`),
+    KEY `idx_review_status` (`review_status`),
+    KEY `idx_certificate_type` (`certificate_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表(统一管理各类资质证书)';
+
+-- 4. 证书同步日志表
+CREATE TABLE IF NOT EXISTS `t_certificate_sync_log` (
+    `id`                      BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`                 BIGINT        NOT NULL COMMENT '系统用户ID',
+    `source_system_user_id`   VARCHAR(64)   DEFAULT NULL COMMENT '外部系统用户ID',
+    `sync_type`               VARCHAR(30)   NOT NULL COMMENT '同步类型:BUSINESS_LICENSE/PHARMACY_LICENSE/MEDICAL_DEVICE_FILING',
+    `sync_status`             VARCHAR(20)   NOT NULL DEFAULT 'PENDING' COMMENT '同步状态:PENDING/SUCCESS/FAILED/SKIPPED',
+    `source_data`             TEXT          DEFAULT NULL COMMENT '原始数据(JSON)',
+    `target_record_id`        BIGINT        DEFAULT NULL COMMENT '目标记录ID',
+    `error_message`           VARCHAR(1000) DEFAULT NULL COMMENT '错误信息',
+    `retry_count`             INT           DEFAULT 0 COMMENT '重试次数',
+    `create_time`             DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`             DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_sync_status` (`sync_status`),
+    KEY `idx_sync_type` (`sync_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书同步日志表';
+
+-- 5. 跳转购买记录表(各端点击"去购买"时写入,小程序只读)
+CREATE TABLE IF NOT EXISTS `t_purchase_intent` (
+    `id`                    BIGINT        NOT NULL COMMENT '主键ID(雪花算法)',
+    `user_id`               BIGINT        NOT NULL COMMENT '用户ID',
+    `query_id`              BIGINT        DEFAULT NULL COMMENT '关联查询记录ID(可空)',
+    `drug_name`             VARCHAR(200)  NOT NULL COMMENT '药品名称',
+    `spec`                  VARCHAR(100)  DEFAULT NULL COMMENT '规格',
+    `manufacturer`          VARCHAR(200)  DEFAULT NULL COMMENT '厂家',
+    `platform_code`         VARCHAR(32)   NOT NULL COMMENT '平台代码',
+    `platform_name`         VARCHAR(100)  DEFAULT NULL COMMENT '平台名称',
+    `reference_price`       DECIMAL(10,2) DEFAULT NULL COMMENT '跳转时参考价',
+    `external_url`          VARCHAR(2048) DEFAULT NULL COMMENT '跳转URL',
+    `source_client`         VARCHAR(20)   NOT NULL DEFAULT 'WINDOWS' COMMENT '产生端:WINDOWS/ANDROID/MINIAPP',
+    `clicked_at`            DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '跳转时间',
+    `create_time`           DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_clicked` (`user_id`, `clicked_at`),
+    KEY `idx_query_id` (`query_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跳转购买记录表';
+
+-- 6. 手动采购台账表(V1.1)
+CREATE TABLE IF NOT EXISTS `t_procurement_ledger` (
+    `id`                    BIGINT        NOT NULL COMMENT '主键ID(雪花算法)',
+    `user_id`               BIGINT        NOT NULL COMMENT '用户ID',
+    `intent_id`             BIGINT        DEFAULT NULL COMMENT '关联跳转购买记录ID',
+    `query_id`              BIGINT        DEFAULT NULL COMMENT '关联查询记录ID',
+    `drug_name`             VARCHAR(200)  NOT NULL COMMENT '药品名称',
+    `spec`                  VARCHAR(100)  DEFAULT NULL COMMENT '规格',
+    `qty`                   DECIMAL(10,2) DEFAULT NULL COMMENT '数量',
+    `unit_price`            DECIMAL(10,2) DEFAULT NULL COMMENT '单价',
+    `total`                 DECIMAL(10,2) DEFAULT NULL COMMENT '总价',
+    `platform`              VARCHAR(100)  DEFAULT NULL COMMENT '平台',
+    `order_note`            VARCHAR(500)  DEFAULT NULL COMMENT '备注',
+    `status`                VARCHAR(20)   NOT NULL DEFAULT 'ordered' COMMENT '状态:ordered-已下单 received-已到货',
+    `created_at`            DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间',
+    `update_time`           DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_created` (`user_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='手动采购台账表';
+

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

@@ -39,7 +39,9 @@ public class SecurityConfig {
             "/api/admin/auth/login",       // 管理员登录(公开)
             "/api/auth/password/login",  // 密码登录
             "/api/auth/refresh",         // 刷新Token(无需认证)
-            "/api/auth/wechat/**",       // 微信登录相关
+            "/api/auth/wechat/**",       // 微信登录相关(含小程序)
+            "/api/auth/external/**",     // 外部系统接口(用 X-Api-Key 认证)
+            "/api/auth/register",        // 小程序独立注册(用 tempToken 认证)
             "/api/invite/validate",      // 验证邀请码(注册前)
             "/api/invite/{code}",        // 邀请链接入口(公开)
             "/api/invite/page/**",       // 邀请落地页(公开)
@@ -48,6 +50,8 @@ public class SecurityConfig {
             "/invite/**",                // 浏览器直接访问邀请链接(公开)
             "/api/platform-config/**",   // 平台配置查询(公开)
             "/api/search/suggestions",   // 搜索建议(公开)
+            "/api/content/help",          // 帮助FAQ(公开)
+            "/api/content/download",      // 下载链接(公开)
             "/api/payment/callback/**",  // 支付回调(公开,供微信/支付宝调用)
             "/api/payment/anxin/notify/**",  // 安心付通知(公开,供支付宝调用)
             "/api/payment/agreement/notify/**",  // 签约回调通知(公开,供微信调用)

+ 116 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AuthController.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.controller;
 
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.Result;
 import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.user.dto.*;
@@ -9,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 
 /**
@@ -23,6 +26,9 @@ public class AuthController {
     private final AuthService authService;
     private final WechatService wechatService;
 
+    @Value("${external-system.miniapp.api-key:}")
+    private String externalApiKey;
+
     /**
      * 发送验证码
      * POST /api/auth/sms/send
@@ -95,6 +101,42 @@ public class AuthController {
         return Result.success("https://your-domain.com/wechat-login?code=" + code);
     }
 
+    // ====================================
+    // 小程序登录接口
+    // ====================================
+
+    /**
+     * 小程序统一登录
+     * <p>
+     * 自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景:
+     * 只要任一端不存在该用户,就在该端自动注册。
+     * POST /api/auth/wechat/miniapp/login
+     */
+    @PostMapping("/wechat/miniapp/login")
+    public Result<LoginResponse> miniappLogin(
+            @Valid @RequestBody MiniProgramLoginRequest request,
+            HttpServletRequest httpRequest) {
+        String ip = ValidateUtil.getClientIp(httpRequest);
+        LoginResponse response = authService.miniappUnifiedLogin(request, ip);
+        return Result.success(response);
+    }
+
+    /**
+     * 小程序绑定手机号
+     * <p>
+     * 当统一登录返回 needBindPhone=true 时调用,
+     * 使用 getPhoneNumber 按钮返回的 code 完成手机号绑定。
+     * POST /api/auth/wechat/miniapp/bind-phone
+     */
+    @PostMapping("/wechat/miniapp/bind-phone")
+    public Result<LoginResponse> miniappBindPhone(
+            @Valid @RequestBody MiniProgramPhoneRequest request,
+            HttpServletRequest httpRequest) {
+        String ip = ValidateUtil.getClientIp(httpRequest);
+        LoginResponse response = authService.miniappBindPhone(request, ip);
+        return Result.success(response);
+    }
+
     /**
      * 刷新Token(通过 Refresh Token)
      * POST /api/auth/refresh
@@ -116,7 +158,8 @@ public class AuthController {
     public Result<LoginResponse> heartbeat(HttpServletRequest httpRequest) {
         Long userId = (Long) httpRequest.getAttribute("userId");
         String role = (String) httpRequest.getAttribute("role");
-        return Result.success(authService.heartbeat(userId, role));
+        String loginSource = (String) httpRequest.getAttribute("loginSource");
+        return Result.success(authService.heartbeat(userId, role, loginSource));
     }
 
     /**
@@ -179,4 +222,76 @@ public class AuthController {
         authService.changePhone(userId, request);
         return Result.success();
     }
+
+    // ====================================
+    // 小程序新增接口(不影响已有端点)
+    // ====================================
+
+    /**
+     * 小程序独立注册
+     * POST /api/auth/register
+     */
+    @PostMapping("/register")
+    public Result<LoginResponse> register(
+            @Valid @RequestBody RegisterRequest request,
+            HttpServletRequest httpRequest) {
+        String ip = ValidateUtil.getClientIp(httpRequest);
+        LoginResponse response = authService.register(request, ip);
+        return Result.success(response);
+    }
+
+    /**
+     * 登出
+     * POST /api/auth/logout
+     */
+    @PostMapping("/logout")
+    public Result<Void> logout(HttpServletRequest httpRequest) {
+        Long userId = (Long) httpRequest.getAttribute("userId");
+        authService.logout(userId);
+        return Result.success();
+    }
+
+    /**
+     * 聚合用户信息(user + gate + quota + platforms)
+     * GET /api/auth/user/me
+     */
+    @GetMapping("/user/me")
+    public Result<java.util.Map<String, Object>> getUserMe(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(authService.getUserMe(userId));
+    }
+
+    /**
+     * 首页概览统计
+     * GET /api/auth/user/dashboard
+     */
+    @GetMapping("/user/dashboard")
+    public Result<DashboardResponse> getDashboard(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(authService.getDashboard(userId));
+    }
+
+    // ====================================
+    // 外部系统接口(API Key 鉴权)
+    // ====================================
+
+    /**
+     * 外部系统换取小程序 Token
+     * POST /api/auth/external/miniapp-token
+     * Header: X-Api-Key: {external-system.miniapp.api-key}
+     */
+    @PostMapping("/external/miniapp-token")
+    public Result<LoginResponse> externalMiniAppToken(
+            @Valid @RequestBody ExternalTokenRequest request,
+            HttpServletRequest httpRequest) {
+        // API Key 鉴权
+        String apiKey = httpRequest.getHeader("X-Api-Key");
+        if (externalApiKey == null || externalApiKey.isBlank()
+                || !externalApiKey.equals(apiKey)) {
+            throw new BusinessException(ErrorCode.UNAUTHORIZED, "API Key 无效");
+        }
+        String ip = ValidateUtil.getClientIp(httpRequest);
+        LoginResponse response = authService.externalMiniAppToken(request, ip);
+        return Result.success(response);
+    }
 }

+ 67 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/ContentController.java

@@ -0,0 +1,67 @@
+package com.xuekairui.user.controller;
+
+import com.xuekairui.common.Result;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 内容与配置控制器(帮助、下载链接等静态内容)
+ */
+@RestController
+@RequestMapping("/api/content")
+public class ContentController {
+
+    @Value("${app.download.windows-url:https://proprice.kailin.com.cn/download/windows}")
+    private String windowsUrl;
+
+    @Value("${app.download.official-site:https://proprice.kailin.com.cn}")
+    private String officialSiteUrl;
+
+    @Value("${app.download.version:1.0.0}")
+    private String version;
+
+    /**
+     * 帮助与 FAQ
+     * GET /api/content/help
+     */
+    @GetMapping("/help")
+    public Result<Map<String, Object>> help() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("title", "智价云(药店版)帮助");
+
+        List<Map<String, String>> faqs = List.of(
+                Map.of("q", "小程序和桌面版有什么区别?",
+                       "a", "桌面版用于实时聚合比价和采购;小程序用于查看查询历史、管理执照和会员。查价功能请使用桌面版。"),
+                Map.of("q", "如何上传营业执照?",
+                       "a", "进入「我的 → 营业执照」,拍照或从相册选择上传。提交后运营会在1个工作日内审核。"),
+                Map.of("q", "为什么无法在桌面端查价?",
+                       "a", "请确保:1)营业执照已通过审核;2)至少绑定了1个B2B平台账号。"),
+                Map.of("q", "会员如何升级?",
+                       "a", "进入「我的 → 会员中心」,选择套餐后通过微信支付完成购买。")
+        );
+        data.put("faqs", faqs);
+        data.put("compliance", "数据来源于您本人绑定的B2B平台账号,智价云不提供药品交易服务。");
+
+        return Result.success(data);
+    }
+
+    /**
+     * 桌面下载链接
+     * GET /api/content/download
+     */
+    @GetMapping("/download")
+    public Result<Map<String, String>> download() {
+        Map<String, String> data = new LinkedHashMap<>();
+        data.put("windowsUrl", windowsUrl);
+        data.put("officialSiteUrl", officialSiteUrl);
+        data.put("version", version);
+        data.put("releaseNotes", version + " 版本发布");
+        return Result.success(data);
+    }
+}

+ 113 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/ProcurementLedgerController.java

@@ -0,0 +1,113 @@
+package com.xuekairui.user.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.common.Result;
+import com.xuekairui.user.entity.ProcurementLedger;
+import com.xuekairui.user.mapper.ProcurementLedgerMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+
+/**
+ * 手动采购台账控制器(V1.1)
+ */
+@RestController
+@RequestMapping("/api/procurement-ledgers")
+@RequiredArgsConstructor
+public class ProcurementLedgerController {
+
+    private final ProcurementLedgerMapper ledgerMapper;
+
+    /** GET /api/procurement-ledgers */
+    @GetMapping
+    public Result<Page<ProcurementLedger>> list(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(ledgerMapper.selectPageByUserId(new Page<>(page, pageSize), userId));
+    }
+
+    /** POST /api/procurement-ledgers */
+    @PostMapping
+    public Result<ProcurementLedger> create(@Valid @RequestBody CreateLedgerRequest body,
+                                            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        ProcurementLedger ledger = ProcurementLedger.builder()
+                .userId(userId)
+                .intentId(body.getIntentId())
+                .queryId(body.getQueryId())
+                .drugName(body.getDrugName())
+                .spec(body.getSpec())
+                .qty(body.getQty())
+                .unitPrice(body.getUnitPrice())
+                .total(body.getTotal())
+                .platform(body.getPlatform())
+                .orderNote(body.getOrderNote())
+                .status(body.getStatus() != null ? body.getStatus() : "ordered")
+                .build();
+        ledgerMapper.insert(ledger);
+        return Result.success(ledger);
+    }
+
+    /** PATCH /api/procurement-ledgers/{id} */
+    @PatchMapping("/{id}")
+    public Result<ProcurementLedger> update(@PathVariable Long id,
+                                            @RequestBody UpdateLedgerRequest body,
+                                            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        ProcurementLedger ledger = ledgerMapper.selectById(id);
+        if (ledger == null || !ledger.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.NOT_FOUND);
+        }
+        if (body.getStatus() != null) ledger.setStatus(body.getStatus());
+        if (body.getQty() != null) ledger.setQty(body.getQty());
+        if (body.getUnitPrice() != null) ledger.setUnitPrice(body.getUnitPrice());
+        if (body.getTotal() != null) ledger.setTotal(body.getTotal());
+        if (body.getOrderNote() != null) ledger.setOrderNote(body.getOrderNote());
+        ledgerMapper.updateById(ledger);
+        return Result.success(ledger);
+    }
+
+    /** DELETE /api/procurement-ledgers/{id} */
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        ProcurementLedger ledger = ledgerMapper.selectById(id);
+        if (ledger == null || !ledger.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.NOT_FOUND);
+        }
+        ledgerMapper.deleteById(id);
+        return Result.success();
+    }
+
+    @Data
+    public static class CreateLedgerRequest {
+        private Long intentId;
+        private Long queryId;
+        @NotBlank private String drugName;
+        private String spec;
+        private BigDecimal qty;
+        private BigDecimal unitPrice;
+        private BigDecimal total;
+        private String platform;
+        private String orderNote;
+        private String status;
+    }
+
+    @Data
+    public static class UpdateLedgerRequest {
+        private String status;
+        private BigDecimal qty;
+        private BigDecimal unitPrice;
+        private BigDecimal total;
+        private String orderNote;
+    }
+}

+ 92 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/PurchaseIntentController.java

@@ -0,0 +1,92 @@
+package com.xuekairui.user.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.common.Result;
+import com.xuekairui.user.entity.PurchaseIntent;
+import com.xuekairui.user.mapper.PurchaseIntentMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+
+/**
+ * 跳转购买记录控制器
+ * <p>
+ * GET 接口供小程序只读展示;POST 接口供桌面端写入。
+ */
+@RestController
+@RequestMapping("/api/purchase-intents")
+@RequiredArgsConstructor
+public class PurchaseIntentController {
+
+    private final PurchaseIntentMapper purchaseIntentMapper;
+
+    /**
+     * 查询跳转购买记录列表
+     * GET /api/purchase-intents?page=1&pageSize=20
+     */
+    @GetMapping
+    public Result<Page<PurchaseIntent>> list(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        Page<PurchaseIntent> pageParam = new Page<>(page, pageSize);
+        return Result.success(purchaseIntentMapper.selectPageByUserId(pageParam, userId));
+    }
+
+    /**
+     * 查询跳转购买详情
+     * GET /api/purchase-intents/{id}
+     */
+    @GetMapping("/{id}")
+    public Result<PurchaseIntent> detail(@PathVariable Long id, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        PurchaseIntent intent = purchaseIntentMapper.selectById(id);
+        if (intent == null || !intent.getUserId().equals(userId)) {
+            return Result.error(com.xuekairui.common.ErrorCode.NOT_FOUND);
+        }
+        return Result.success(intent);
+    }
+
+    /**
+     * 写入跳转购买记录(桌面端/Android 调用)
+     * POST /api/purchase-intents
+     */
+    @PostMapping
+    public Result<PurchaseIntent> create(@Valid @RequestBody CreateIntentRequest body,
+                                         HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        PurchaseIntent intent = PurchaseIntent.builder()
+                .userId(userId)
+                .queryId(body.getQueryId())
+                .drugName(body.getDrugName())
+                .spec(body.getSpec())
+                .manufacturer(body.getManufacturer())
+                .platformCode(body.getPlatformCode())
+                .platformName(body.getPlatformName())
+                .referencePrice(body.getReferencePrice())
+                .externalUrl(body.getExternalUrl())
+                .sourceClient(body.getSourceClient() != null ? body.getSourceClient() : "WINDOWS")
+                .build();
+        purchaseIntentMapper.insert(intent);
+        return Result.success(intent);
+    }
+
+    @Data
+    public static class CreateIntentRequest {
+        private Long queryId;
+        @NotBlank private String drugName;
+        private String spec;
+        private String manufacturer;
+        @NotBlank private String platformCode;
+        private String platformName;
+        private BigDecimal referencePrice;
+        private String externalUrl;
+        private String sourceClient;
+    }
+}

+ 34 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/DashboardResponse.java

@@ -0,0 +1,34 @@
+package com.xuekairui.user.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 首页概览响应
+ */
+@Data
+@Builder
+public class DashboardResponse {
+
+    /** 今日查询次数 */
+    private int todayQueryCount;
+
+    /** 今日跳转购买次数 */
+    private int todayPurchaseIntentCount;
+
+    /** 累计查询次数 */
+    private int totalQueryCount;
+
+    /** 最近查询摘要(最多 3 条) */
+    private List<RecentQueryItem> recentQueries;
+
+    @Data
+    @Builder
+    public static class RecentQueryItem {
+        private String drugName;
+        private String searchedAt;
+        private String lowestPrice;
+    }
+}

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

@@ -0,0 +1,18 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 外部系统换取 Token 请求
+ */
+@Data
+public class ExternalTokenRequest {
+
+    /** 手机号 */
+    @NotBlank(message = "手机号不能为空")
+    private String phone;
+
+    /** 可选:邀请码 */
+    private String inviteCode;
+}

+ 27 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/MiniProgramLoginRequest.java

@@ -0,0 +1,27 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 微信小程序登录请求
+ * <p>
+ * 前端调用 wx.login() 获取 jsCode 后提交到此接口,
+ * 后端自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景。
+ */
+@Data
+public class MiniProgramLoginRequest {
+
+    /** 小程序授权码(wx.login() 返回的 code) */
+    @NotBlank(message = "小程序授权码不能为空")
+    private String jsCode;
+
+    /** 手机号授权码(getPhoneNumber 按钮返回的 code,新用户注册时提供) */
+    private String phoneCode;
+
+    /** 邀请码(可选) */
+    private String inviteCode;
+
+    /** 设备类型(默认 MINIAPP) */
+    private String deviceType = "MINIAPP";
+}

+ 25 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/MiniProgramPhoneRequest.java

@@ -0,0 +1,25 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 小程序绑定手机号请求
+ * <p>
+ * 当统一登录返回 needBindPhone=true 时,前端引导用户点击
+ * getPhoneNumber 按钮获取 phoneCode,与 tempToken 一起提交完成手机号绑定。
+ */
+@Data
+public class MiniProgramPhoneRequest {
+
+    /** 临时令牌(登录时返回的 tempToken) */
+    @NotBlank(message = "临时令牌不能为空")
+    private String tempToken;
+
+    /** 手机号授权码(getPhoneNumber 按钮返回的 code) */
+    @NotBlank(message = "手机号授权码不能为空")
+    private String phoneCode;
+
+    /** 邀请码(可选,注册时填写) */
+    private String inviteCode;
+}

+ 37 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/RegisterRequest.java

@@ -0,0 +1,37 @@
+package com.xuekairui.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 小程序独立注册请求
+ * <p>
+ * 微信授权手机号后,如果 isRegistered=false,前端跳转注册页,
+ * 用户填写药店信息后调用此接口完成注册。
+ */
+@Data
+public class RegisterRequest {
+
+    /** 临时令牌(登录时返回的 tempToken) */
+    @NotBlank(message = "临时令牌不能为空")
+    private String tempToken;
+
+    /** 邀请码(可选) */
+    private String inviteCode;
+
+    /** 药店名称 */
+    @NotBlank(message = "药店名称不能为空")
+    private String pharmacyName;
+
+    /** 省 */
+    @NotBlank(message = "省份不能为空")
+    private String province;
+
+    /** 市 */
+    @NotBlank(message = "城市不能为空")
+    private String city;
+
+    /** 区 */
+    @NotBlank(message = "区不能为空")
+    private String district;
+}

+ 24 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserGateStatus.java

@@ -0,0 +1,24 @@
+package com.xuekairui.user.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 用户门禁状态(聚合字段,非数据库表)
+ */
+@Data
+@Builder
+public class UserGateStatus {
+
+    /** 是否可在桌面端查价(执照通过 + 绑平台 ≥1) */
+    private Boolean canQueryOnDesktop;
+
+    /** 执照审核状态:PENDING / APPROVED / REJECTED / NONE */
+    private String licenseStatus;
+
+    /** 已绑定平台数 */
+    private int platformBoundCount;
+
+    /** 待完成事项提示 */
+    private java.util.List<String> gateMessages;
+}

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

@@ -81,6 +81,14 @@ public class BusinessLicense {
     /** 是否显示认证标识 */
     @TableField("show_verified_badge")
     private Boolean showVerifiedBadge;
+
+    /** 来源:MANUAL-手动上传, MINIAPP_SYNC-外部系统同步 */
+    @TableField("sync_source")
+    private String syncSource;
+
+    /** 外部系统记录ID */
+    @TableField("external_id")
+    private String externalId;
     
     /** 备注 */
     @TableField("remark")

+ 69 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/CertificateSyncLog.java

@@ -0,0 +1,69 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 证书同步日志实体
+ * <p>
+ * 记录从外部系统同步证书的每次操作,用于追踪同步状态、失败重试和问题排查。
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("t_certificate_sync_log")
+public class CertificateSyncLog {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 系统用户ID */
+    @TableField("user_id")
+    private Long userId;
+
+    /** 外部系统用户ID */
+    @TableField("source_system_user_id")
+    private String sourceSystemUserId;
+
+    /** 同步类型:BUSINESS_LICENSE / PHARMACY_LICENSE / MEDICAL_DEVICE_FILING */
+    @TableField("sync_type")
+    private String syncType;
+
+    /** 同步状态:PENDING / SUCCESS / FAILED / SKIPPED */
+    @TableField("sync_status")
+    private String syncStatus;
+
+    /** 原始数据(JSON) */
+    @TableField("source_data")
+    private String sourceData;
+
+    /** 目标记录ID */
+    @TableField("target_record_id")
+    private Long targetRecordId;
+
+    /** 错误信息 */
+    @TableField("error_message")
+    private String errorMessage;
+
+    /** 重试次数 */
+    @TableField("retry_count")
+    private Integer retryCount;
+
+    /** 创建时间 */
+    @TableField(value = "create_time", fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 43 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/ProcurementLedger.java

@@ -0,0 +1,43 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 手动采购台账实体(V1.1)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("t_procurement_ledger")
+public class ProcurementLedger {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    private Long userId;
+    private Long intentId;
+    private Long queryId;
+
+    private String drugName;
+    private String spec;
+    private BigDecimal qty;
+    private BigDecimal unitPrice;
+    private BigDecimal total;
+    private String platform;
+    private String orderNote;
+    private String status; // ordered / received
+
+    @TableField("created_at")
+    private LocalDateTime createdAt;
+
+    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 78 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/PurchaseIntent.java

@@ -0,0 +1,78 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 跳转购买记录实体
+ * <p>
+ * 用户在任何端点击「去 [平台] 购买」时写入,小程序只读展示。
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("t_purchase_intent")
+public class PurchaseIntent {
+
+    /** 主键ID(雪花算法) */
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 用户ID */
+    @TableField("user_id")
+    private Long userId;
+
+    /** 关联查询记录ID(可空) */
+    @TableField("query_id")
+    private Long queryId;
+
+    /** 药品名称 */
+    @TableField("drug_name")
+    private String drugName;
+
+    /** 规格 */
+    @TableField("spec")
+    private String spec;
+
+    /** 厂家 */
+    @TableField("manufacturer")
+    private String manufacturer;
+
+    /** 平台代码 */
+    @TableField("platform_code")
+    private String platformCode;
+
+    /** 平台名称 */
+    @TableField("platform_name")
+    private String platformName;
+
+    /** 跳转时参考价 */
+    @TableField("reference_price")
+    private BigDecimal referencePrice;
+
+    /** 跳转URL(内部存储,小程序不返回) */
+    @TableField("external_url")
+    private String externalUrl;
+
+    /** 产生端:WINDOWS / ANDROID / MINIAPP */
+    @TableField("source_client")
+    private String sourceClient;
+
+    /** 跳转时间 */
+    @TableField("clicked_at")
+    private LocalDateTime clickedAt;
+
+    /** 创建时间 */
+    @TableField(value = "create_time", fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 11 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java

@@ -35,12 +35,21 @@ public class User {
     /** 密码(加密存储,可选) */
     private String password;
 
-    /** 微信OpenID(公众号/小程序) */
+    /** 微信OpenID(公众号/网站应用) */
     private String wechatOpenId;
 
+    /** 微信小程序OpenID(与网站应用OpenID不同作用域) */
+    private String wechatMiniOpenId;
+
     /** 微信UnionID(开放平台) */
     private String wechatUnionId;
 
+    /** 外部系统(小程序后台)用户ID */
+    private String externalUserId;
+
+    /** 最后同步时间 */
+    private LocalDateTime externalSyncTime;
+
     /** 微信昵称 */
     private String wechatNickname;
 
@@ -81,7 +90,7 @@ public class User {
     /** 最后登录IP */
     private String lastLoginIp;
 
-    /** Token版本号(每次登录递增,旧版本Token全部失效,实现单设备登录) */
+    /** Token版本号(每次登录递增,旧版本全部失效,单设备登录控制) */
     @Builder.Default
     private Long tokenVersion = 0L;
 

+ 94 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/UserCertificate.java

@@ -0,0 +1,94 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户证书实体(统一管理各类资质证书)
+ * <p>
+ * 设计理念:
+ * <ul>
+ *   <li>证书的核心价值是<b>图片</b>,用来验证用户真实性和资质</li>
+ *   <li>所有证书类型共用同一张表,通过 certificate_type 区分</li>
+ *   <li>额外信息(编号、有效期等)通过 OCR 识别或人工审核时补充到 ocr_data JSON 字段</li>
+ *   <li>每个用户每种证书类型最多一条记录(uk_user_cert_type)</li>
+ * </ul>
+ * <p>
+ * 支持的类型:
+ * <ul>
+ *   <li>BUSINESS_LICENSE — 营业执照</li>
+ *   <li>PHARMACY_LICENSE — 药品经营许可证</li>
+ *   <li>MEDICAL_DEVICE_FILING — 医疗器械备案凭证</li>
+ * </ul>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("t_user_certificate")
+public class UserCertificate {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    @TableField("user_id")
+    private Long userId;
+
+    /** 证书类型:BUSINESS_LICENSE / PHARMACY_LICENSE / MEDICAL_DEVICE_FILING */
+    @TableField("certificate_type")
+    private String certificateType;
+
+    /** 证书图片URL(核心字段) */
+    @TableField("image_url")
+    private String imageUrl;
+
+    /** OCR识别结果或人工审核填写的结构化信息(JSON,可为NULL) */
+    @TableField("ocr_data")
+    private String ocrData;
+
+    /** 审核状态:PENDING-待审核, APPROVED-已通过, REJECTED-已驳回 */
+    @TableField("review_status")
+    private String reviewStatus;
+
+    /** 审核人ID */
+    @TableField("reviewer_id")
+    private Long reviewerId;
+
+    /** 审核时间 */
+    @TableField("review_time")
+    private LocalDateTime reviewTime;
+
+    /** 驳回原因 */
+    @TableField("reject_reason")
+    private String rejectReason;
+
+    /** 来源:MANUAL-手动上传, MINIAPP_SYNC-外部系统同步 */
+    @TableField("sync_source")
+    private String syncSource;
+
+    /** 外部系统记录ID */
+    @TableField("external_id")
+    private String externalId;
+
+    /** 备注 */
+    @TableField("remark")
+    private String remark;
+
+    /** 创建时间 */
+    @TableField(value = "create_time", fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 12 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/CertificateSyncLogMapper.java

@@ -0,0 +1,12 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.entity.CertificateSyncLog;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 证书同步日志 Mapper
+ */
+@Mapper
+public interface CertificateSyncLogMapper extends BaseMapper<CertificateSyncLog> {
+}

+ 4 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/CrawlerUsageLogMapper.java

@@ -62,4 +62,8 @@ public interface CrawlerUsageLogMapper extends BaseMapper<CrawlerUsageLog> {
             "WHERE user_id = #{userId} " +
             "GROUP BY platform")
     List<Map<String, Object>> sumTotalUsageByPlatform(@Param("userId") Long userId);
+
+    /** 统计用户总使用次数 */
+    @Select("SELECT COALESCE(SUM(usage_count), 0) FROM t_crawler_usage_log WHERE user_id = #{userId}")
+    int sumTotalUsage(@Param("userId") Long userId);
 }

+ 15 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/ProcurementLedgerMapper.java

@@ -0,0 +1,15 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.user.entity.ProcurementLedger;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface ProcurementLedgerMapper extends BaseMapper<ProcurementLedger> {
+
+    @Select("SELECT * FROM t_procurement_ledger WHERE user_id = #{userId} ORDER BY created_at DESC")
+    Page<ProcurementLedger> selectPageByUserId(Page<ProcurementLedger> page, @Param("userId") Long userId);
+}

+ 25 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/PurchaseIntentMapper.java

@@ -0,0 +1,25 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.user.entity.PurchaseIntent;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDate;
+
+/**
+ * 跳转购买记录 Mapper
+ */
+@Mapper
+public interface PurchaseIntentMapper extends BaseMapper<PurchaseIntent> {
+
+    /** 分页查询用户跳转购买记录(最近优先) */
+    @Select("SELECT * FROM t_purchase_intent WHERE user_id = #{userId} ORDER BY clicked_at DESC")
+    Page<PurchaseIntent> selectPageByUserId(Page<PurchaseIntent> page, @Param("userId") Long userId);
+
+    /** 统计用户今日跳转购买次数 */
+    @Select("SELECT COUNT(*) FROM t_purchase_intent WHERE user_id = #{userId} AND DATE(clicked_at) = #{today}")
+    int countTodayByUserId(@Param("userId") Long userId, @Param("today") LocalDate today);
+}

+ 7 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/SearchRecordMapper.java

@@ -21,4 +21,11 @@ public interface SearchRecordMapper extends BaseMapper<SearchRecord> {
             "FROM t_search_record WHERE user_id = #{userId} " +
             "ORDER BY searched_at DESC")
     Page<SearchRecord> selectSummaryPage(Page<SearchRecord> page, @Param("userId") Long userId);
+
+    /** 查询用户最近 N 条搜索记录摘要 */
+    @Select("SELECT id, user_id, drug_name, searched_at, status_text, " +
+            "platform_count, quote_count, create_time " +
+            "FROM t_search_record WHERE user_id = #{userId} " +
+            "ORDER BY searched_at DESC LIMIT #{limit}")
+    java.util.List<SearchRecord> selectRecentByUserId(@Param("userId") Long userId, @Param("limit") int limit);
 }

+ 23 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/UserCertificateMapper.java

@@ -0,0 +1,23 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.entity.UserCertificate;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 用户证书 Mapper
+ */
+@Mapper
+public interface UserCertificateMapper extends BaseMapper<UserCertificate> {
+
+    /** 根据用户ID和证书类型查询(每种类型最多一条) */
+    @Select("SELECT * FROM t_user_certificate WHERE user_id = #{userId} AND certificate_type = #{type} LIMIT 1")
+    UserCertificate selectByUserIdAndType(Long userId, String type);
+
+    /** 根据用户ID查询所有证书 */
+    @Select("SELECT * FROM t_user_certificate WHERE user_id = #{userId}")
+    List<UserCertificate> selectByUserId(Long userId);
+}

+ 23 - 21
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -93,6 +93,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                     Long userId = claims.get("userId", Long.class);
                     Long tokenVer = getTokenVersionFromClaims(claims);
                     String role = claims.get("role", String.class);
+                    String loginSource = claims.get("loginSource", String.class);
                     boolean isAdmin = "ADMIN".equals(role) || "SUPER_ADMIN".equals(role);
 
                     if (userId != null) {
@@ -101,8 +102,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
                     if ("access".equals(tokenType)) {
                         request.setAttribute("userId", userId);
+                        request.setAttribute("loginSource", loginSource);
 
-                        if (!isTokenVersionValid(userId, isAdmin, tokenVer, role)) {
+                        if (!isTokenVersionValid(userId, loginSource, tokenVer, role)) {
                             log.info("{} {} 的Token版本号不匹配(已在其他设备登录),拒绝访问",
                                     isAdmin ? "管理员" : "用户", userId);
                             writeKickedResponse(response);
@@ -148,44 +150,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
     }
 
     /**
-     * 校验 Token 版本号是否有效(单设备/多设备登录控制)
+     * 校验 Token 版本号是否有效(分端设备登录控制)
      * <p>
-     * 管理员(ADMIN / SUPER_ADMIN)允许最多 5 台设备同时登录:
-     * 接受 [currentVersion-4, currentVersion] 范围内的版本号(最近 5 次登录)。
-     * 普通用户(USER)保持单设备登录,精确匹配。
+     * 管理员:最多 5 台(范围匹配)。
      * <p>
-     * 优先从内存缓存读取,缓存未命中时查 DB 并回填缓存。
+     * 普通用户:
+     * <ul>
+     *   <li>PC 端 / 旧 token(loginSource 为空或非 MINIAPP):精确匹配,严格单设备</li>
+     *   <li>小程序端(loginSource=MINIAPP):范围匹配 [current-1, current],与 PC 共存</li>
+     * </ul>
      */
-    private boolean isTokenVersionValid(Long id, boolean isAdmin, Long tokenVersion, String role) {
-        if (id == null) {
-            return false;
-        }
-        // 从缓存获取当前版本号
+    private boolean isTokenVersionValid(Long id, String loginSource,
+                                        Long tokenVersion, String role) {
+        if (id == null) return false;
+        long tokenVer = tokenVersion != null ? tokenVersion : 0L;
+        boolean isAdmin = "ADMIN".equals(role) || "SUPER_ADMIN".equals(role);
+
         Long currentVersion = tokenVersionCache.getVersion(id, isAdmin);
         if (currentVersion == null) {
-            // 缓存未命中:按 userType 分流查不同表
             if (isAdmin) {
                 com.xuekairui.user.entity.Admin admin = adminMapper.selectById(id);
-                if (admin == null) {
-                    return false;
-                }
+                if (admin == null) return false;
                 currentVersion = admin.getTokenVersion() != null ? admin.getTokenVersion() : 0L;
             } else {
                 User user = userMapper.selectById(id);
-                if (user == null) {
-                    return false;
-                }
+                if (user == null) return false;
                 currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
             }
             tokenVersionCache.putVersion(id, isAdmin, currentVersion);
         }
 
-        long tokenVer = tokenVersion != null ? tokenVersion : 0L;
+        if (isAdmin) return tokenVer <= currentVersion && (currentVersion - tokenVer) < 5;
 
-        if ("ADMIN".equals(role) || "SUPER_ADMIN".equals(role)) {
-            return tokenVer <= currentVersion && (currentVersion - tokenVer) < 5;
+        // 小程序端:范围匹配,最多 2 台,不踢 PC
+        if ("MINIAPP".equals(loginSource)) {
+            return tokenVer <= currentVersion && (currentVersion - tokenVer) < 2;
         }
 
+        // PC 端 / 旧 token(loginSource 为空):精确匹配,严格单设备(向前兼容)
         return currentVersion.equals(tokenVer);
     }
 

+ 2 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java

@@ -52,6 +52,8 @@ public class TokenVersionCache {
         }
     }
 
+
+
     @Scheduled(fixedRate = 3600000)
     public void reportStats() {
         CacheStats stats = cache.stats();

+ 512 - 28
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -40,6 +40,14 @@ public class AuthService {
     private final MembershipService membershipService;
     private final AdminAuthService adminAuthService;
     private final com.xuekairui.user.security.TokenVersionCache tokenVersionCache;
+    private final WechatMiniProgramService wechatMiniProgramService;
+    private final ExternalUserSyncService externalUserSyncService;
+    private final CertificateSyncService certificateSyncService;
+    private final com.xuekairui.user.mapper.BusinessLicenseMapper businessLicenseMapper;
+    private final com.xuekairui.user.mapper.PlatformAccountMapper platformAccountMapper;
+    private final com.xuekairui.user.mapper.CrawlerUsageLogMapper crawlerUsageLogMapper;
+    private final com.xuekairui.user.mapper.SearchRecordMapper searchRecordMapper;
+    private final com.xuekairui.user.mapper.PurchaseIntentMapper purchaseIntentMapper;
 
     /**
      * 发送验证码
@@ -232,32 +240,67 @@ public class AuthService {
      * 不递增 tokenVersion(同一设备续期),使用 JWT 中已有信息签发新 Token。
      * 过滤层已校验 tokenVersion 和用户存在性,这里直接签发。
      */
-    public LoginResponse heartbeat(Long userId, String role) {
+    /**
+     * 外部系统换取小程序 Token(供小程序后台按手机号签发)
+     * <p>查找或自动注册用户,不递增 tokenVersion,不踢 PC。
+     */
+    @Transactional
+    public LoginResponse externalMiniAppToken(ExternalTokenRequest request, String ip) {
+        User user = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
+
+        boolean isNewUser = false;
+        if (user == null) {
+            isNewUser = true;
+            user = User.builder()
+                    .phone(request.getPhone())
+                    .nickname("用户" + request.getPhone().substring(request.getPhone().length() - 4))
+                    .membershipLevel(MembershipLevel.PLUS)
+                    .loginSource("MINIAPP")
+                    .status(1)
+                    .build();
+            userMapper.insert(user);
+            membershipService.initPlusMembership(user.getId());
+            if (request.getInviteCode() != null) {
+                eventPublisher.publishEvent(
+                        new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
+            }
+        }
+
+        user.setLoginSource("MINIAPP");
+        user.setLastLoginTime(LocalDateTime.now());
+        user.setLastLoginIp(ip);
+        userMapper.updateById(user);
+
+        LoginResponse response = buildMiniAppLoginResponse(user);
+        response.setNewUser(isNewUser);
+
+        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+                isNewUser ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
+                "USER:" + user.getId(), user.getId());
+        return response;
+    }
+
+    public LoginResponse heartbeat(Long userId, String role, String loginSource) {
         String roleCode = role != null ? role : "USER";
-        // 从缓存获取 tokenVersion(过滤器大概率已缓存),缓存 miss 时回退 DB
         Long currentVersion = tokenVersionCache.getVersion(userId, false);
         if (currentVersion == null) {
             User user = userMapper.selectById(userId);
             currentVersion = user != null && user.getTokenVersion() != null
                     ? user.getTokenVersion() : 0L;
-            if (user != null) {
-                tokenVersionCache.putVersion(userId, false, currentVersion);
-            }
+            if (user != null) tokenVersionCache.putVersion(userId, false, currentVersion);
         }
 
-        // 签发新 Token(无需查DB,JWT中已有所有必要信息)
         String newAccessToken = jwtUtil.generateAccessToken(
-                userId, roleCode, currentVersion);
+                userId, roleCode, loginSource, currentVersion);
         String newRefreshToken = jwtUtil.generateRefreshToken(userId);
 
-        // 仅一次 blind UPDATE 更新 Refresh Token 哈希
         User updateUser = new User();
         updateUser.setId(userId);
         updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
         updateUser.setRefreshTokenStatus("ACTIVE");
         userMapper.updateById(updateUser);
 
-        log.debug("用户 {} 心跳续期成功(零查询)", userId);
         return LoginResponse.builder()
                 .userId(userId)
                 .accessToken(newAccessToken)
@@ -279,6 +322,201 @@ public class AuthService {
         return convertToUserInfoResponse(user);
     }
 
+    /**
+     * 获取聚合用户信息(小程序"我的"页面)— 新增,不影响 /api/auth/user/info
+     * <p>返回 user + gate(门禁)+ quota(配额)+ platformBindings(平台绑定)。
+     */
+    public java.util.Map<String, Object> getUserMe(Long userId) {
+        UserInfoResponse userInfo = getUserInfo(userId);
+        UserGateStatus gate = buildGateStatus(userId);
+        java.util.Map<String, Object> quota = buildQuotaSummary(userId);
+        java.util.List<?> platforms = getPlatformBindings(userId);
+
+        java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
+        result.putAll(toMap(userInfo));
+        result.put("gate", gate);
+        result.put("quota", quota);
+        result.put("platformBindings", platforms);
+        return result;
+    }
+
+    /**
+     * 首页概览统计 — 新增
+     */
+    public DashboardResponse getDashboard(Long userId) {
+        // 今日查询次数
+        int todayQueryCount = crawlerUsageLogMapper.sumTodayUsage(userId);
+
+        // 累计查询次数
+        int totalQueryCount = crawlerUsageLogMapper.sumTotalUsage(userId);
+
+        // 最近查询摘要(最多 3 条)
+        java.util.List<DashboardResponse.RecentQueryItem> recent =
+                searchRecordMapper.selectRecentByUserId(userId, 3)
+                        .stream().map(r -> DashboardResponse.RecentQueryItem.builder()
+                                .drugName(r.getDrugName())
+                                .searchedAt(r.getSearchedAt() != null ? r.getSearchedAt().toString() : null)
+                                .lowestPrice(r.getQuoteCount() > 0 ? "有报价" : null)
+                                .build())
+                        .toList();
+
+        return DashboardResponse.builder()
+                .todayQueryCount(todayQueryCount)
+                .todayPurchaseIntentCount(
+                        purchaseIntentMapper.countTodayByUserId(userId, java.time.LocalDate.now()))
+                .totalQueryCount(totalQueryCount)
+                .recentQueries(recent)
+                .build();
+    }
+
+    /**
+     * 登出 — 新增,清除 refresh token
+     */
+    @Transactional
+    public void logout(Long userId) {
+        User user = new User();
+        user.setId(userId);
+        user.setRefreshTokenHash(null);
+        user.setRefreshTokenStatus(null);
+        userMapper.updateById(user);
+        log.info("用户 {} 已登出", userId);
+    }
+
+    /**
+     * 独立注册(小程序填写药店信息后调用) — 新增
+     */
+    @Transactional
+    public LoginResponse register(RegisterRequest request, String ip) {
+        // 1. 验证临时 Token
+        io.jsonwebtoken.Claims claims = jwtUtil.parseToken(request.getTempToken());
+        if (!"temp".equals(claims.get("type", String.class))) {
+            throw new BusinessException(ErrorCode.TOKEN_INVALID);
+        }
+        String openId = claims.get("openId", String.class);
+        String unionId = claims.get("unionId", String.class);
+
+        // 2. 查找小程序用户(由 miniappUnifiedLogin 创建的未完成注册的用户)
+        User user = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, openId));
+        if (user == null) {
+            // fallback: try unionId
+            if (unionId != null) {
+                user = userMapper.selectOne(
+                        new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, unionId));
+            }
+        }
+        if (user == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND, "请先通过小程序授权登录");
+        }
+
+        // 3. 补全药店信息
+        user.setPharmacyName(request.getPharmacyName());
+        user.setProvince(request.getProvince());
+        user.setCity(request.getCity());
+        user.setDistrict(request.getDistrict());
+        user.setLastLoginTime(LocalDateTime.now());
+        user.setLastLoginIp(ip);
+        user.setLoginSource("MINIAPP");
+        userMapper.updateById(user);
+
+        // 4. 发布注册事件(处理邀请码)
+        if (request.getInviteCode() != null) {
+            eventPublisher.publishEvent(
+                    new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
+        }
+
+        // 5. 签发正式 Token
+        LoginResponse response = buildLoginResponse(user);
+        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+                OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
+        return response;
+    }
+
+    // ====================================
+    // 聚合查询辅助方法
+    // ====================================
+
+    private UserGateStatus buildGateStatus(Long userId) {
+        // 执照审核状态
+        com.xuekairui.user.entity.BusinessLicense license =
+                businessLicenseMapper.selectByUserId(userId);
+        String licenseStatus = license != null ? license.getReviewStatus() : "NONE";
+
+        // 已绑定平台数
+        Long boundCount = platformAccountMapper.selectCount(
+                new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
+                        .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId)
+                        .eq(com.xuekairui.user.entity.PlatformAccount::getEnabled, true));
+
+        boolean canQuery = "APPROVED".equals(licenseStatus) && boundCount >= 1;
+
+        java.util.List<String> messages = new java.util.ArrayList<>();
+        if (!"APPROVED".equals(licenseStatus)) {
+            messages.add("请先上传营业执照并通过审核");
+        }
+        if (boundCount == 0) {
+            messages.add("请至少绑定1个B2B平台账号");
+        }
+
+        return UserGateStatus.builder()
+                .canQueryOnDesktop(canQuery)
+                .licenseStatus(licenseStatus)
+                .platformBoundCount(boundCount.intValue())
+                .gateMessages(messages)
+                .build();
+    }
+
+    private java.util.Map<String, Object> buildQuotaSummary(Long userId) {
+        // 今日已用 + 每日上限
+        int dailyUsed = crawlerUsageLogMapper.sumTodayUsage(userId);
+        java.util.Map<String, Object> quota = new java.util.LinkedHashMap<>();
+        quota.put("dailyQueryUsed", dailyUsed);
+        quota.put("dailyQueryLimit", 20); // TODO: 从会员等级动态获取
+        return quota;
+    }
+
+    private java.util.List<java.util.Map<String, Object>> getPlatformBindings(Long userId) {
+        java.util.List<com.xuekairui.user.entity.PlatformAccount> accounts =
+                platformAccountMapper.selectList(
+                        new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
+                                .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId));
+
+        return accounts.stream().map(a -> {
+            java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
+            m.put("platformCode", a.getPlatformCode());
+            m.put("platformName", a.getPlatformName());
+            m.put("status", Boolean.TRUE.equals(a.getEnabled()) ? "bound" : "disabled");
+            m.put("lastVerifyTime", a.getLastVerifyTime());
+            return m;
+        }).toList();
+    }
+
+    @SuppressWarnings("unchecked")
+    private java.util.Map<String, Object> toMap(UserInfoResponse info) {
+        // 简单反射转 Map,避免引入额外依赖
+        java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
+        map.put("id", info.getId());
+        map.put("phone", info.getPhone());
+        map.put("nickname", info.getNickname());
+        map.put("avatar", info.getAvatar());
+        map.put("wechatBound", info.getWechatBound());
+        map.put("createTime", info.getCreateTime());
+        map.put("loginSource", info.getLoginSource());
+        map.put("pharmacyName", info.getPharmacyName());
+        map.put("province", info.getProvince());
+        map.put("city", info.getCity());
+        map.put("district", info.getDistrict());
+        map.put("role", info.getRole());
+        map.put("membershipLevel", info.getMembershipLevel());
+        map.put("membershipLevelName", info.getMembershipLevelName());
+        map.put("inviterId", info.getInviterId());
+        map.put("inviterNickname", info.getInviterNickname());
+        map.put("hasBoundInviteCode", info.getHasBoundInviteCode());
+        map.put("levelCode", info.getLevelCode());
+        map.put("version", info.getVersion());
+        return map;
+    }
+
     /**
      * Refresh Token 旋转 + 重用检测
      * <p>
@@ -355,33 +593,39 @@ public class AuthService {
                 .build();
     }
 
-    /**
-     * 构建登录响应(递增 tokenVersion + 存储 Refresh Token 哈希用于旋转检测)
-     */
+    /** PC 端构建登录响应:
+     * 递增 tokenVersion,踢掉旧 PC */
     private LoginResponse buildLoginResponse(User user) {
-        // 登录/注册时注入 MDC uid,不依赖 token 解析
-        MDC.put("uid", String.valueOf(user.getId()));
-
-        String roleCode = "USER";
+        return buildLoginResponseInternal(user, true);
+    }
 
-        // 检测是否存在旧会话
-        long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
-        boolean hadPreviousSession = oldVersion > 0;
+    /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC */
+    private LoginResponse buildMiniAppLoginResponse(User user) {
+        return buildLoginResponseInternal(user, false);
+    }
 
-        // 递增 tokenVersion,使所有旧 Token 立即失效
-        long newVersion = oldVersion + 1;
-        user.setTokenVersion(newVersion);
+    private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion) {
+        MDC.put("uid", String.valueOf(user.getId()));
+        String roleCode = "USER";
+        String loginSource = user.getLoginSource() != null ? user.getLoginSource() : "UNKNOWN";
+
+        long version;
+        if (incrementVersion) {
+            long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
+            version = oldVersion + 1;
+            user.setTokenVersion(version);
+            tokenVersionCache.putVersion(user.getId(), false, version);
+        } else {
+            version = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
+        }
 
-        // 生成 Token
         String accessToken = jwtUtil.generateAccessToken(
-                user.getId(), "USER", newVersion);
+                user.getId(), "USER", loginSource, version);
         String refreshToken = jwtUtil.generateRefreshToken(user.getId());
 
-        // 存储 Refresh Token 哈希(用于旋转+重用检测)
         user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
         user.setRefreshTokenStatus("ACTIVE");
         userMapper.updateById(user);
-        tokenVersionCache.putVersion(user.getId(), false, newVersion);
 
         return LoginResponse.builder()
                 .userId(user.getId())
@@ -392,8 +636,6 @@ public class AuthService {
                 .needBindPhone(false)
                 .newUser(false)
                 .role(roleCode)
-                .kickedPreviousSession(hadPreviousSession)
-                .kickedDeviceInfo(hadPreviousSession ? user.getLastLoginDevice() : null)
                 .build();
     }
 
@@ -554,4 +796,246 @@ public class AuthService {
         auditLogService.logSuccess(userId, user.getNickname(), "USER",
                 OperationType.USER_UPDATE, "PHONE:" + userId, userId);
     }
+
+    // ====================================
+    // 小程序统一登录(处理四种用户身份场景)
+    // ====================================
+
+    /**
+     * 小程序统一登录
+     * <p>
+     * 自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景:
+     * <ol>
+     *   <li>外部老 + 本地老 → 直接登录</li>
+     *   <li>外部老 + 本地新 → 本地注册 + 异步同步证书</li>
+     *   <li>外部新 + 本地老 → 异步注册外部 + 本地直接登录</li>
+     *   <li>外部新 + 本地新 → 两端都注册</li>
+     * </ol>
+     * <p>
+     * 关键原则:外部系统操作异步执行,不阻塞登录响应。
+     */
+    @Transactional
+    public LoginResponse miniappUnifiedLogin(MiniProgramLoginRequest request, String ip) {
+        // 1. code2Session
+        WechatMiniProgramService.MiniProgramSessionInfo session =
+                wechatMiniProgramService.code2Session(request.getJsCode());
+
+        // 2. 获取手机号(如果提供了 phoneCode)
+        String phone = null;
+        if (request.getPhoneCode() != null && !request.getPhoneCode().isBlank()) {
+            try {
+                phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode());
+            } catch (Exception e) {
+                log.warn("获取小程序手机号失败(不阻断登录): {}", e.getMessage());
+            }
+        }
+
+        // 3. 并行检查双系统
+        User user = findUserByWechatIdentity(session.getUnionId(), session.getOpenId());
+        boolean existsExternal = checkExternalUserSafe(session.getUnionId());
+        boolean isNewLocal = (user == null);
+
+        log.info("小程序统一登录: unionId={}, isNewLocal={}, existsExternal={}",
+                session.getUnionId(), isNewLocal, existsExternal);
+
+        // 4. 场景路由
+        if (!isNewLocal && existsExternal) {
+            // 场景1: 两端都有 → 更新微信信息,直接登录
+            updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip);
+        } else if (isNewLocal && existsExternal) {
+            // 场景2: 外部老 + 本地新 → 本地注册 + 异步同步证书
+            user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip);
+            final Long newUserId = user.getId();
+            final String syncUnionId = session.getUnionId();
+            try {
+                certificateSyncService.syncCertificatesFromExternal(newUserId, syncUnionId);
+            } catch (Exception e) {
+                log.warn("触发证书同步失败(不阻断登录): userId={}", newUserId, e);
+            }
+        } else if (!isNewLocal) {
+            // 场景3: 外部新 + 本地老 → 更新微信信息,异步注册外部
+            updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip);
+            asyncRegisterInExternal(user, session.getOpenId());
+        } else {
+            // 场景4: 两端都新 → 本地注册 + 异步注册外部
+            user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip);
+            asyncRegisterInExternal(user, session.getOpenId());
+        }
+
+        // 5. 没有手机号 → 返回需要绑定
+        if (user.getPhone() == null) {
+            String tempToken = jwtUtil.generateTempToken(session.getOpenId(), session.getUnionId());
+            MDC.put("uid", String.valueOf(user.getId()));
+            return LoginResponse.builder()
+                    .needBindPhone(true)
+                    .tempToken(tempToken)
+                    .userInfo(convertToUserInfoResponse(user))
+                    .build();
+        }
+
+        // 6. 更新登录信息并签发 Token
+        user.setLastLoginTime(LocalDateTime.now());
+        user.setLastLoginIp(ip);
+        user.setLoginSource("MINIAPP");
+        userMapper.updateById(user);
+
+        LoginResponse response = buildMiniAppLoginResponse(user);
+        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+                isNewLocal ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
+                "USER:" + user.getId(), user.getId());
+        return response;
+    }
+
+    /**
+     * 小程序绑定手机号(needBindPhone=true 时调用)
+     */
+    @Transactional
+    public LoginResponse miniappBindPhone(MiniProgramPhoneRequest request, String ip) {
+        // 1. 验证临时 Token
+        Claims claims = jwtUtil.parseToken(request.getTempToken());
+        if (!"temp".equals(claims.get("type", String.class))) {
+            throw new BusinessException(ErrorCode.TOKEN_INVALID);
+        }
+        String openId = claims.get("openId", String.class);
+        String unionId = claims.get("unionId", String.class);
+
+        // 2. 获取手机号
+        String phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode());
+
+        // 3. 查找小程序用户
+        User wechatUser = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, openId));
+        if (wechatUser == null) {
+            throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED);
+        }
+
+        // 4. 检查手机号是否已被其他用户绑定
+        User existUser = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
+
+        if (existUser != null && !existUser.getId().equals(wechatUser.getId())) {
+            // 手机号已被其他账号绑定 → 合并账号
+            existUser.setWechatMiniOpenId(openId);
+            if (unionId != null) {
+                existUser.setWechatUnionId(unionId);
+            }
+            existUser.setLastLoginTime(LocalDateTime.now());
+            existUser.setLastLoginIp(ip);
+            userMapper.updateById(existUser);
+            userMapper.deleteById(wechatUser.getId());
+            wechatUser = existUser;
+        } else if (existUser == null) {
+            // 绑定手机号(完成注册)
+            wechatUser.setPhone(phone);
+            wechatUser.setLoginSource("MINIAPP");
+            wechatUser.setLastLoginTime(LocalDateTime.now());
+            wechatUser.setLastLoginIp(ip);
+            userMapper.updateById(wechatUser);
+            eventPublisher.publishEvent(new UserRegisteredEvent(
+                    this, wechatUser.getId(), request.getInviteCode()));
+        }
+
+        return buildMiniAppLoginResponse(wechatUser);
+    }
+
+    // ====================================
+    // 小程序登录辅助方法
+    // ====================================
+
+    /** 通过微信身份标识查找用户(unionId 优先,fallback miniOpenId) */
+    private User findUserByWechatIdentity(String unionId, String miniOpenId) {
+        if (unionId != null) {
+            User user = userMapper.selectOne(
+                    new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, unionId));
+            if (user != null) {
+                return user;
+            }
+        }
+        if (miniOpenId != null) {
+            return userMapper.selectOne(
+                    new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, miniOpenId));
+        }
+        return null;
+    }
+
+    /** 安全检查外部用户(不可用时降级为"外部新用户",不阻塞流程) */
+    private boolean checkExternalUserSafe(String unionId) {
+        try {
+            return externalUserSyncService.userExists(unionId);
+        } catch (Exception e) {
+            log.warn("检查外部用户失败(降级为外部新用户): unionId={}", unionId, e);
+            return false;
+        }
+    }
+
+    /** 从小程序注册新用户 */
+    private User createUserFromMiniProgram(
+            WechatMiniProgramService.MiniProgramSessionInfo session,
+            String phone, String inviteCode, String ip) {
+        User user = User.builder()
+                .wechatMiniOpenId(session.getOpenId())
+                .wechatUnionId(session.getUnionId())
+                .phone(phone)
+                .nickname("药店用户")
+                .membershipLevel(MembershipLevel.PLUS)
+                .loginSource("MINIAPP")
+                .lastLoginTime(LocalDateTime.now())
+                .lastLoginIp(ip)
+                .status(1)
+                .build();
+        userMapper.insert(user);
+        membershipService.initPlusMembership(user.getId());
+        if (inviteCode != null) {
+            eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), inviteCode));
+        }
+        log.info("小程序新用户注册: userId={}, unionId={}", user.getId(), session.getUnionId());
+        return user;
+    }
+
+    /** 更新已有用户的微信小程序信息 */
+    private void updateWechatMiniInfo(User user, String miniOpenId, String unionId,
+                                      String phone, String ip) {
+        boolean changed = false;
+        if (miniOpenId != null && !miniOpenId.equals(user.getWechatMiniOpenId())) {
+            user.setWechatMiniOpenId(miniOpenId);
+            changed = true;
+        }
+        if (unionId != null && !unionId.equals(user.getWechatUnionId())) {
+            user.setWechatUnionId(unionId);
+            changed = true;
+        }
+        if (phone != null && user.getPhone() == null) {
+            user.setPhone(phone);
+            changed = true;
+        }
+        user.setLastLoginTime(LocalDateTime.now());
+        user.setLastLoginIp(ip);
+        user.setLoginSource("MINIAPP");
+        if (changed) {
+            userMapper.updateById(user);
+        }
+    }
+
+    /** 异步在外部系统注册用户(不阻塞主流程) */
+    private void asyncRegisterInExternal(User user, String miniProgramOpenId) {
+        try {
+            ExternalUserSyncService.ExternalUserInfo extInfo =
+                    new ExternalUserSyncService.ExternalUserInfo();
+            extInfo.setMiniProgramOpenId(miniProgramOpenId);
+            extInfo.setUnionId(user.getWechatUnionId());
+            extInfo.setPhone(user.getPhone());
+            extInfo.setNickname(user.getNickname());
+            extInfo.setAvatar(user.getAvatar());
+
+            String extUserId = externalUserSyncService.registerUser(extInfo);
+            if (extUserId != null) {
+                user.setExternalUserId(extUserId);
+                user.setExternalSyncTime(LocalDateTime.now());
+                userMapper.updateById(user);
+                log.info("外部系统注册成功: userId={}, extUserId={}", user.getId(), extUserId);
+            }
+        } catch (Exception e) {
+            log.warn("外部系统注册失败(不阻断主流程): userId={}", user.getId(), e);
+        }
+    }
 }

+ 141 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/CertificateSyncService.java

@@ -0,0 +1,141 @@
+package com.xuekairui.user.service;
+
+import cn.hutool.json.JSONUtil;
+import com.xuekairui.user.entity.CertificateSyncLog;
+import com.xuekairui.user.entity.UserCertificate;
+import com.xuekairui.user.mapper.CertificateSyncLogMapper;
+import com.xuekairui.user.mapper.UserCertificateMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 证书同步服务
+ * <p>
+ * 负责从外部系统拉取证书数据并写入统一的 t_user_certificate 表。
+ * 异步执行,不阻塞登录主流程。
+ * <p>
+ * 同步策略:
+ * <ul>
+ *   <li>MANUAL(手动上传)来源的证书优先保留,不会被 SYNC 覆盖</li>
+ *   <li>MINIAPP_SYNC 来源的证书可被新的同步数据覆盖更新</li>
+ *   <li>每次同步记录日志到 t_certificate_sync_log</li>
+ * </ul>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CertificateSyncService {
+
+    private final ExternalUserSyncService externalUserSyncService;
+    private final UserCertificateMapper userCertificateMapper;
+    private final CertificateSyncLogMapper syncLogMapper;
+
+    /**
+     * 从外部系统异步同步所有证书数据
+     *
+     * @param userId  本地系统用户ID
+     * @param unionId 微信 UnionID(用于外部系统查询)
+     */
+    @Async
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void syncCertificatesFromExternal(Long userId, String unionId) {
+        if (unionId == null) {
+            log.info("用户 {} 无 UnionID,跳过证书同步", userId);
+            return;
+        }
+        log.info("开始异步同步证书: userId={}, unionId={}", userId, unionId);
+
+        try {
+            List<ExternalUserSyncService.CertificateData> certs =
+                    externalUserSyncService.getCertificates(unionId);
+
+            if (certs.isEmpty()) {
+                log.info("用户 {} 在外部系统无证书数据", userId);
+                return;
+            }
+
+            for (ExternalUserSyncService.CertificateData cert : certs) {
+                syncSingleCertificate(userId, cert);
+            }
+
+            log.info("证书同步完成: userId={}, 共同步 {} 条", userId, certs.size());
+        } catch (Exception e) {
+            log.error("证书同步异常: userId={}", userId, e);
+        }
+    }
+
+    /**
+     * 同步单条证书到统一证书表
+     */
+    private void syncSingleCertificate(Long userId, ExternalUserSyncService.CertificateData cert) {
+        String certType = cert.getType();
+        CertificateSyncLog syncLog = CertificateSyncLog.builder()
+                .userId(userId)
+                .syncType(certType)
+                .syncStatus("PENDING")
+                .build();
+        syncLogMapper.insert(syncLog);
+
+        try {
+            // 检查是否已存在
+            UserCertificate existing = userCertificateMapper.selectByUserIdAndType(userId, certType);
+            if (existing != null && !"MINIAPP_SYNC".equals(existing.getSyncSource())) {
+                syncLog.setSyncStatus("SKIPPED");
+                syncLog.setErrorMessage("已存在手动上传的" + certType + ",跳过同步");
+                syncLogMapper.updateById(syncLog);
+                return;
+            }
+
+            // 构建/更新证书记录
+            UserCertificate entity = (existing != null) ? existing : UserCertificate.builder()
+                    .userId(userId)
+                    .certificateType(certType)
+                    .reviewStatus("PENDING")
+                    .syncSource("MINIAPP_SYNC")
+                    .build();
+
+            Map<String, Object> data = cert.getData();
+            entity.setImageUrl(getString(data, "imageUrl"));
+            entity.setExternalId(cert.getExternalId());
+            // 外部系统返回的额外信息存入 ocr_data JSON 字段
+            entity.setOcrData(JSONUtil.toJsonStr(data));
+
+            if (existing != null) {
+                userCertificateMapper.updateById(entity);
+            } else {
+                userCertificateMapper.insert(entity);
+            }
+
+            syncLog.setSyncStatus("SUCCESS");
+            syncLog.setTargetRecordId(entity.getId());
+            syncLogMapper.updateById(syncLog);
+
+            log.info("证书同步成功: userId={}, type={}, certId={}", userId, certType, entity.getId());
+        } catch (Exception e) {
+            syncLog.setSyncStatus("FAILED");
+            syncLog.setErrorMessage(truncate(e.getMessage(), 1000));
+            syncLogMapper.updateById(syncLog);
+            log.error("同步证书失败: userId={}, type={}, error={}", userId, certType, e.getMessage());
+        }
+    }
+
+    private String getString(Map<String, Object> data, String key) {
+        Object value = data.get(key);
+        return value != null ? value.toString() : null;
+    }
+
+    private String truncate(String text, int maxLen) {
+        if (text == null) return null;
+        return text.length() > maxLen ? text.substring(0, maxLen) : text;
+    }
+}

+ 79 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/ExternalUserSyncService.java

@@ -0,0 +1,79 @@
+package com.xuekairui.user.service;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 外部用户系统(小程序后台)同步接口
+ * <p>
+ * 用于与外部小程序后台系统进行用户数据同步。
+ * 默认提供基于 HTTP 的实现({@link com.xuekairui.user.service.impl.ExternalUserSyncServiceImpl}),
+ * 后续可替换为 RPC、MQ 等方式。
+ * <p>
+ * 设计原则:
+ * <ul>
+ *   <li>所有方法应具备容错能力,外部系统不可用时不应阻塞本地登录流程</li>
+ *   <li>返回值使用 Optional 语义(null 表示不存在或查询失败)</li>
+ * </ul>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+public interface ExternalUserSyncService {
+
+    /**
+     * 检查用户是否存在于外部系统
+     *
+     * @param unionId 微信 UnionID
+     * @return true=存在, false=不存在或外部系统不可用
+     */
+    boolean userExists(String unionId);
+
+    /**
+     * 在外部系统注册新用户
+     *
+     * @param userInfo 用户信息
+     * @return 外部系统用户ID,失败返回 null
+     */
+    String registerUser(ExternalUserInfo userInfo);
+
+    /**
+     * 从外部系统拉取用户证书数据
+     *
+     * @param unionId 微信 UnionID
+     * @return 证书数据列表,无证书或失败返回空列表
+     */
+    List<CertificateData> getCertificates(String unionId);
+
+    /**
+     * 外部系统用户信息
+     */
+    @Data
+    class ExternalUserInfo {
+        /** 微信 UnionID */
+        private String unionId;
+        /** 小程序 OpenID */
+        private String miniProgramOpenId;
+        /** 手机号 */
+        private String phone;
+        /** 昵称 */
+        private String nickname;
+        /** 头像 */
+        private String avatar;
+    }
+
+    /**
+     * 外部系统证书数据
+     */
+    @Data
+    class CertificateData {
+        /** 证书类型:BUSINESS_LICENSE / PHARMACY_LICENSE / MEDICAL_DEVICE_FILING */
+        private String type;
+        /** 外部系统记录ID */
+        private String externalId;
+        /** 证书字段数据(key 为字段名,value 为字段值) */
+        private Map<String, Object> data;
+    }
+}

+ 186 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/WechatMiniProgramService.java

@@ -0,0 +1,186 @@
+package com.xuekairui.user.service;
+
+import cn.hutool.http.HttpUtil;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 微信小程序服务
+ * <p>
+ * 独立于 {@link WechatService}(网站应用扫码登录),专门处理小程序相关的微信 API:
+ * <ul>
+ *   <li>jscode2session — 用 js_code 换取 openId + unionId + session_key</li>
+ *   <li>getAccessToken — 获取小程序全局 access_token(Redis 缓存 7200s)</li>
+ *   <li>getPhoneNumber — 通过手机号授权码换取手机号(新版接口)</li>
+ * </ul>
+ * <p>
+ * 文档: <a href="https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html">code2Session</a>
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WechatMiniProgramService {
+
+    @Value("${wechat.miniapp.app-id:}")
+    private String appId;
+
+    @Value("${wechat.miniapp.app-secret:}")
+    private String appSecret;
+
+    private final RedisTemplate<String, Object> redisTemplate;
+
+    /** 微信接口地址 */
+    private static final String JSCODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
+    private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
+    private static final String PHONE_NUMBER_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
+
+    /** Redis 缓存 key */
+    private static final String MINIAPP_ACCESS_TOKEN_KEY = "wechat:miniapp:access_token";
+    /** access_token 提前过期时间(秒),避免边界情况 */
+    private static final long TOKEN_EXPIRE_BUFFER = 300;
+
+    /**
+     * jscode2session:用小程序 js_code 换取会话信息
+     *
+     * @param jsCode wx.login() 返回的 code
+     * @return 小程序会话信息(openId, unionId, sessionKey)
+     */
+    public MiniProgramSessionInfo code2Session(String jsCode) {
+        String url = String.format(
+                "%s?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
+                JSCODE2SESSION_URL, appId, appSecret, jsCode);
+
+        try {
+            String response = HttpUtil.get(url, 5000);
+            JSONObject json = JSONUtil.parseObj(response);
+
+            if (json.containsKey("errcode") && json.getInt("errcode") != 0) {
+                log.error("小程序 jscode2session 失败: errcode={}, errmsg={}",
+                        json.getInt("errcode"), json.getStr("errmsg"));
+                throw new BusinessException(ErrorCode.WECHAT_MINIAPP_CODE_INVALID);
+            }
+
+            MiniProgramSessionInfo info = new MiniProgramSessionInfo();
+            info.setOpenId(json.getStr("openid"));
+            info.setUnionId(json.getStr("unionid"));
+            info.setSessionKey(json.getStr("session_key"));
+
+            log.info("小程序 jscode2session 成功: openId={}, unionId={}",
+                    info.getOpenId(), info.getUnionId());
+            return info;
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("调用小程序 jscode2session 异常", e);
+            throw new BusinessException(ErrorCode.WECHAT_MINIAPP_CODE_INVALID);
+        }
+    }
+
+    /**
+     * 获取小程序全局 access_token(Redis 缓存,提前 5 分钟过期)
+     * <p>
+     * 注意:此 access_token 是小程序级别的,与用户授权获取的 access_token 是不同的。
+     */
+    public String getAccessToken() {
+        // 1. 尝试从 Redis 缓存读取
+        Object cached = redisTemplate.opsForValue().get(MINIAPP_ACCESS_TOKEN_KEY);
+        if (cached != null) {
+            return cached.toString();
+        }
+
+        // 2. 缓存未命中,调微信 API 获取
+        String url = String.format(
+                "%s?grant_type=client_credential&appid=%s&secret=%s",
+                ACCESS_TOKEN_URL, appId, appSecret);
+
+        try {
+            String response = HttpUtil.get(url, 5000);
+            JSONObject json = JSONUtil.parseObj(response);
+
+            if (json.containsKey("errcode") && json.getInt("errcode") != 0) {
+                log.error("获取小程序 access_token 失败: {}", response);
+                throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED, "获取小程序凭证失败");
+            }
+
+            String accessToken = json.getStr("access_token");
+            int expiresIn = json.getInt("expires_in", 7200);
+            long cacheSeconds = Math.max(1, expiresIn - TOKEN_EXPIRE_BUFFER);
+
+            // 3. 写入 Redis 缓存
+            redisTemplate.opsForValue().set(MINIAPP_ACCESS_TOKEN_KEY, accessToken,
+                    cacheSeconds, TimeUnit.SECONDS);
+
+            log.info("小程序 access_token 已刷新,缓存 {} 秒", cacheSeconds);
+            return accessToken;
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("获取小程序 access_token 异常", e);
+            throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED, "获取小程序凭证失败");
+        }
+    }
+
+    /**
+     * 通过手机号授权码获取手机号(新版接口)
+     * <p>
+     * 前端通过 &lt;button open-type="getPhoneNumber"&gt; 获取 phoneCode,
+     * 后端使用小程序全局 access_token 调用此接口换取真实手机号。
+     *
+     * @param code getPhoneNumber 按钮返回的动态令牌
+     * @return 手机号(纯数字,不含国家代码)
+     */
+    public String getPhoneNumber(String code) {
+        String accessToken = getAccessToken();
+        String url = PHONE_NUMBER_URL + "?access_token=" + accessToken;
+
+        Map<String, String> body = Map.of("code", code);
+
+        try {
+            String response = HttpUtil.post(url, JSONUtil.toJsonStr(body), 5000);
+            JSONObject json = JSONUtil.parseObj(response);
+
+            int errcode = json.getInt("errcode", 0);
+            if (errcode != 0) {
+                log.error("获取小程序手机号失败: errcode={}, errmsg={}",
+                        errcode, json.getStr("errmsg"));
+                throw new BusinessException(ErrorCode.WECHAT_MINIAPP_PHONE_FAILED);
+            }
+
+            String phoneNumber = json.getByPath("phone_info.phoneNumber", String.class);
+            log.info("获取小程序手机号成功: phone={}", phoneNumber);
+            return phoneNumber;
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("调用手机号接口异常", e);
+            throw new BusinessException(ErrorCode.WECHAT_MINIAPP_PHONE_FAILED);
+        }
+    }
+
+    /**
+     * 小程序会话信息
+     */
+    @Data
+    public static class MiniProgramSessionInfo {
+        /** 小程序 OpenID */
+        private String openId;
+        /** 开放平台 UnionID(需绑定开放平台,否则为空) */
+        private String unionId;
+        /** 会话密钥 */
+        private String sessionKey;
+    }
+}

+ 146 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/impl/ExternalUserSyncServiceImpl.java

@@ -0,0 +1,146 @@
+package com.xuekairui.user.service.impl;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.xuekairui.user.service.ExternalUserSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 外部用户系统 HTTP 实现(基于 Hutool HttpUtil,与项目现有风格一致)
+ * <p>
+ * 通过 HTTP API 与外部小程序后台系统通信。当前实现为骨架代码,
+ * 实际 API 路径需根据外部系统的接口文档配置。
+ * <p>
+ * 容错策略:所有外部调用失败时返回安全默认值(false/null/空列表),不抛异常阻塞主流程。
+ *
+ * @author ProPrice Team
+ * @since 2026-07-06
+ */
+@Slf4j
+@Service
+public class ExternalUserSyncServiceImpl implements ExternalUserSyncService {
+
+    @Value("${external-system.miniapp.base-url:}")
+    private String baseUrl;
+
+    @Value("${external-system.miniapp.api-key:}")
+    private String apiKey;
+
+    @Value("${external-system.miniapp.connect-timeout:5000}")
+    private int connectTimeout;
+
+    @Value("${external-system.miniapp.read-timeout:10000}")
+    private int readTimeout;
+
+    @Override
+    public boolean userExists(String unionId) {
+        if (isNotConfigured() || unionId == null) {
+            return false;
+        }
+        try {
+            String url = baseUrl + "/api/user/exists?unionId=" + unionId;
+            String response = doGet(url);
+            if (response != null) {
+                JSONObject json = JSONUtil.parseObj(response);
+                return json.getBool("exists", false);
+            }
+        } catch (Exception e) {
+            log.warn("检查外部用户是否存在失败: unionId={}, error={}", unionId, e.getMessage());
+        }
+        return false;
+    }
+
+    @Override
+    public String registerUser(ExternalUserInfo userInfo) {
+        if (isNotConfigured() || userInfo == null) {
+            return null;
+        }
+        try {
+            String url = baseUrl + "/api/user/register";
+            String body = JSONUtil.toJsonStr(userInfo);
+            String response = doPost(url, body);
+            if (response != null) {
+                JSONObject json = JSONUtil.parseObj(response);
+                Object userId = json.get("userId");
+                return userId != null ? userId.toString() : null;
+            }
+        } catch (Exception e) {
+            log.warn("外部系统注册用户失败: unionId={}, error={}",
+                    userInfo.getUnionId(), e.getMessage());
+        }
+        return null;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public List<CertificateData> getCertificates(String unionId) {
+        if (isNotConfigured() || unionId == null) {
+            return Collections.emptyList();
+        }
+        try {
+            String url = baseUrl + "/api/user/certificates?unionId=" + unionId;
+            String response = doGet(url);
+            if (response != null) {
+                JSONObject json = JSONUtil.parseObj(response);
+                Object certsData = json.get("certificates");
+                if (certsData instanceof List) {
+                    List<Map<String, Object>> rawList = (List<Map<String, Object>>) certsData;
+                    return rawList.stream().map(raw -> {
+                        CertificateData cert = new CertificateData();
+                        cert.setType((String) raw.get("type"));
+                        cert.setExternalId((String) raw.get("externalId"));
+                        cert.setData(raw);
+                        return cert;
+                    }).toList();
+                }
+            }
+        } catch (Exception e) {
+            log.warn("拉取外部系统证书失败: unionId={}, error={}", unionId, e.getMessage());
+        }
+        return Collections.emptyList();
+    }
+
+    private String doGet(String url) {
+        try (HttpResponse response = HttpRequest.get(url)
+                .header("X-Api-Key", apiKey)
+                .timeout(connectTimeout + readTimeout)
+                .execute()) {
+            if (response.isOk()) {
+                return response.body();
+            }
+            log.warn("外部API GET {} 返回: {}", url, response.getStatus());
+        }
+        return null;
+    }
+
+    private String doPost(String url, String body) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("X-Api-Key", apiKey)
+                .header("Content-Type", "application/json")
+                .body(body)
+                .timeout(connectTimeout + readTimeout)
+                .execute()) {
+            if (response.isOk()) {
+                return response.body();
+            }
+            log.warn("外部API POST {} 返回: {}", url, response.getStatus());
+        }
+        return null;
+    }
+
+    private boolean isNotConfigured() {
+        if (baseUrl == null || baseUrl.isBlank()) {
+            log.debug("外部系统 base-url 未配置,跳过外部调用");
+            return true;
+        }
+        return false;
+    }
+}

+ 9 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

@@ -46,17 +46,18 @@ public class JwtUtil {
     }
 
     /**
-     * 生成访问令牌(含 tokenVersion 用于单设备登录控制)
+     * 生成访问令牌(含 loginSource + tokenVersion 用于分端登录控制)
      * <p>
-     * 管理员 12 小时过期,普通用户 1 小时过期,共用同一密钥签名
+     * 管理员 12 小时过期,普通用户 1 小时过期。
      */
-    public String generateAccessToken(Long userId, String role, Long tokenVersion) {
+    public String generateAccessToken(Long userId, String role, String loginSource, Long tokenVersion) {
         long expirationMs = isAdminRole(role) ? adminExpiration : userExpiration;
 
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
         claims.put("role", role != null ? role : "USER");
         claims.put("type", "access");
+        claims.put("loginSource", loginSource != null ? loginSource : "UNKNOWN");
         claims.put("tokenVersion", tokenVersion != null ? tokenVersion : 0L);
 
         return Jwts.builder()
@@ -68,6 +69,11 @@ public class JwtUtil {
                 .compact();
     }
 
+    /** 兼容旧调用(默认 UNKNOWN → PC 端精确匹配) */
+    public String generateAccessToken(Long userId, String role, Long tokenVersion) {
+        return generateAccessToken(userId, role, "UNKNOWN", tokenVersion);
+    }
+
     /**
      * 生成刷新令牌
      */