Răsfoiți Sursa

审计日志调整

liuchengsen 1 lună în urmă
părinte
comite
6f8b62aafe
23 a modificat fișierele cu 1337 adăugiri și 160 ștergeri
  1. 170 3
      docs/03-接口文档.md
  2. 358 0
      docs/小程序支付接口文档.md
  3. 18 3
      docs/第三方登录接口文档.md
  4. 3 2
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java
  5. 19 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java
  6. 24 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentController.java
  7. 0 3
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/CreateOrderRequest.java
  8. 0 3
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/SwitchChannelRequest.java
  9. 206 20
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java
  10. 10 4
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentRefundService.java
  11. 29 4
      zhijiayun-test/src/main/resources/static/activity-test.html
  12. 225 6
      zhijiayun-test/src/main/resources/static/invite-test.html
  13. 12 5
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/PlatformAccountController.java
  14. 6 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java
  15. 4 3
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/OperationAuditLog.java
  16. 42 0
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/AuditResult.java
  17. 51 38
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperationType.java
  18. 51 0
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperatorRole.java
  19. 18 9
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  20. 24 11
      zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java
  21. 5 4
      zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java
  22. 3 17
      zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java
  23. 59 25
      zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

+ 170 - 3
docs/03-接口文档.md

@@ -2064,9 +2064,9 @@ POST /api/payment/order/create
 | 字段 | 类型 | 必填 | 说明 |
 |------|------|------|------|
 | planId | Long | 是 | 支付方案ID |
-| channel | String | 是 | 支付渠道:WECHAT/ALIPAY |
+| channel | String | 是 | 支付渠道:WECHAT/ALIPAY/WECHAT_MINIAPP |
 
-**响应:**
+**响应(WECHAT / ALIPAY — 扫码支付,需展示二维码):**
 ```json
 {
   "code": 200,
@@ -2078,6 +2078,7 @@ POST /api/payment/order/create
     "status": "PENDING",
     "qrCodeBase64": "data:image/png;base64,...",
     "payUrl": "weixin://wxpay/bizpayurl?pr=xxx",
+    "miniAppPayParams": null,
     "expireTime": "2026-06-30T22:00:00",
     "expireHint": "请在15分钟内完成付款,超时订单将自动过期",
     "createTime": "2026-06-30T21:45:00"
@@ -2085,6 +2086,166 @@ POST /api/payment/order/create
 }
 ```
 
+**响应(WECHAT_MINIAPP — 小程序支付,直接调起微信支付):**
+```json
+{
+  "code": 200,
+  "data": {
+    "orderNo": "OP1234567890123456",
+    "planName": "高级会员·月卡",
+    "amount": 29.90,
+    "channel": "WECHAT_MINIAPP",
+    "status": "PENDING",
+    "qrCodeBase64": null,
+    "payUrl": null,
+    "miniAppPayParams": {
+      "appId": "wxabc123...",
+      "timeStamp": "1752060000",
+      "nonceStr": "a1b2c3d4e5f6...",
+      "package": "prepay_id=wx1234567890...",
+      "signType": "RSA",
+      "paySign": "XXXX..."
+    },
+    "expireTime": "2026-06-30T22:00:00",
+    "expireHint": "请在15分钟内完成付款,超时订单将自动过期",
+    "createTime": "2026-06-30T21:45:00"
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---------|------|------|
+| orderNo | String | 订单号 |
+| planName | String | 方案名称 |
+| amount | BigDecimal | 金额(元) |
+| channel | String | 支付渠道 |
+| status | String | PENDING/PAID/EXPIRED |
+| qrCodeBase64 | String | 二维码Base64图片(WECHAT/ALIPAY 才有) |
+| payUrl | String | 支付链接/码串(WECHAT/ALIPAY 才有) |
+| miniAppPayParams | Object | 小程序支付参数,含签名(WECHAT_MINIAPP 才有) |
+| expireTime | String | 过期时间 |
+| expireHint | String | 过期提示 |
+| createTime | String | 创建时间 |
+
+> **关键区分**:`qrCodeBase64` / `payUrl` 与 `miniAppPayParams` 互斥。扫码支付返回前者,小程序支付返回后者。
+
+---
+
+### 13.2+ 微信小程序支付(完整指引)
+
+> ⚠️ **前端必读**:小程序支付与扫码支付流程完全不同,不是展示二维码,而是**一键调起微信支付**。
+
+**与扫码支付的关键差异:**
+
+| | WECHAT / ALIPAY(扫码) | WECHAT_MINIAPP(小程序) |
+|---|---|---|
+| 用户操作 | 展示二维码 → 扫码付款 | **直接弹出微信支付面板** |
+| 返回凭证 | `qrCodeBase64`(Base64图片) | `miniAppPayParams`(签名对象) |
+| openId | 不需要 | 自动从用户记录读取(登录时已存储) |
+| 下单接口 | `TradeTypeEnum.NATIVE` | `TradeTypeEnum.JSAPI` |
+
+**完整前端调用流程:**
+
+```javascript
+// ──── 步骤 1:用户登录(已有流程,无需改动)────
+// 小程序端调用 wx.login() + 后端 miniappUnifiedLogin
+// → 后端 code2Session 获取 openId 并存入 t_user.wechat_mini_open_id
+// → 返回 JWT token(后续请求携带)
+
+// ──── 步骤 2:创建小程序支付订单 ────
+const res = await request({
+  url: '/api/payment/order/create',
+  method: 'POST',
+  header: { Authorization: `Bearer ${token}` },
+  data: {
+    planId: 1,
+    channel: 'WECHAT_MINIAPP'     // ⚠️ 不传 openid,后端从用户记录读取
+  }
+})
+
+// ──── 步骤 3:调起微信支付面板 ────
+const params = res.data.miniAppPayParams
+wx.requestPayment({
+  timeStamp: params.timeStamp,
+  nonceStr:  params.nonceStr,
+  package:   params.package,       // "prepay_id=wx..."
+  signType:  params.signType,      // "RSA"
+  paySign:   params.paySign,
+  success(res) {
+    // 支付成功 → 查询订单确认状态
+    queryOrderStatus(orderNo)
+  },
+  fail(err) {
+    // 用户取消或支付失败
+    console.log('支付取消', err)
+  }
+})
+```
+
+**前置条件(重要):**
+
+1. ⚠️ 用户必须已通过小程序登录,`t_user.wechat_mini_open_id` 不为空
+2. 如果用户未登录小程序,调用创建订单会返回错误:`"未获取到小程序 openId,请先通过小程序登录"`
+3. `openId` 在登录时通过 `code2Session` 获取并持久化,支付时从数据库直接读取,**不需要重复 wx.login 授权**
+
+**miniAppPayParams 字段说明:**
+
+| 字段 | 说明 | 对应 wx.requestPayment 参数 |
+|------|------|------|
+| appId | 小程序AppID | ❌ 不传(wx.requestPayment不需要) |
+| timeStamp | 时间戳(秒) | ✅ timeStamp |
+| nonceStr | 随机字符串(32位hex) | ✅ nonceStr |
+| package | 格式 `prepay_id=xxx` | ✅ package |
+| signType | 固定 `RSA` | ✅ signType |
+| paySign | RSA-SHA256签名 | ✅ paySign |
+
+**切换渠道注意:**
+
+```javascript
+// 如果从 WECHAT_MINIAPP 切换到 WECHAT 扫码:
+// → 返回 qrCodeBase64(展示二维码让用户扫码)
+// 如果从 WECHAT 切换到 WECHAT_MINIAPP:
+// → 返回 miniAppPayParams(直接调起支付面板)
+// → 同样是 POST /api/payment/order/{orderNo}/switch-channel,只传 channel
+```
+
+**配置要求(后端):**
+
+```yaml
+# application.yml
+wechat:
+  miniapp:
+    app-id: wxMINIAPP123...    # 小程序appId(登录用)
+    app-secret: secret...        # 小程序appSecret(登录用)
+
+payment:
+  miniapp:
+    app-id: wxMINIAPP123...    # 支付签名用appId(必须与小程序的相同,或独立配置)
+  wechat:
+    app-id: wxOFFICIAL...      # Native扫码用appId(公众号/网站应用,可与上面不同)
+    mch-id: 1234567890          # 商户号(Native + JSAPI 共用)
+    # ... 密钥、证书等
+```
+
+**错误场景:**
+
+| 错误信息 | 原因 | 解决 |
+|---|---|---|
+| `未获取到小程序 openId` | 用户未通过小程序登录 | 先调用小程序登录接口 |
+| `用户不存在` | userId 无效 | 检查 JWT token |
+| `微信支付未配置` | payment.wechat 参数缺失 | 检查配置文件 |
+| `微信小程序支付需要提供用户 openid` | 内部异常 | 联系后端(正常情况下不会出现) |
+
+**完整 API 清单:**
+
+| 接口 | 方法 | 认证 | 说明 |
+|---|---|---|---|
+| `/api/payment/plans` | GET | 否 | 获取支付方案列表 |
+| `/api/payment/order/create` | POST | 是 | 创建订单(channel=WECHAT_MINIAPP) |
+| `/api/payment/order/{orderNo}/switch-channel` | POST | 是 | 切换渠道 |
+| `/api/payment/order/{orderNo}` | GET | 是 | 查询订单状态 |
+| `/api/payment/orders` | GET | 是 | 我的订单列表 |
+
 ---
 
 ### 13.3 切换支付渠道
@@ -2095,10 +2256,16 @@ POST /api/payment/order/{orderNo}/switch-channel
 
 **需要认证**
 
-**业务场景:** 用户创建订单后,可在订单详情页切换为其他支付渠道,后端根据新渠道重新调用预下单接口生成二维码。
+**业务场景:** 用户创建订单后,可在订单详情页切换为其他支付渠道,后端根据新渠道重新调用预下单接口生成二维码/小程序支付参数
 
 > **重要**:不关闭原渠道订单,允许用户在多个渠道间切换。以第一个支付成功渠道为准,后续支付记录到重复支付表并自动退款。
 
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| channel | String | 是 | 新支付渠道:WECHAT/ALIPAY/WECHAT_MINIAPP |
+
+> 小程序切渠道时同样不传 `openid`,后端自动从用户记录读取。
+
 **请求体:**
 ```json
 {

+ 358 - 0
docs/小程序支付接口文档.md

@@ -0,0 +1,358 @@
+# 智价云(药店版) - 微信小程序支付接口文档
+
+> **适用对象**:小程序前端开发  
+> **版本**:v1.0 | 更新日期:2026-07-09  
+> **Base URL**:`http://localhost:8080`(开发环境)
+
+---
+
+## 1. 概述
+
+小程序支付使用微信 **JSAPI 支付**,与扫码支付(NATIVE)流程完全不同:不是展示二维码,而是**一键调起微信支付面板**。
+
+| | WECHAT / ALIPAY(扫码) | WECHAT_MINIAPP(小程序) |
+|---|---|---|
+| 用户操作 | 展示二维码 → 扫码付款 | **直接弹出微信支付面板** |
+| 返回凭证 | `qrCodeBase64`(Base64图片) | `miniAppPayParams`(签名对象) |
+| openId | 不需要 | 自动从用户记录读取(登录时已存储) |
+| 下单接口 | `TradeTypeEnum.NATIVE` | `TradeTypeEnum.JSAPI` |
+
+---
+
+## 2. 前置条件
+
+1. ⚠️ **用户必须已通过小程序登录**,`t_user.wechat_mini_open_id` 不为空
+2. 如果用户未登录小程序,调用创建订单会返回错误:`"未获取到小程序 openId,请先通过小程序登录"`
+3. `openId` 在登录时通过 `code2Session` 获取并持久化,支付时从数据库直接读取,**不需要重复 wx.login 授权**
+
+---
+
+## 3. 完整前端调用流程
+
+```javascript
+// ──── 步骤 1:用户登录(已有流程,无需改动)────
+// 小程序端调用 wx.login() + 后端 miniappUnifiedLogin
+// → 后端 code2Session 获取 openId 并存入 t_user.wechat_mini_open_id
+// → 返回 JWT token(后续请求携带)
+
+// ──── 步骤 2:创建小程序支付订单 ────
+const res = await request({
+  url: '/api/payment/order/create',
+  method: 'POST',
+  header: { Authorization: `Bearer ${token}` },
+  data: {
+    planId: 1,
+    channel: 'WECHAT_MINIAPP'     // ⚠️ 不传 openid,后端从用户记录读取
+  }
+})
+
+// ──── 步骤 3:调起微信支付面板 ────
+const params = res.data.miniAppPayParams
+wx.requestPayment({
+  timeStamp: params.timeStamp,
+  nonceStr:  params.nonceStr,
+  package:   params.package,       // "prepay_id=wx..."
+  signType:  params.signType,      // "RSA"
+  paySign:   params.paySign,
+  success(res) {
+    // 支付成功 → 查询订单确认状态
+    queryOrderStatus(orderNo)
+  },
+  fail(err) {
+    // 用户取消或支付失败
+    console.log('支付取消', err)
+  }
+})
+```
+
+---
+
+## 4. 接口详情
+
+### 4.1 获取支付方案列表(公开)
+
+```
+GET /api/payment/plans
+```
+
+**无需认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": [
+    {
+      "id": 1,
+      "planCode": "MONTHLY_PRO",
+      "planName": "高级会员·月卡",
+      "membershipLevel": "PRO",
+      "price": 29.90,
+      "originalPrice": 59.90,
+      "durationDays": 30,
+      "sortOrder": 3,
+      "status": 1,
+      "description": "每天不限量· 1个月29.9(原价59.9)"
+    }
+  ]
+}
+```
+
+---
+
+### 4.2 创建支付订单
+
+```
+POST /api/payment/order/create
+```
+
+**需要认证**(Header: `Authorization: Bearer {token}`)
+
+**请求体:**
+```json
+{
+  "planId": 1,
+  "channel": "WECHAT_MINIAPP"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| planId | Long | 是 | 支付方案ID(从 4.1 接口获取) |
+| channel | String | 是 | 支付渠道,小程序固定传 `WECHAT_MINIAPP` |
+
+> ⚠️ **不需要传 `openid`**,后端自动从当前登录用户的 `t_user.wechat_mini_open_id` 读取。
+
+**响应(WECHAT_MINIAPP — 小程序支付):**
+```json
+{
+  "code": 200,
+  "data": {
+    "orderNo": "OP1234567890123456",
+    "planName": "高级会员·月卡",
+    "amount": 29.90,
+    "channel": "WECHAT_MINIAPP",
+    "status": "PENDING",
+    "qrCodeBase64": null,
+    "payUrl": null,
+    "miniAppPayParams": {
+      "appId": "wxabc123...",
+      "timeStamp": "1752060000",
+      "nonceStr": "a1b2c3d4e5f6...",
+      "package": "prepay_id=wx1234567890...",
+      "signType": "RSA",
+      "paySign": "XXXX..."
+    },
+    "expireTime": "2026-06-30T22:00:00",
+    "expireHint": "请在15分钟内完成付款,超时订单将自动过期",
+    "createTime": "2026-06-30T21:45:00"
+  }
+}
+```
+
+**响应字段说明:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| orderNo | String | 订单号(后续查询/切换渠道用) |
+| planName | String | 方案名称 |
+| amount | BigDecimal | 金额(元) |
+| channel | String | 支付渠道 |
+| status | String | PENDING-待支付 / PAID-已支付 / EXPIRED-已过期 |
+| qrCodeBase64 | String | 小程序支付为 null |
+| payUrl | String | 小程序支付为 null |
+| **miniAppPayParams** | Object | **小程序支付参数,用于调起微信支付** |
+| expireTime | String | 过期时间(创建后15分钟) |
+| expireHint | String | 过期提示文案 |
+| createTime | String | 创建时间 |
+
+#### miniAppPayParams 字段 → wx.requestPayment 参数映射
+
+| 字段 | 说明 | wx.requestPayment 参数 |
+|------|------|------------------------|
+| appId | 小程序AppID | ❌ **不传**(wx.requestPayment 不需要) |
+| timeStamp | 时间戳(秒,字符串) | ✅ `timeStamp` |
+| nonceStr | 随机字符串(32位hex) | ✅ `nonceStr` |
+| package | 格式 `prepay_id=xxx` | ✅ `package` |
+| signType | 固定 `RSA` | ✅ `signType` |
+| paySign | RSA-SHA256 签名 | ✅ `paySign` |
+
+---
+
+### 4.3 切换支付渠道
+
+```
+POST /api/payment/order/{orderNo}/switch-channel
+```
+
+**需要认证**
+
+**业务场景:** 用户创建订单后,如需从扫码支付切换为小程序支付(或反之),调用此接口。
+
+> 不关闭原渠道订单,以第一个支付成功渠道为准,后续重复支付自动退款。
+
+**请求体:**
+```json
+{
+  "channel": "WECHAT_MINIAPP"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| channel | String | 是 | 新支付渠道:WECHAT / ALIPAY / WECHAT_MINIAPP |
+
+> 同样不传 `openid`,后端自动从用户记录读取。
+
+**响应:** 同 4.2 创建订单(根据新渠道返回对应格式)
+
+**切换渠道示例:**
+```javascript
+// 从 WECHAT_MINIAPP 切换到 WECHAT 扫码:
+// → 返回 qrCodeBase64(展示二维码让用户扫码)
+// 从 WECHAT 切换到 WECHAT_MINIAPP:
+// → 返回 miniAppPayParams(直接调起支付面板)
+```
+
+---
+
+### 4.4 查询订单状态
+
+```
+GET /api/payment/order/{orderNo}
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "orderNo": "OP1234567890123456",
+    "planName": "高级会员·月卡",
+    "amount": 29.90,
+    "channel": "WECHAT_MINIAPP",
+    "status": "PAID",
+    "qrCodeBase64": null,
+    "payUrl": null,
+    "expireTime": "2026-06-30T22:00:00",
+    "expireHint": null,
+    "createTime": "2026-06-30T21:45:00"
+  }
+}
+```
+
+> 支付成功后 `status` 变为 `PAID`,可跳转至支付成功页。
+
+---
+
+### 4.5 我的订单列表
+
+```
+GET /api/payment/orders
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": [
+    {
+      "orderNo": "OP1234567890123456",
+      "planName": "高级会员·月卡",
+      "amount": 29.90,
+      "channel": "WECHAT_MINIAPP",
+      "status": "PAID",
+      "createTime": "2026-06-30T21:45:00"
+    }
+  ]
+}
+```
+
+---
+
+## 5. 错误场景
+
+| 错误信息 | 原因 | 解决方案 |
+|----------|------|----------|
+| `未获取到小程序 openId` | 用户未通过小程序登录 | 先调用小程序登录接口 |
+| `用户不存在` | userId 无效 | 检查 JWT token 是否有效 |
+| `微信支付未配置` | payment.wechat 参数缺失 | 联系后端检查配置 |
+| `支付方案不存在` | planId 无效 | 检查 planId 是否正确 |
+| `支付方案已下架` | 方案已停用 | 提示用户该方案已下架 |
+| `支付订单已过期` | 超过15分钟未支付 | 重新创建订单 |
+
+---
+
+## 6. 支付模块错误码
+
+| code | 说明 |
+|------|------|
+| 2001 | 支付订单不存在 |
+| 2002 | 支付订单已过期 |
+| 2003 | 支付金额不正确 |
+| 2004 | 不支持该支付渠道 |
+| 2005 | 支付失败 |
+| 2006 | 退款失败,请重试 |
+| 2007 | 支付方案不存在 |
+| 2008 | 支付方案已下架 |
+| 2010 | 支付渠道未配置 |
+
+---
+
+## 7. 后端配置要求
+
+以下为后端配置,供联调参考:
+
+```yaml
+# application.yml
+wechat:
+  miniapp:
+    app-id: wxMINIAPP123...    # 小程序appId(登录用)
+    app-secret: secret...        # 小程序appSecret(登录用)
+
+payment:
+  miniapp:
+    app-id: wxMINIAPP123...    # 支付签名用appId(必须与小程序一致)
+  wechat:
+    app-id: wxOFFICIAL...      # Native扫码用appId(可与上面不同)
+    mch-id: 1234567890          # 商户号(Native + JSAPI 共用)
+    # ... 密钥、证书等
+```
+
+---
+
+## 8. 完整 API 清单
+
+| 接口 | 方法 | 认证 | 说明 |
+|------|------|------|------|
+| `/api/payment/plans` | GET | 否 | 获取支付方案列表 |
+| `/api/payment/order/create` | POST | 是 | 创建订单(channel=WECHAT_MINIAPP) |
+| `/api/payment/order/{orderNo}/switch-channel` | POST | 是 | 切换支付渠道 |
+| `/api/payment/order/{orderNo}` | GET | 是 | 查询订单状态 |
+| `/api/payment/orders` | GET | 是 | 我的订单列表 |
+
+---
+
+## 9. 统一响应格式
+
+所有接口返回格式:
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {}
+}
+```
+
+| code | 说明 |
+|------|------|
+| 200 | 成功 |
+| 400 | 参数错误 |
+| 401 | 未认证(Token 无效或过期) |
+| 403 | 无权限 |
+| 500 | 服务器内部错误 |

+ 18 - 3
docs/第三方登录接口文档.md

@@ -1,8 +1,8 @@
 # 第三方登录接口文档
 
-> 版本:V1.4  
+> 版本:V1.5  
 > 更新日期:2026-07-09  
-> 变更说明:响应 userInfo 补全完整返回字段(userName、药店信息、邀请人信息等);修正 nickname 优先级描述(nickname > username)
+> 变更说明:新增 openId、unionId 参数,第三方可传入小程序 openId/unionId 保存到用户表,后续支付等场景无需重复授权
 
 ---
 
@@ -17,6 +17,7 @@
 - 不递增 `tokenVersion`,不踢出 PC 端已有会话
 - 新用户自动初始化 **PLUS 会员(普通会员)**
 - 第三方已审核用户(`licenseApproved=true`)自动赠送 **30 天 PRO 高级会员**
+- 支持传入小程序 `openId` / `unionId`,一次性保存后后续支付等场景直接从用户表读取,**无需重复授权**
 
 ---
 
@@ -79,6 +80,8 @@ X-Api-Key: {我方提供的API Key}
 | `contactPerson` | String | 否 | 联系人 | `contact_person` |
 | `contactPhone` | String | 否 | 联系电话 | `contact_phone` |
 | `businessLicenseNo` | String | 否 | 营业执照号(统一社会信用代码) | `business_license_no` |
+| `openId` | String | 否 | 微信小程序 openId(第三方从小程序 wx.login 获取后传入,保存到 wechat_mini_open_id。传入后支付场景无需重复授权) | `wechat_mini_open_id` |
+| `unionId` | String | 否 | 微信开放平台 unionId(用于跨应用用户统一标识,保存到 wechat_union_id) | `wechat_union_id` |
 
 #### 入驻信息字段(对应 /api/business-license/upload,均为可选,传入后同步写入 t_business_license)
 
@@ -136,6 +139,8 @@ X-Api-Key: {我方提供的API Key}
   "contactPerson": "张三",
   "contactPhone": "13800138000",
   "businessLicenseNo": "91110105MA12345678",
+  "openId": "oJx1x5xxxxxxxxxxxxxxxxx",
+  "unionId": "oABCD1234xxxxxxxxxxxxxx",
   "storeName": "北京同仁堂大药房(朝阳分店)",
   "terminalType": "1",
   "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
@@ -304,6 +309,7 @@ Authorization: Bearer {currentAccessToken}
 | 9 | 传入入驻信息字段后自动创建/更新入驻记录,无需再调用 `/api/business-license/upload` |
 | 10 | `licenseApproved=true` 时入驻状态直接审核通过,首次通过赠送 **30 天 PRO 高级会员** |
 | 11 | 已审核通过的入驻记录(APPROVED)不会因后续调用而降级 |
+| 12 | 传入 `openId`/`unionId` 后,小程序支付无需再次 `wx.login` 授权,后端直接从 `t_user.wechat_mini_open_id` 读取 |
 
 ---
 
@@ -326,6 +332,8 @@ curl -X POST "https://your-domain.com/api/auth/external/miniapp-token" \
     "contactPerson": "张三",
     "contactPhone": "13800138000",
     "businessLicenseNo": "91110105MA12345678",
+    "openId": "oJx1x5xxxxxxxxxxxxxxxxx",
+    "unionId": "oABCD1234xxxxxxxxxxxxxx",
     "storeName": "北京同仁堂大药房(朝阳分店)",
     "terminalType": "1",
     "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
@@ -350,6 +358,8 @@ String json = "{"
     + "\"contactPerson\": \"张三\","
     + "\"contactPhone\": \"13800138000\","
     + "\"businessLicenseNo\": \"91110105MA12345678\","
+    + "\"openId\": \"oJx1x5xxxxxxxxxxxxxxxxx\","
+    + "\"unionId\": \"oABCD1234xxxxxxxxxxxxxx\","
     + "\"storeName\": \"北京同仁堂大药房(朝阳分店)\","
     + "\"terminalType\": \"1\","
     + "\"businessLicenseUrl\": \"https://oss.example.com/yyzz.jpg\","
@@ -391,6 +401,8 @@ data = {
     "contactPerson": "张三",
     "contactPhone": "13800138000",
     "businessLicenseNo": "91110105MA12345678",
+    "openId": "oJx1x5xxxxxxxxxxxxxxxxx",
+    "unionId": "oABCD1234xxxxxxxxxxxxxx",
     "storeName": "北京同仁堂大药房(朝阳分店)",
     "terminalType": "1",
     "businessLicenseUrl": "https://oss.example.com/yyzz.jpg",
@@ -423,6 +435,8 @@ print(response.json())
 | `contactPerson` | `contact_person` | `contactPerson` | 联系人 |
 | `contactPhone` | `contact_phone` | `contactPhone` | 联系电话 |
 | `businessLicenseNo` | `business_license_no` | `businessLicenseNo` | 营业执照号 |
+| `openId` | `wechat_mini_open_id` | `wechatMiniOpenId` | 微信小程序 openId |
+| `unionId` | `wechat_union_id` | `wechatUnionId` | 微信开放平台 unionId |
 
 ### 10.2 入驻信息(写入 t_business_license,全部可选)
 
@@ -459,4 +473,5 @@ print(response.json())
 | V1.1 | 2026-07-07 | 扩展请求参数:支持传入用户名、药店信息等,自动保存到用户表 |
 | V1.2 | 2026-07-09 | 新增入驻信息字段(storeName / terminalType / businessLicenseUrl / drugLicenseUrl / medicalDeviceClass2Url / medicalDeviceClass3Url),传入后同步写入 t_business_license,无需额外调用入驻接口 |
 | V1.3 | 2026-07-09 | 新增 `licenseApproved` 参数支持第三方已审核入驻状态直通;审核通过自动赠送 30 天 PRO 高级会员;已 APPROVED 状态不降级;响应新增 `licenseStatus` 字段 |
-| V1.4 | 2026-07-09 | 响应 userInfo 补全完整返回字段(userName、pharmacyName、province、city、district、inviterId、inviterNickname、hasBoundInviteCode);修正 nickname 优先级描述(nickname > username) |
+| V1.4 | 2026-07-09 | 响应 userInfo 补全完整返回字段(userName、pharmacyName、province、city、district、inviterId、inviterNickname、hasBoundInviteCode);修正 nickname 优先级描述(nickname > username) |
+| V1.5 | 2026-07-09 | 新增 `openId`、`unionId` 参数,第三方传入后保存到 `wechat_mini_open_id` / `wechat_union_id`;后续小程序支付等场景直接从用户表读取,无需重复 wx.login 授权

+ 3 - 2
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java

@@ -17,6 +17,7 @@ import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
 import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.service.MembershipService;
@@ -359,7 +360,7 @@ public class ActivityInviteService implements ActivityInviteGate {
         auditLogService.logSuccess(
                 inviterId,
                 inviterName,
-                "USER",
+                OperatorRole.USER.getCode(),
                 OperationType.INVITE_REWARD_GRANT,
                 "INVITE_REWARD:" + inviteeId,
                 inviterId,
@@ -423,7 +424,7 @@ public class ActivityInviteService implements ActivityInviteGate {
             auditLogService.logSuccess(
                     revokerId,
                     "运营(反作弊)",
-                    "ADMIN",
+                    OperatorRole.ADMIN.getCode(),
                     OperationType.INVITE_REWARD_GRANT,
                     "INVITE_REVOKE:" + inviteeId,
                     record.getInviterId(),

+ 19 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java

@@ -29,6 +29,9 @@ public class PaymentProperties {
     /** 微信支付配置 */
     private Wechat wechat = new Wechat();
 
+    /** 微信小程序支付配置(JSAPI 调起支付,签名用独立 appId,避免与 Native 扫码混淆) */
+    private Miniapp miniapp = new Miniapp();
+
     /** 支付宝配置(网页应用 - 二维码付款) */
     private Alipay alipay = new Alipay();
 
@@ -79,6 +82,22 @@ public class PaymentProperties {
         private String papayPlanDetailId;
     }
 
+    /**
+     * 微信小程序支付配置(JSAPI 调起支付专用)
+     *
+     * <p>小程序 JSAPI 下单签名的 appId 必须为小程序自身的 appId。
+     * 如果 Native 扫码支付用的是公众号/网站应用 appId,
+     * 则需要通过此配置独立指定小程序 appId,避免相互干扰。
+     *
+     * <p>配置项:payment.miniapp.app-id
+     * <p>不配置时自动回退到 payment.wechat.app-id(兼容旧配置)。
+     */
+    @Data
+    public static class Miniapp {
+        /** 微信小程序 appId(JSAPI 下单签名用) */
+        private String appId = "";
+    }
+
     /**
      * 支付宝配置(电脑网站支付 alipay.trade.page.pay)
      * 支持两种模式(公钥模式优先):

+ 24 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentController.java

@@ -4,6 +4,7 @@ import com.xuekairui.common.Result;
 import com.xuekairui.payment.dto.*;
 import com.xuekairui.payment.service.PaymentOrderService;
 import com.xuekairui.payment.service.PaymentPlanService;
+import com.xuekairui.user.service.WechatMiniProgramService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -11,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * 支付控制器(用户端)
@@ -23,6 +25,7 @@ public class PaymentController {
 
     private final PaymentPlanService paymentPlanService;
     private final PaymentOrderService paymentOrderService;
+    private final WechatMiniProgramService wechatMiniProgramService;
 
     /**
      * 获取可用支付方案列表(公开)
@@ -81,4 +84,25 @@ public class PaymentController {
     public Result<PaymentResultResponse> confirmPay(@PathVariable String orderNo) {
         return Result.success(paymentOrderService.confirmPay(orderNo));
     }
+
+    /**
+     * 微信小程序获取 OpenId
+     * <p>
+     * 小程序端调用 wx.login() 获取 jsCode,提交到此接口换取 openId。
+     * 此接口为公开接口,无需登录即可调用。
+     * 获取到的 openId 用于后续创建支付订单时传入 channel=WECHAT_MINIAPP。
+     *
+     * @param body {"jsCode": "wx.login()返回的code"}
+     * @return {"openId": "oxxx..."}
+     */
+    @PostMapping("/wechat/openid")
+    public Result<Map<String, String>> getOpenId(@RequestBody Map<String, String> body) {
+        String jsCode = body.get("jsCode");
+        if (jsCode == null || jsCode.isBlank()) {
+            return Result.error(400, "jsCode 不能为空");
+        }
+        WechatMiniProgramService.MiniProgramSessionInfo session =
+                wechatMiniProgramService.code2Session(jsCode);
+        return Result.success(Map.of("openId", session.getOpenId()));
+    }
 }

+ 0 - 3
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/CreateOrderRequest.java

@@ -16,7 +16,4 @@ public class CreateOrderRequest {
 
     @NotBlank(message = "支付渠道不能为空")
     private String channel;
-
-    /** 微信小程序用户 openid(渠道为 WECHAT_MINIAPP 时必填) */
-    private String openid;
 }

+ 0 - 3
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/SwitchChannelRequest.java

@@ -12,7 +12,4 @@ public class SwitchChannelRequest {
 
     @NotBlank(message = "支付渠道不能为空")
     private String channel;
-
-    /** 微信小程序用户 openid(切换到 WECHAT_MINIAPP 时必填) */
-    private String openid;
 }

+ 206 - 20
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java

@@ -23,10 +23,11 @@ import com.xuekairui.payment.enums.PaymentChannel;
 import com.xuekairui.payment.enums.PaymentStatus;
 import com.xuekairui.payment.mapper.PaymentOrderExtraPaymentMapper;
 import com.xuekairui.payment.mapper.PaymentOrderMapper;
+import com.xuekairui.user.entity.User;
 import com.xuekairui.user.entity.UserMembership;
 import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.service.MembershipService;
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -43,7 +44,6 @@ import java.util.stream.Collectors;
  */
 @Slf4j
 @Service
-@RequiredArgsConstructor
 public class PaymentOrderService {
 
     private final PaymentOrderMapper paymentOrderMapper;
@@ -52,12 +52,37 @@ public class PaymentOrderService {
     private final QRCodeService qrCodeService;
     private final MembershipService membershipService;
     private final PaymentProperties paymentProperties;
-    @Qualifier("alipayClient")
+    private final UserMapper userMapper;
     private final ObjectProvider<AlipayClient> alipayClientProvider;
     private final ObjectProvider<WxPayService> wxPayServiceProvider;
     private final PaymentAgreementService agreementService;
     private final PaymentRefundService refundService;
 
+    public PaymentOrderService(
+            PaymentOrderMapper paymentOrderMapper,
+            PaymentOrderExtraPaymentMapper extraPaymentMapper,
+            PaymentPlanService paymentPlanService,
+            QRCodeService qrCodeService,
+            MembershipService membershipService,
+            PaymentProperties paymentProperties,
+            UserMapper userMapper,
+            @Qualifier("alipayClient") ObjectProvider<AlipayClient> alipayClientProvider,
+            ObjectProvider<WxPayService> wxPayServiceProvider,
+            PaymentAgreementService agreementService,
+            PaymentRefundService refundService) {
+        this.paymentOrderMapper = paymentOrderMapper;
+        this.extraPaymentMapper = extraPaymentMapper;
+        this.paymentPlanService = paymentPlanService;
+        this.qrCodeService = qrCodeService;
+        this.membershipService = membershipService;
+        this.paymentProperties = paymentProperties;
+        this.userMapper = userMapper;
+        this.alipayClientProvider = alipayClientProvider;
+        this.wxPayServiceProvider = wxPayServiceProvider;
+        this.agreementService = agreementService;
+        this.refundService = refundService;
+    }
+
     /**
      * 创建支付订单
      */
@@ -67,7 +92,7 @@ public class PaymentOrderService {
         PaymentChannel channel = PaymentChannel.fromCode(request.getChannel());
         if (channel == null) {
             throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_SUPPORT,
-                    "支持的渠道:WECHAT, ALIPAY");
+                    "支持的渠道:WECHAT, ALIPAY, WECHAT_MINIAPP");
         }
 
         // 获取方案
@@ -80,13 +105,15 @@ public class PaymentOrderService {
         LocalDateTime expireTime = LocalDateTime.now()
                 .plusMinutes(paymentProperties.getOrderExpireMinutes());
 
-        // 生成支付二维码/链接
-        QrCodeResult qrCodeResult = generateQrCode(channel, orderNo, plan.getPrice(), plan.getPlanName(), plan.getDescription());
+        // 生成支付二维码/链接/小程序支付参数
+        QrCodeResult qrCodeResult = generateQrCode(channel, orderNo, plan.getPrice(),
+                plan.getPlanName(), plan.getDescription(), userId);
         String qrContent = qrCodeResult.getQrContent();
         String payUrl = qrCodeResult.getPayUrl();
         String qrCodeBase64 = qrCodeResult.getQrCodeBase64();
+        java.util.Map<String, String> miniAppPayParams = qrCodeResult.getMiniAppPayParams();
 
-        // 保存订单
+        // 保存订单:小程序渠道存储 prepay_id 到 qrCodeUrl 字段
         PaymentOrder order = PaymentOrder.builder()
                 .orderNo(orderNo)
                 .userId(userId)
@@ -94,7 +121,8 @@ public class PaymentOrderService {
                 .amount(plan.getPrice())
                 .channel(request.getChannel())
                 .status(PaymentStatus.PENDING.name())
-                .qrCodeUrl(qrContent)
+                .qrCodeUrl(miniAppPayParams != null
+                        ? miniAppPayParams.get("package") : qrContent)
                 .expireTime(expireTime)
                 .build();
         paymentOrderMapper.insert(order);
@@ -110,6 +138,7 @@ public class PaymentOrderService {
                 .status(order.getStatus())
                 .qrCodeBase64(qrCodeBase64)
                 .payUrl(payUrl)
+                .miniAppPayParams(miniAppPayParams)
                 .expireTime(order.getExpireTime())
                 .expireHint("请在" + paymentProperties.getOrderExpireMinutes() + "分钟内完成付款,超时订单将自动过期")
                 .createTime(order.getCreateTime())
@@ -126,7 +155,7 @@ public class PaymentOrderService {
         PaymentChannel newChannel = PaymentChannel.fromCode(request.getChannel());
         if (newChannel == null) {
             throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_SUPPORT,
-                    "支持的渠道:WECHAT, ALIPAY");
+                    "支持的渠道:WECHAT, ALIPAY, WECHAT_MINIAPP");
         }
 
         PaymentOrder order = paymentOrderMapper.selectByOrderNo(orderNo);
@@ -147,11 +176,13 @@ public class PaymentOrderService {
         // 不关闭原渠道订单:15分钟内允许用户在多个渠道间重复切换,原渠道二维码仍可被扫描支付
         // 以第一个支付成功渠道为准,后续支付记录到 t_payment_order_extra_payment
         QrCodeResult qrCodeResult = generateQrCode(newChannel, orderNo, order.getAmount(),
-                plan.getPlanName(), plan.getDescription());
+                plan.getPlanName(), plan.getDescription(), userId);
 
-        // 更新订单渠道和二维码
+        // 更新订单渠道和凭证:小程序渠道存储 prepay_id,扫码渠道存储 qrContent
         order.setChannel(request.getChannel());
-        order.setQrCodeUrl(qrCodeResult.getQrContent());
+        java.util.Map<String, String> miniAppPayParams = qrCodeResult.getMiniAppPayParams();
+        order.setQrCodeUrl(miniAppPayParams != null
+                ? miniAppPayParams.get("package") : qrCodeResult.getQrContent());
         paymentOrderMapper.updateById(order);
 
         log.info("切换支付渠道: orderNo={} 新渠道={}", orderNo, newChannel.getDisplayName());
@@ -164,6 +195,7 @@ public class PaymentOrderService {
                 .status(order.getStatus())
                 .qrCodeBase64(qrCodeResult.getQrCodeBase64())
                 .payUrl(qrCodeResult.getPayUrl())
+                .miniAppPayParams(miniAppPayParams)
                 .expireTime(order.getExpireTime())
                 .expireHint("请在" + paymentProperties.getOrderExpireMinutes() + "分钟内完成付款,超时订单将自动过期")
                 .createTime(order.getCreateTime())
@@ -405,13 +437,29 @@ public class PaymentOrderService {
         private String qrContent;
         private String payUrl;
         private String qrCodeBase64;
+        /** 微信小程序支付参数(仅 WECHAT_MINIAPP 渠道) */
+        private java.util.Map<String, String> miniAppPayParams;
     }
 
     /**
-     * 统一生成支付二维码
+     * 统一生成支付凭证(二维码或小程序支付参数)
      */
     private QrCodeResult generateQrCode(PaymentChannel channel, String orderNo,
-                                        java.math.BigDecimal amount, String subject, String body) {
+                                        java.math.BigDecimal amount, String subject, String body,
+                                        Long userId) {
+        if (channel == PaymentChannel.WECHAT_MINIAPP) {
+            // 微信小程序支付:从用户记录读取已存储的 openId(登录时通过 code2Session 获取并持久化)
+            String openid = getMiniAppOpenId(userId);
+            java.util.Map<String, String> miniAppParams = createWechatJsapiPrepay(
+                    orderNo, amount, subject, body, openid);
+            return QrCodeResult.builder()
+                    .qrContent(null)
+                    .payUrl(null)
+                    .qrCodeBase64(null)
+                    .miniAppPayParams(miniAppParams)
+                    .build();
+        }
+
         String qrContent;
         String payUrl = null;
         if (paymentProperties.isMock()) {
@@ -452,6 +500,8 @@ public class PaymentOrderService {
         return switch (channel) {
             case WECHAT -> createWechatPrepay(orderNo, amount, subject, body);
             case ALIPAY -> createAlipayPrepay(orderNo, amount, subject, body);
+            case WECHAT_MINIAPP -> // 小程序不走 PrepayResult,在 generateQrCode 中单独处理
+                    PrepayResult.builder().build();
         };
     }
 
@@ -461,24 +511,24 @@ public class PaymentOrderService {
      */
     private String buildWechatDescription(String subject, String body) {
         if (body == null || body.isBlank()) {
-            return truncateUtf8(subject, 127);
+            return truncateUtf8(subject);
         }
         String combined = subject.trim() + " - " + body.trim();
-        return truncateUtf8(combined, 127);
+        return truncateUtf8(combined);
     }
 
     /**
      * 按 UTF-8 字节长度截断字符串,避免截断到多字节字符中间。
      */
-    private String truncateUtf8(String value, int maxBytes) {
+    private String truncateUtf8(String value) {
         if (value == null) {
             return "";
         }
         byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
-        if (bytes.length <= maxBytes) {
+        if (bytes.length <= 127) {
             return value;
         }
-        int cut = maxBytes;
+        int cut = 127;
         // 避免截断到多字节字符中间:向前回退直到是合法 UTF-8 起始字节
         while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) {
             cut--;
@@ -607,6 +657,141 @@ public class PaymentOrderService {
         }
     }
 
+    /**
+     * 微信JSAPI支付预下单(小程序支付)
+     *
+     * <p>调用微信支付 v3 JSAPI下单接口,返回 prepay_id,
+     * 并对支付参数进行签名,小程序端可直接调用 wx.requestPayment 发起支付。
+     *
+     * @param orderNo 订单号
+     * @param amount  金额
+     * @param subject 商品标题
+     * @param body    商品描述
+     * @param openid  微信小程序用户 openid
+     * @return 支付参数 Map(appId, timeStamp, nonceStr, package, signType, paySign)
+     */
+    private java.util.Map<String, String> createWechatJsapiPrepay(
+            String orderNo, java.math.BigDecimal amount, String subject, String body, String openid) {
+        PaymentProperties.Wechat wc = paymentProperties.getWechat();
+        if (wc.getAppId() == null || wc.getMchId() == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED,
+                    "微信支付未配置,请检查 payment.wechat 参数");
+        }
+        if (openid == null || openid.isBlank()) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR,
+                    "微信小程序支付需要提供用户 openid");
+        }
+
+        WxPayService wxPayService = wxPayServiceProvider.getIfAvailable();
+        if (wxPayService == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED,
+                    "WxPayService未初始化,请确认 payment.mock=false 且已配置微信支付参数");
+        }
+
+        try {
+            WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request();
+            request.setOutTradeNo(orderNo);
+            request.setDescription(buildWechatDescription(subject, body));
+            request.setNotifyUrl(wc.getNotifyUrl());
+
+            // 金额单位为分
+            WxPayUnifiedOrderV3Request.Amount amountObj = new WxPayUnifiedOrderV3Request.Amount();
+            amountObj.setTotal(amount.movePointRight(2).intValue());
+            amountObj.setCurrency("CNY");
+            request.setAmount(amountObj);
+
+            // JSAPI 必须设置 payer
+            WxPayUnifiedOrderV3Request.Payer payer = new WxPayUnifiedOrderV3Request.Payer();
+            payer.setOpenid(openid);
+            request.setPayer(payer);
+
+            // 过期时间(RFC3339格式)
+            String timeExpire = java.time.format.DateTimeFormatter
+                    .ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
+                    .format(java.time.ZonedDateTime.now()
+                            .plusMinutes(paymentProperties.getOrderExpireMinutes()));
+            request.setTimeExpire(timeExpire);
+
+            // JSAPI 返回 prepay_id
+            String prepayId = wxPayService.createOrderV3(TradeTypeEnum.JSAPI, request);
+            log.info("微信JSAPI预下单成功: orderNo={}, amount={}, prepayId={}", orderNo, amount, prepayId);
+
+            // 对支付参数进行签名,供小程序 wx.requestPayment 使用
+            // JSAPI 签名 appId 优先用 payment.miniapp.app-id,未配置时回退到 payment.wechat.app-id
+            String jsapiAppId = resolveJsapiAppId(wc);
+            return buildMiniAppPayParams(jsapiAppId, prepayId, wxPayService);
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("微信JSAPI预下单异常: orderNo={}", orderNo, e);
+            throw new BusinessException(ErrorCode.PAY_FAILED, "微信小程序下单异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 从用户记录中读取小程序 openId
+     * <p>登录时通过 code2Session 获取并持久化到 t_user.wechat_mini_open_id。
+     * 支付时直接读取,避免重复 wx.login 授权。
+     */
+    private String getMiniAppOpenId(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        String openid = user.getWechatMiniOpenId();
+        if (openid == null || openid.isBlank()) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR,
+                    "未获取到小程序 openId,请先通过小程序登录");
+        }
+        return openid;
+    }
+
+    /**
+     * 解析 JSAPI 签名用的 appId
+     * <p>优先从 payment.miniapp.app-id 取,未配置时回退到 payment.wechat.app-id(兼容旧配置)。
+     */
+    private String resolveJsapiAppId(PaymentProperties.Wechat wc) {
+        String miniappId = paymentProperties.getMiniapp().getAppId();
+        if (miniappId != null && !miniappId.isBlank()) {
+            return miniappId;
+        }
+        return wc.getAppId();
+    }
+
+    /**
+     * 构建小程序 wx.requestPayment 所需签名参数
+     *
+     * <p>签名规则:appId + "\n" + timeStamp + "\n" + nonceStr + "\n" + prepayId + "\n"
+     * 使用商户私钥进行 SHA256withRSA 签名。
+     */
+    private java.util.Map<String, String> buildMiniAppPayParams(
+            String appId, String prepayId, WxPayService wxPayService) throws Exception {
+        java.security.SecureRandom random = new java.security.SecureRandom();
+        byte[] nonceBytes = new byte[16];
+        random.nextBytes(nonceBytes);
+        String nonceStr = cn.hutool.core.util.HexUtil.encodeHexStr(nonceBytes);
+        String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
+        String pkg = "prepay_id=" + prepayId;
+
+        // 待签名串
+        String signData = appId + "\n" + timeStamp + "\n" + nonceStr + "\n" + pkg + "\n";
+
+        // RSA-SHA256 签名
+        java.security.Signature sign = java.security.Signature.getInstance("SHA256withRSA");
+        sign.initSign(wxPayService.getConfig().getPrivateKey());
+        sign.update(signData.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+        String paySign = java.util.Base64.getEncoder().encodeToString(sign.sign());
+
+        java.util.Map<String, String> params = new java.util.LinkedHashMap<>();
+        params.put("appId", appId);
+        params.put("timeStamp", timeStamp);
+        params.put("nonceStr", nonceStr);
+        params.put("package", pkg);
+        params.put("signType", "RSA");
+        params.put("paySign", paySign);
+        return params;
+    }
+
     private OrderResponse toResponse(PaymentOrder order) {
         PaymentPlan plan = paymentPlanService.getPlanById(order.getPlanId());
         String hint = PaymentStatus.PENDING.name().equals(order.getStatus())
@@ -616,7 +801,8 @@ public class PaymentOrderService {
         String qrCodeBase64 = null;
         String payUrl = null;
         if (PaymentStatus.PENDING.name().equals(order.getStatus())
-                && order.getQrCodeUrl() != null && !order.getQrCodeUrl().isBlank()) {
+                && order.getQrCodeUrl() != null && !order.getQrCodeUrl().isBlank()
+                && !PaymentChannel.WECHAT_MINIAPP.name().equals(order.getChannel())) {
             qrCodeBase64 = qrCodeService.generateBase64(order.getQrCodeUrl());
             payUrl = order.getQrCodeUrl();
         }

+ 10 - 4
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentRefundService.java

@@ -12,7 +12,6 @@ import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.payment.config.PaymentProperties;
 import com.xuekairui.payment.enums.PaymentChannel;
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -28,14 +27,21 @@ import java.math.BigDecimal;
  */
 @Slf4j
 @Service
-@RequiredArgsConstructor
 public class PaymentRefundService {
 
     private final PaymentProperties paymentProperties;
-    @Qualifier("alipayClient")
     private final ObjectProvider<AlipayClient> alipayClientProvider;
     private final ObjectProvider<WxPayService> wxPayServiceProvider;
 
+    public PaymentRefundService(
+            PaymentProperties paymentProperties,
+            @Qualifier("alipayClient") ObjectProvider<AlipayClient> alipayClientProvider,
+            ObjectProvider<WxPayService> wxPayServiceProvider) {
+        this.paymentProperties = paymentProperties;
+        this.alipayClientProvider = alipayClientProvider;
+        this.wxPayServiceProvider = wxPayServiceProvider;
+    }
+
     /**
      * 发起原路退款
      *
@@ -60,7 +66,7 @@ public class PaymentRefundService {
         }
 
         return switch (ch) {
-            case WECHAT -> wechatRefund(orderNo, tradeNo, amount, refundNo);
+            case WECHAT, WECHAT_MINIAPP -> wechatRefund(orderNo, tradeNo, amount, refundNo);
             case ALIPAY -> alipayRefund(orderNo, tradeNo, amount, refundNo);
         };
     }

+ 29 - 4
zhijiayun-test/src/main/resources/static/activity-test.html

@@ -808,12 +808,21 @@ const FIELD_DESC = {
         ['data.role', 'String', '固定值"USER"'],
         ['data.userInfo.id', 'String', '用户ID'],
         ['data.userInfo.phone', 'String', '手机号(脱敏)'],
-        ['data.userInfo.nickname', 'String', '昵称(即第三方传入的username或nickname)'],
+        ['data.userInfo.nickname', 'String', '昵称'],
+        ['data.userInfo.userName', 'String', '第三方传入的原始用户名'],
         ['data.userInfo.avatar', 'String', '头像URL'],
         ['data.userInfo.wechatBound', 'Boolean', '是否已绑定微信'],
         ['data.userInfo.loginSource', 'String', '登录来源(MINIAPP)'],
-        ['data.userInfo.membershipLevel', 'String', '会员等级代码'],
+        ['data.userInfo.pharmacyName', 'String', '药店名称'],
+        ['data.userInfo.province', 'String', '省'],
+        ['data.userInfo.city', 'String', '市'],
+        ['data.userInfo.district', 'String', '区'],
+        ['data.userInfo.membershipLevel', 'String', '会员等级代码(PLUS/PRO/ULTRA)'],
         ['data.userInfo.membershipLevelName', 'String', '会员等级名称'],
+        ['data.userInfo.licenseStatus', 'String', '营业执照审核状态(NOT_SUBMITTED/PENDING/APPROVED/REJECTED)'],
+        ['data.userInfo.inviterId', 'String', '邀请人用户ID'],
+        ['data.userInfo.inviterNickname', 'String', '邀请人昵称'],
+        ['data.userInfo.hasBoundInviteCode', 'Boolean', '是否已绑定邀请码'],
         ['data.userInfo.createTime', 'String', '注册时间']
     ],
     'systemConfig': [
@@ -839,6 +848,11 @@ const FIELD_DESC = {
         ['data.current', 'Long', '当前页'],
         ['data.size', 'Long', '每页条数'],
         ['data.pages', 'Long', '总页数']
+    ],
+    'adminActivityInviteRetroactive': [
+        ['processedCount', 'Integer', '已处理的邀请关系数量'],
+        ['grantedCount', 'Integer', '成功补发奖励的数量'],
+        ['skippedCount', 'Integer', '跳过的数量(已发放或不满足条件)']
     ]
 };
 
@@ -1142,6 +1156,9 @@ curl -X POST "$" + "{BASE_URL}/api/auth/external/miniapp-token" \\
             <div class="form-group"><label>药品经营许可证(drugLicenseUrl)</label><input type="text" id="external-login-drugLicenseUrl" placeholder="https://oss.example.com/ypjy.jpg" value=""></div>
             <div class="form-group"><label>二类医疗器械备案(medicalDeviceClass2Url)</label><input type="text" id="external-login-medicalDeviceClass2Url" placeholder="https://oss.example.com/elqx.jpg" value=""></div>
             <div class="form-group"><label>三类医疗器械备案(medicalDeviceClass3Url)</label><input type="text" id="external-login-medicalDeviceClass3Url" placeholder="https://oss.example.com/slqx.jpg" value=""></div>
+            <div class="form-group"><label>小程序openId(openId)</label><input type="text" id="external-login-openId" placeholder="oJx1x5..." value=""></div>
+            <div class="form-group"><label>开放平台unionId(unionId)</label><input type="text" id="external-login-unionId" placeholder="oABCD1234..." value=""></div>
+            <div class="form-group"><label style="display:flex;align-items:center;gap:6px;"><input type="checkbox" id="external-login-licenseApproved" style="width:auto;"> 第三方已审核(licenseApproved)</label><span style="font-size:12px;color:#888;">勾选=直接APPROVED+送30天PRO</span></div>
         </div>
         <button class="btn btn-primary" onclick="runExternalMiniappToken()">执行测试</button>
         <div class="response-box" id="external-login-box" style="display:none;">
@@ -1237,7 +1254,12 @@ function renderAdminActivityInviteModule() {
         [
          {label:'被邀请人ID(inviteeId)', name:'inviteeId', placeholder:'作弊者用户ID', default:''},
          {label:'撤销原因(reason)', name:'reason', placeholder:'冒用他人资质', default:'冒用他人资质'}
-        ], true, 'adminActivityInviteRevoke', '反作弊:根据被邀请人ID撤销其对应的邀请奖励,标记为已撤销状态')}`;
+        ], true, 'adminActivityInviteRevoke', '反作弊:根据被邀请人ID撤销其对应的邀请奖励,标记为已撤销状态')}
+    ${testSection('admin-activity-invite-retroactive', '邀请奖励补发(运营补发)', 'POST', 'post', '/api/admin/activity/invite/retroactive-grant', '需认证', 'auth-required',
+        [
+         {label:'开始时间(startTime,可选)', name:'startTime', placeholder:'2026-07-09T00:00:00', default:'2026-07-09T00:00:00'},
+         {label:'结束时间(endTime,可选)', name:'endTime', placeholder:'2026-07-30T23:59:59', default:'2026-07-30T23:59:59'}
+        ], true, 'adminActivityInviteRetroactive', '运营补发:对活动时间窗内已入驻的被邀请人批量补发邀请奖励。场景:活动配置晚于实际邀请时间,配置活动后对窗口内已入驻用户补发。不传时间默认使用当前活动配置的时间窗口')}`;
 }
 
 // ==================== 运营端:签到送会员管理模块(旧版独立配置,已被签到全局配置+周期表替代,保留兼容) ====================
@@ -1615,12 +1637,15 @@ async function runExternalMiniappToken() {
     const apiKey = document.getElementById(sectionId + '-apikey').value;
     if (!apiKey) { alert('请填写 API Key'); return; }
 
-    const fieldNames = ['phone', 'inviteCode', 'username', 'nickname', 'pharmacyName', 'province', 'city', 'district', 'pharmacyAddress', 'contactPerson', 'contactPhone', 'businessLicenseNo', 'storeName', 'terminalType', 'businessLicenseUrl', 'drugLicenseUrl', 'medicalDeviceClass2Url', 'medicalDeviceClass3Url'];
+    const fieldNames = ['phone', 'inviteCode', 'username', 'nickname', 'pharmacyName', 'province', 'city', 'district', 'pharmacyAddress', 'contactPerson', 'contactPhone', 'businessLicenseNo', 'storeName', 'terminalType', 'businessLicenseUrl', 'drugLicenseUrl', 'medicalDeviceClass2Url', 'medicalDeviceClass3Url', 'openId', 'unionId'];
     const body = {};
     fieldNames.forEach(f => {
         const val = document.getElementById(sectionId + '-' + f).value;
         if (val) body[f] = val;
     });
+    // licenseApproved 复选框
+    const licApproved = document.getElementById(sectionId + '-licenseApproved');
+    if (licApproved && licApproved.checked) body.licenseApproved = true;
     if (!body.phone) { alert('请填写手机号'); return; }
 
     const box = document.getElementById(sectionId + '-box');

+ 225 - 6
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -40,6 +40,7 @@
         .method-get { background: #e8f5e9; color: #2e7d32; }
         .method-post { background: #e3f2fd; color: #1565c0; }
         .method-put { background: #fff3e0; color: #e65100; }
+        .method-patch { background: #fff8e1; color: #f9a825; }
         .method-delete { background: #fce4ec; color: #c62828; }
         .section-title .path { font-family: Consolas, Monaco, monospace; color: #555; font-size: 15px; }
         .section-title .auth-tag { font-size: 12px; padding: 3px 8px; border-radius: 3px; margin-left: auto; }
@@ -234,6 +235,9 @@
         <div class="module-tab" data-module="platform-config">11.平台配置</div>
         <div class="module-tab" data-module="search-record">12.搜索记录</div>
         <div class="module-tab" data-module="platform-session">13.平台会话</div>
+        <div class="module-tab" data-module="content">14.内容配置</div>
+        <div class="module-tab" data-module="procurement-ledger">15.采购台账</div>
+        <div class="module-tab" data-module="purchase-intent">16.跳转购买记录</div>
     </div>
 
     <!-- 运营侧模块标签栏 -->
@@ -681,6 +685,9 @@ function renderModule(module) {
     case 'platform-config': html = renderPlatformConfigModule(); break;
     case 'search-record': html = renderSearchRecordModule(); break;
     case 'platform-session': html = renderPlatformSessionModule(); break;
+    case 'content': html = renderContentModule(); break;
+    case 'procurement-ledger': html = renderProcurementLedgerModule(); break;
+    case 'purchase-intent': html = renderPurchaseIntentModule(); break;
     // 运营侧模块
     case 'admin-membership': html = renderAdminMembershipModule(); break;
     case 'admin-invite': html = renderAdminInviteModule(); break;
@@ -1185,6 +1192,82 @@ const FIELD_DESC = {
         ['searchLog', 'Object/null', '搜索日志(仅详情接口返回,列表接口为null)']
     ],
 
+    'contentHelp': [
+        ['title', 'String', '帮助标题(如:智价云(药店版)帮助)'],
+        ['faqs', 'Array', '常见问题列表'],
+        ['faqs[].q', 'String', '问题(如:小程序和桌面版有什么区别?)'],
+        ['faqs[].a', 'String', '答案说明'],
+        ['compliance', 'String', '合规声明(数据来源于绑定的B2B平台账号)']
+    ],
+
+    'contentDownload': [
+        ['windowsUrl', 'String', 'Windows桌面客户端下载链接'],
+        ['officialSiteUrl', 'String', '官方网站地址'],
+        ['version', 'String', '当前版本号(如:1.0.0)'],
+        ['releaseNotes', 'String', '版本发布说明']
+    ],
+
+    'procurementLedger': [
+        ['id', 'Long', '台账记录ID'],
+        ['userId', 'Long', '用户ID'],
+        ['intentId', 'Long', '关联的跳转购买记录ID(可为null)'],
+        ['queryId', 'Long', '关联的查询记录ID(可为null)'],
+        ['drugName', 'String', '药品名称'],
+        ['spec', 'String', '规格'],
+        ['qty', 'BigDecimal', '采购数量'],
+        ['unitPrice', 'BigDecimal', '单价'],
+        ['total', 'BigDecimal', '总价'],
+        ['platform', 'String', '采购平台'],
+        ['orderNote', 'String', '订单备注'],
+        ['status', 'String', '状态(ordered/received/cancelled)'],
+        ['createTime', 'DateTime', '创建时间'],
+        ['updateTime', 'DateTime', '更新时间']
+    ],
+
+    'purchaseIntent': [
+        ['id', 'Long', '记录ID'],
+        ['userId', 'Long', '用户ID'],
+        ['queryId', 'Long', '关联查询记录ID(可为null)'],
+        ['drugName', 'String', '药品名称'],
+        ['spec', 'String', '规格'],
+        ['manufacturer', 'String', '厂家'],
+        ['platformCode', 'String', '平台代码(如:yaoshibang)'],
+        ['platformName', 'String', '平台名称(如:药师帮)'],
+        ['referencePrice', 'BigDecimal', '参考价格'],
+        ['externalUrl', 'String', '外部跳转链接'],
+        ['sourceClient', 'String', '来源客户端(WINDOWS/ANDROID/MINIAPP)'],
+        ['createTime', 'DateTime', '创建时间']
+    ],
+
+    'userMe': [
+        ['user', 'Object', '用户基本信息'],
+        ['gate', 'Object', '准入信息(入驻审核状态等)'],
+        ['quota', 'Object', '配额信息(爬虫次数、关注数等)'],
+        ['platforms', 'Array', '已绑定的平台账号列表']
+    ],
+
+    'dashboard': [
+        ['membership', 'Object', '会员状态摘要'],
+        ['quota', 'Object', '配额使用摘要'],
+        ['watchlistCount', 'Integer', '关注药品数量'],
+        ['orderCount', 'Integer', '订单数量']
+    ],
+
+    'membershipUser': [
+        ['userId', 'Long', '用户ID'],
+        ['phone', 'String', '手机号'],
+        ['nickname', 'String', '昵称'],
+        ['level', 'String', '会员等级(PLUS/PRO/ULTRA)'],
+        ['membershipExpireAt', 'DateTime', '会员到期时间'],
+        ['membershipDaysRemaining', 'Integer', '会员剩余天数'],
+        ['source', 'String', '会员来源']
+    ],
+
+    'adminActivityInviteRetroactive': [
+        ['processedCount', 'Integer', '已处理的邀请关系数量'],
+        ['grantedCount', 'Integer', '成功补发奖励的数量'],
+        ['skippedCount', 'Integer', '跳过的数量(已发放或不满足条件)']
+    ],
 
 };
 
@@ -1329,7 +1412,26 @@ function renderAuthModule() {
     <div style="margin-top:-8px;margin-bottom:14px;">
         <button class="btn btn-primary btn-sm" onclick="sendCodeToField('auth-change-phone-newPhone','change_phone')">发送验证码到新手机</button>
         <span style="font-size:11px;color:#888;margin-left:8px;">开发环境固定验证码: 123456</span>
-    </div>`;
+    </div>
+    ${testSection('auth-miniapp-login', '小程序统一登录', 'POST', 'post', '/api/auth/wechat/miniapp/login', '公开', 'auth-public',
+        [{label:'小程序授权码(jsCode)', name:'jsCode', placeholder:'wx.login()返回的code', default:'test_miniapp_code'},
+         {label:'手机号授权码(phoneCode,可选)', name:'phoneCode', placeholder:'getPhoneNumber返回的code', default:''},
+         {label:'邀请码(inviteCode,可选)', name:'inviteCode', placeholder:'ABC123', default:''},
+         {label:'设备类型(deviceType)', name:'deviceType', placeholder:'MINIAPP', default:'MINIAPP'}], true, 'loginResponse', '小程序统一登录,自动处理"小程序老/新用户×当前系统老/新用户"四种场景,任一端不存在该用户则自动注册')}
+    ${testSection('auth-miniapp-bind-phone', '小程序绑定手机号', 'POST', 'post', '/api/auth/wechat/miniapp/bind-phone', '公开', 'auth-public',
+        [{label:'临时令牌(tempToken)', name:'tempToken', placeholder:'登录时返回的tempToken', default:''},
+         {label:'手机号授权码(phoneCode)', name:'phoneCode', placeholder:'getPhoneNumber返回的code', default:''},
+         {label:'邀请码(inviteCode,可选)', name:'inviteCode', placeholder:'ABC123', default:''}], true, 'loginResponse', '当统一登录返回needBindPhone=true时调用,使用getPhoneNumber按钮返回的code完成手机号绑定')}
+    ${testSection('auth-register', '小程序独立注册', 'POST', 'post', '/api/auth/register', '需认证', 'auth-required',
+        [{label:'临时令牌(tempToken)', name:'tempToken', placeholder:'登录时返回的tempToken', default:''},
+         {label:'邀请码(inviteCode,可选)', name:'inviteCode', placeholder:'ABC123', default:''},
+         {label:'药店名称(pharmacyName)', name:'pharmacyName', placeholder:'仁爱大药房', default:'测试大药房'},
+         {label:'省份(province)', name:'province', placeholder:'广东省', default:'广东省'},
+         {label:'城市(city)', name:'city', placeholder:'深圳市', default:'深圳市'},
+         {label:'区(district)', name:'district', placeholder:'南山区', default:'南山区'}], true, 'loginResponse', '微信授权手机号后,如果isRegistered=false,前端跳转注册页,用户填写药店信息后调用此接口完成注册')}
+    ${testSection('auth-logout', '登出', 'POST', 'post', '/api/auth/logout', '需认证', 'auth-required', null, false, null, '登出当前用户,清空RefreshToken,使Token对失效')}
+    ${testSection('auth-user-me', '聚合用户信息', 'GET', 'get', '/api/auth/user/me', '需认证', 'auth-required', null, true, 'userMe', '聚合用户信息(user+gate+quota+platforms),一次请求获取首页所需全部数据')}
+    ${testSection('auth-dashboard', '首页概览统计', 'GET', 'get', '/api/auth/user/dashboard', '需认证', 'auth-required', null, true, 'dashboard', '首页概览统计:会员状态、配额使用、关注数、订单数等')}`;
 }
 
 function renderCrawlerModule() {
@@ -1463,7 +1565,17 @@ function renderPaymentModule() {
     ${testSection('payment-anxin-notify-card-change', '安心付卡变更通知', 'POST', 'post', '/api/payment/anxin/notify/card-change?card_id=MOCK_CARD_xxx&change_type=AUDIT&status=EFFECTIVE', '公开', 'auth-public',
         [{label:'卡模板ID(card_id)', name:'card_id', placeholder:'创建卡模板后返回', default:''},
          {label:'变更类型(change_type)', name:'change_type', placeholder:'AUDIT/STATUS_CHANGE', default:'AUDIT'},
-         {label:'新状态(status)', name:'status', placeholder:'PENDING/EFFECTIVE/INVALID/FROZEN', default:'EFFECTIVE'}], true, null, '安心付卡模板审核结果通知。支付宝在卡模板审核通过/驳回/冻结/解冻时发送。mock模式直接更新本地状态')}`;
+         {label:'新状态(status)', name:'status', placeholder:'PENDING/EFFECTIVE/INVALID/FROZEN', default:'EFFECTIVE'}], true, null, '安心付卡模板审核结果通知。支付宝在卡模板审核通过/驳回/冻结/解冻时发送。mock模式直接更新本地状态')}
+    ${testSection('payment-wechat-openid', '微信小程序获取OpenId', 'POST', 'post', '/api/payment/wechat/openid', '公开', 'auth-public',
+        [{label:'小程序授权码(jsCode)', name:'jsCode', placeholder:'wx.login()返回的code', default:'test_js_code'}], true, null, '小程序端调用wx.login()获取jsCode后提交到此接口换取openId。获取到的openId用于后续创建支付订单时传入channel=WECHAT_MINIAPP。公开接口无需登录')}
+    ${testSection('payment-alipay-gateway', '支付宝应用网关', 'POST', 'post', '/api/payment/alipay/gateway', '公开', 'auth-public',
+        [{label:'消息类型(msg_method)', name:'msg_method', placeholder:'alipay.trade.refund.depositback.completed', default:'alipay.open.auth.userauth.cancelled'},
+         {label:'应用ID(app_id)', name:'app_id', placeholder:'支付宝应用ID', default:'2021...'}], false, null, '支付宝应用网关入口,接收支付宝主动推送的异步消息(退款、投诉、账单等)。服务端持久化到t_alipay_callback_record表并返回success,避免支付宝重试')}
+    ${testSection('payment-alipay-auth-callback', '支付宝授权回调', 'GET', 'get', '/api/payment/alipay/auth-callback?auth_code=test_auth_code&app_id=2021...&source=alipay', '公开', 'auth-public',
+        [{label:'授权码(auth_code)', name:'auth_code', placeholder:'支付宝授权码', default:'test_auth_code'},
+         {label:'应用ID(app_id)', name:'app_id', placeholder:'支付宝应用ID', default:'2021...'},
+         {label:'来源(source)', name:'source', placeholder:'alipay', default:'alipay'},
+         {label:'状态(state)', name:'state', placeholder:'自定义state', default:''}], false, null, '支付宝OAuth授权回调入口。用户在支付宝完成授权后重定向到此地址,携带auth_code、app_id、source等参数。服务端持久化到t_alipay_callback_record表并返回成功页面')}`;
 }
 
 function renderUploadModule() {
@@ -1616,6 +1728,67 @@ function renderPlatformSessionModule() {
         [{label:'平台代码(platformCode)', name:'platformCode', placeholder:'yiyaocheng', default:'yiyaocheng'}], false, null, '删除某个平台的远端会话(幂等,不存在也返回成功)')}`;
 }
 
+function renderContentModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runContentTests()">运行全部内容配置测试</button>
+    </div>
+    ${testSection('content-help', '帮助与FAQ', 'GET', 'get', '/api/content/help', '公开', 'auth-public', null, true, 'contentHelp', '获取帮助文档与常见问题解答,包含小程序与桌面版区别、入驻流程、查价条件、会员升级说明')}
+    ${testSection('content-download', '桌面下载链接', 'GET', 'get', '/api/content/download', '公开', 'auth-public', null, true, 'contentDownload', '获取桌面客户端下载链接、官网地址、版本号与发布说明')}`;
+}
+
+function renderProcurementLedgerModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runProcurementLedgerTests()">运行全部采购台账测试</button>
+    </div>
+    ${testSection('pl-list', '采购台账列表', 'GET', 'get', '/api/procurement-ledgers?page=1&pageSize=20', '需认证', 'auth-required',
+        [{label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页条数(pageSize)', name:'pageSize', placeholder:'20', default:'20'}], true, 'procurementLedger', '分页查询当前用户的手动采购台账记录')}
+    ${testSection('pl-create', '创建采购台账', 'POST', 'post', '/api/procurement-ledgers', '需认证', 'auth-required',
+        [{label:'意向ID(intentId,可选)', name:'intentId', placeholder:'1', default:''},
+         {label:'查询ID(queryId,可选)', name:'queryId', placeholder:'1', default:''},
+         {label:'药品名称(drugName)', name:'drugName', placeholder:'阿莫西林胶囊', default:'阿莫西林胶囊'},
+         {label:'规格(spec)', name:'spec', placeholder:'0.25g×24片', default:'0.25g×24片'},
+         {label:'数量(qty)', name:'qty', placeholder:'10', default:'10'},
+         {label:'单价(unitPrice)', name:'unitPrice', placeholder:'12.50', default:'12.50'},
+         {label:'总价(total)', name:'total', placeholder:'125.00', default:'125.00'},
+         {label:'平台(platform)', name:'platform', placeholder:'yaoshibang', default:'yaoshibang'},
+         {label:'订单备注(orderNote)', name:'orderNote', placeholder:'加急', default:''},
+         {label:'状态(status)', name:'status', placeholder:'ordered/received/cancelled', default:'ordered'}], true, 'procurementLedger', '手动录入采购台账记录,关联跳转购买记录或查询记录')}
+    ${testSection('pl-update', '更新采购台账', 'PATCH', 'patch', '/api/procurement-ledgers/{id}', '需认证', 'auth-required',
+        [{label:'台账ID(id)', name:'id', placeholder:'1', default:'1'},
+         {label:'状态(status)', name:'status', placeholder:'ordered/received/cancelled', default:'received'},
+         {label:'数量(qty)', name:'qty', placeholder:'10', default:'10'},
+         {label:'单价(unitPrice)', name:'unitPrice', placeholder:'12.50', default:'12.50'},
+         {label:'总价(total)', name:'total', placeholder:'125.00', default:'125.00'},
+         {label:'订单备注(orderNote)', name:'orderNote', placeholder:'已到货', default:''}], true, 'procurementLedger', '更新采购台账的状态、数量、价格等信息')}
+    ${testSection('pl-delete', '删除采购台账', 'DELETE', 'delete', '/api/procurement-ledgers/{id}', '需认证', 'auth-required',
+        [{label:'台账ID(id)', name:'id', placeholder:'1', default:'1'}], false, null, '删除指定的采购台账记录')}`;
+}
+
+function renderPurchaseIntentModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runPurchaseIntentTests()">运行全部跳转购买记录测试</button>
+    </div>
+    ${testSection('pi-list', '跳转购买记录列表', 'GET', 'get', '/api/purchase-intents?page=1&pageSize=20', '需认证', 'auth-required',
+        [{label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页条数(pageSize)', name:'pageSize', placeholder:'20', default:'20'}], true, 'purchaseIntent', '分页查询当前用户的跳转购买记录,供小程序只读展示')}
+    ${testSection('pi-detail', '跳转购买详情', 'GET', 'get', '/api/purchase-intents/{id}', '需认证', 'auth-required',
+        [{label:'记录ID(id)', name:'id', placeholder:'1', default:'1'}], true, 'purchaseIntent', '查询单条跳转购买记录详情')}
+    ${testSection('pi-create', '写入跳转购买记录', 'POST', 'post', '/api/purchase-intents', '需认证', 'auth-required',
+        [{label:'查询ID(queryId,可选)', name:'queryId', placeholder:'1', default:''},
+         {label:'药品名称(drugName)', name:'drugName', placeholder:'阿莫西林胶囊', default:'阿莫西林胶囊'},
+         {label:'规格(spec)', name:'spec', placeholder:'0.25g×24片', default:'0.25g×24片'},
+         {label:'厂家(manufacturer)', name:'manufacturer', placeholder:'石药集团', default:''},
+         {label:'平台代码(platformCode)', name:'platformCode', placeholder:'yaoshibang', default:'yaoshibang'},
+         {label:'平台名称(platformName)', name:'platformName', placeholder:'药师帮', default:'药师帮'},
+         {label:'参考价(referencePrice)', name:'referencePrice', placeholder:'12.50', default:'12.50'},
+         {label:'外链(externalUrl)', name:'externalUrl', placeholder:'https://...', default:''},
+         {label:'来源客户端(sourceClient)', name:'sourceClient', placeholder:'WINDOWS/ANDROID/MINIAPP', default:'WINDOWS'}], true, 'purchaseIntent', '桌面端/Android调用,记录用户跳转到B2B平台购买药品的行为')}`;
+}
+
 // ==================== 运营侧模块 ====================
 function renderAdminMembershipModule() {
     return `
@@ -1625,7 +1798,12 @@ function renderAdminMembershipModule() {
          {label:'会员等级(level)', name:'level', placeholder:'PLUS/PRO/ULTRA', default:'PRO'},
          {label:'来源(source)', name:'source', placeholder:'ADMIN_GRANT', default:'ADMIN_GRANT'},
          {label:'备注(remark)', name:'remark', placeholder:'运营发放', default:'测试发放'}], false, 'membership', '运营/超管给用户发放或调整会员等级')}
-    ${testSection('admin-membership-list', '会员记录列表', 'GET', 'get', '/api/admin/membership/memberships?page=1&size=10', '需认证', 'auth-required', null, true, 'membership', '分页查询会员发放记录')}`;
+    ${testSection('admin-membership-list', '会员记录列表', 'GET', 'get', '/api/admin/membership/memberships?page=1&size=10', '需认证', 'auth-required', null, true, 'membership', '分页查询会员发放记录')}
+    ${testSection('admin-membership-users', '会员用户快照列表', 'GET', 'get', '/api/admin/membership/users?page=1&size=20&phone=138&level=PRO', '需认证', 'auth-required',
+        [{label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页条数(size)', name:'size', placeholder:'20', default:'20'},
+         {label:'手机号(phone)', name:'phone', placeholder:'138', default:''},
+         {label:'会员等级(level)', name:'level', placeholder:'PLUS/PRO/ULTRA', default:''}], true, 'membershipUser', '会员用户快照列表(用户维度,每用户一行),支持按手机号和等级筛选')}`;
 }
 
 function renderAdminInviteModule() {
@@ -1633,6 +1811,21 @@ function renderAdminInviteModule() {
     <div class="batch-buttons"><button class="btn btn-warning btn-sm" onclick="runAdminInviteTests()">运行全部邀请管理测试</button></div>
     ${testSection('admin-invite-config', '邀请配置查询', 'GET', 'get', '/api/admin/invite/config', '需认证', 'auth-required', null, true, 'inviteConfig', '获取当前激活的邀请配置')}
     ${testSection('admin-invite-configs', '全部邀请配置', 'GET', 'get', '/api/admin/invite/configs', '需认证', 'auth-required', null, true, 'inviteConfig', '获取所有邀请配置历史记录')}
+    ${testSection('admin-invite-config-by-id', '按ID查询邀请配置', 'GET', 'get', '/api/admin/invite/config/{id}', '需认证', 'auth-required',
+        [{label:'配置ID(id)', name:'id', placeholder:'1', default:'1'}], true, 'inviteConfig', '根据ID获取指定邀请配置详情')}
+    ${testSection('admin-invite-config-create', '创建邀请配置', 'POST', 'post', '/api/admin/invite/config', '需认证', 'auth-required',
+        [{label:'奖励月数(rewardMonths)', name:'rewardMonths', placeholder:'1', default:'1'},
+         {label:'总邀请上限(maxTotalInvites)', name:'maxTotalInvites', placeholder:'30', default:'30'},
+         {label:'日奖励上限(maxDailyReward)', name:'maxDailyReward', placeholder:'50', default:'50'},
+         {label:'状态(status)', name:'status', placeholder:'0停用/1启用', default:'1'}], false, 'inviteConfig', '创建新的邀请配置记录')}
+    ${testSection('admin-invite-config-update-by-id', '按ID更新邀请配置', 'PUT', 'put', '/api/admin/invite/config/{id}', '需认证', 'auth-required',
+        [{label:'配置ID(id)', name:'id', placeholder:'1', default:'1'},
+         {label:'奖励月数(rewardMonths)', name:'rewardMonths', placeholder:'1', default:'1'},
+         {label:'总邀请上限(maxTotalInvites)', name:'maxTotalInvites', placeholder:'30', default:'30'},
+         {label:'日奖励上限(maxDailyReward)', name:'maxDailyReward', placeholder:'50', default:'50'},
+         {label:'状态(status)', name:'status', placeholder:'0停用/1启用', default:'1'}], false, 'inviteConfig', '更新指定ID的邀请配置')}
+    ${testSection('admin-invite-config-delete', '删除邀请配置', 'DELETE', 'delete', '/api/admin/invite/config/{id}', '需认证', 'auth-required',
+        [{label:'配置ID(id)', name:'id', placeholder:'1', default:'1'}], false, null, '删除指定的邀请配置记录')}
     ${testSection('admin-invite-config-update', '更新邀请配置', 'PUT', 'put', '/api/admin/invite/config', '需认证', 'auth-required',
         [
          {label:'奖励月数(rewardMonths)', name:'rewardMonths', placeholder:'1', default:'1'},
@@ -1771,7 +1964,30 @@ function renderAdminUserModule() {
     ${testSection('admin-user-kick', '强制踢出用户', 'POST', 'post', '/api/admin/users/{userId}/kick', '需认证', 'auth-required',
         [{label:'用户ID(userId)', name:'userId', placeholder:'1', default:'1'}], false, null, '强制踢出前端用户:递增tokenVersion+清空RefreshToken')}
     ${testSection('admin-admin-kick', '强制踢出管理员', 'POST', 'post', '/api/admin/admins/{adminId}/kick', '需认证', 'auth-required',
-        [{label:'管理员ID(adminId)', name:'adminId', placeholder:'100', default:'100'}], false, null, '强制踢出管理员:递增tokenVersion+清空RefreshToken')}`;
+         [{label:'管理员ID(adminId)', name:'adminId', placeholder:'100', default:'100'}], false, null, '强制踢出管理员:递增tokenVersion+清空RefreshToken')}
+    ${testSection('admin-auth-login', '管理员登录', 'POST', 'post', '/api/admin/auth/login', '公开', 'auth-public',
+        [{label:'账号(username/phone)', name:'username', placeholder:'手机号或用户名', default:'13800138000'},
+         {label:'密码(password)', name:'password', placeholder:'管理员密码', default:'123456'}], true, 'loginResponse', '管理员密码登录,支持username或phone字段传账号。返回与用户侧相同的LoginResponse结构')}
+    ${testSection('admin-admins-detail', '管理员详情', 'GET', 'get', '/api/admin/admins/{id}', '需认证', 'auth-required',
+        [{label:'管理员ID(id)', name:'id', placeholder:'100', default:'100'}], true, null, '查询单个管理员详情(t_admin表)')}
+    ${testSection('admin-admins-create', '创建管理员', 'POST', 'post', '/api/admin/admins', '需认证', 'auth-required',
+        [{label:'用户名(username)', name:'username', placeholder:'admin001', default:'admin001'},
+         {label:'密码(password)', name:'password', placeholder:'初始密码', default:'123456'},
+         {label:'手机号(phone)', name:'phone', placeholder:'13800138000', default:'13800138000'},
+         {label:'角色(role)', name:'role', placeholder:'ADMIN/SUPER_ADMIN', default:'ADMIN'}], true, null, '创建管理员账号(写入t_admin表),角色可选ADMIN或SUPER_ADMIN')}
+    ${testSection('admin-admins-update', '更新管理员', 'PUT', 'put', '/api/admin/admins/{id}', '需认证', 'auth-required',
+        [{label:'管理员ID(id)', name:'id', placeholder:'100', default:'100'},
+         {label:'用户名(username)', name:'username', placeholder:'admin001', default:'admin001'},
+         {label:'密码(password)', name:'password', placeholder:'新密码(留空不改)', default:''},
+         {label:'手机号(phone)', name:'phone', placeholder:'13800138000', default:'13800138000'},
+         {label:'角色(role)', name:'role', placeholder:'ADMIN/SUPER_ADMIN', default:'ADMIN'}], true, null, '更新管理员账号信息')}
+    ${testSection('admin-admins-delete', '禁用管理员', 'DELETE', 'delete', '/api/admin/admins/{id}', '需认证', 'auth-required',
+        [{label:'管理员ID(id)', name:'id', placeholder:'100', default:'100'}], false, null, '禁用管理员账号(软删除,标记为不可用)')}
+    ${testSection('admin-admins-reset-pwd', '重置管理员密码', 'PUT', 'put', '/api/admin/admins/{id}/password', '需认证', 'auth-required',
+        [{label:'管理员ID(id)', name:'id', placeholder:'100', default:'100'}], false, null, '超管重置指定管理员密码,返回新随机密码')}
+    ${testSection('admin-admins-change-pwd', '修改自己的密码', 'PUT', 'put', '/api/admin/admins/password', '需认证', 'auth-required',
+        [{label:'旧密码(oldPassword)', name:'oldPassword', placeholder:'当前密码', default:''},
+         {label:'新密码(newPassword)', name:'newPassword', placeholder:'新密码', default:'Abc123456'}], false, null, '管理员修改自己的登录密码')}`;
 }
 
 function renderAdminAuditModule() {
@@ -1795,7 +2011,7 @@ function getSectionsInModule(modulePrefix) {
     });
 }
 
-const USER_MODULES = ['auth','crawler','invite','coupon','membership','payment','upload','search','platform-account','business-license','platform-config','search-record','platform-session','watchlist','trial-quota'];
+const USER_MODULES = ['auth','crawler','invite','coupon','membership','payment','upload','search','platform-account','business-license','platform-config','search-record','platform-session','watchlist','trial-quota','content','procurement-ledger','purchase-intent'];
 const ADMIN_MODULES = ['admin-membership','admin-invite','admin-crawler','admin-coupon','admin-license','admin-payment','admin-anxin','admin-trial-quota','admin-user','admin-audit','upload'];
 
 function countTestsInCurrentPanel() {
@@ -1973,6 +2189,9 @@ function runPlatformConfigTests() { runBatchTests('pc'); }
 function runSearchRecordTests() { runBatchTests('sr'); }
 function runPlatformSessionTests() { runBatchTests('ps'); }function runWatchlistTests() { runBatchTests('watchlist'); }
 function runTrialQuotaTests() { runBatchTests('trial-quota'); }
+function runContentTests() { runBatchTests('content'); }
+function runProcurementLedgerTests() { runBatchTests('pl'); }
+function runPurchaseIntentTests() { runBatchTests('pi'); }
 // 运营侧快捷批量运行
 function runAdminMembershipTests() { runBatchTests('admin-membership'); }
 function runAdminInviteTests() { runBatchTests('admin-invite'); }
@@ -2092,4 +2311,4 @@ window.addEventListener('DOMContentLoaded', () => {
 });
 </script>
 </body>
-</html>
+</html>

+ 12 - 5
zhijiayun-user/src/main/java/com/xuekairui/user/controller/PlatformAccountController.java

@@ -2,6 +2,7 @@ package com.xuekairui.user.controller;
 
 import com.xuekairui.common.Result;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.service.OperationAuditLogService;
 import com.xuekairui.user.service.PlatformAccountService;
 import jakarta.validation.Valid;
@@ -9,7 +10,13 @@ import jakarta.validation.constraints.NotBlank;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import org.springframework.security.core.Authentication;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
 
@@ -38,7 +45,7 @@ public class PlatformAccountController {
                 request.getAccount(),
                 request.getPassword()
         );
-        auditLogService.logSuccess(userId, "用户", "USER", OperationType.ACCOUNT_BIND,
+        auditLogService.logSuccess(userId, "用户", OperatorRole.USER, OperationType.ACCOUNT_BIND,
                 "ACCOUNT:" + request.getPlatformCode(), userId);
         return Result.success(null);
     }
@@ -51,7 +58,7 @@ public class PlatformAccountController {
                                        Authentication authentication) {
         Long userId = getUserId(authentication);
         platformAccountService.unbindAccount(userId, platformCode);
-        auditLogService.logSuccess(userId, "用户", "USER", OperationType.ACCOUNT_UNBIND,
+        auditLogService.logSuccess(userId, "用户", OperatorRole.USER, OperationType.ACCOUNT_UNBIND,
                 "ACCOUNT:" + platformCode, userId);
         return Result.success(null);
     }
@@ -76,7 +83,7 @@ public class PlatformAccountController {
                                        Authentication authentication) {
         Long userId = getUserId(authentication);
         platformAccountService.verifyAccount(userId, platformCode);
-        auditLogService.logSuccess(userId, "用户", "USER", OperationType.ACCOUNT_VERIFY,
+        auditLogService.logSuccess(userId, OperatorRole.USER.getDescription(), OperatorRole.USER, OperationType.ACCOUNT_VERIFY,
                 "ACCOUNT:" + platformCode, userId);
         return Result.success(null);
     }
@@ -102,4 +109,4 @@ public class PlatformAccountController {
         @NotBlank(message = "密码不能为空")
         private String password;
     }
-}
+}

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

@@ -46,6 +46,12 @@ public class ExternalTokenRequest {
     /** 可选:营业执照号 */
     private String businessLicenseNo;
 
+    /** 可选:微信小程序 openId(第三方系统从小程序 wx.login 获取后传入,保存到 wechat_mini_open_id) */
+    private String openId;
+
+    /** 可选:微信开放平台 unionId(用于跨应用用户统一标识) */
+    private String unionId;
+
     // ===== 入驻信息字段(对应 /api/business-license/upload,均为可选) =====
 
     /** 可选:店铺名称(入驻信息专用,不传则用 pharmacyName 兜底) */

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

@@ -1,6 +1,7 @@
 package com.xuekairui.user.entity;
 
 import com.baomidou.mybatisplus.annotation.*;
+import com.xuekairui.user.enums.AuditResult;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.AllArgsConstructor;
@@ -67,9 +68,9 @@ public class OperationAuditLog {
     /** 操作后的数据快照(JSON格式) */
     private String afterData;
 
-    /** 操作结果:SUCCESS-成功 FAILED-失败 PARTIAL-部分成功 */
+    /** 操作结果:SUCCESS-成功 FAILED-失败 PARTIAL-部分成功(见 AuditResult 枚举) */
     @Builder.Default
-    private String result = "SUCCESS";
+    private String result = AuditResult.SUCCESS.getCode();
 
     /** 错误信息(操作失败时记录) */
     private String errorMessage;
@@ -99,4 +100,4 @@ public class OperationAuditLog {
     /** 创建时间 */
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;
-}
+}

+ 42 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/enums/AuditResult.java

@@ -0,0 +1,42 @@
+package com.xuekairui.user.enums;
+
+import lombok.Getter;
+
+/**
+ * 审计日志操作结果枚举
+ *
+ * 统一管理操作结果状态,避免在代码中硬编码字符串
+ */
+@Getter
+public enum AuditResult {
+
+    /** 操作成功 */
+    SUCCESS("SUCCESS", "操作成功"),
+    /** 操作失败 */
+    FAILED("FAILED", "操作失败"),
+    /** 部分成功 */
+    PARTIAL("PARTIAL", "部分成功");
+
+    /** 结果代码(存入数据库的值) */
+    private final String code;
+
+    /** 结果描述 */
+    private final String description;
+
+    AuditResult(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    /**
+     * 根据代码查找枚举
+     */
+    public static AuditResult fromCode(String code) {
+        for (AuditResult result : values()) {
+            if (result.getCode().equals(code)) {
+                return result;
+            }
+        }
+        return null;
+    }
+}

+ 51 - 38
zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperationType.java

@@ -4,99 +4,108 @@ import lombok.Getter;
 
 /**
  * 操作类型枚举
- * 
+ *
  * 定义系统中所有可记录的操作类型
+ *
+ * 字段说明:
+ * <ul>
+ *   <li><b>module</b> — 所属模块(如 USER、PLATFORM)</li>
+ *   <li><b>code</b> — 操作类型代码(如 REGISTER、CREATE)</li>
+ *   <li><b>targetType</b> — 目标对象类型(如 USER、LEVEL_CONFIG),用于审计日志的目标对象归类</li>
+ *   <li><b>description</b> — 中文描述,便于阅读</li>
+ *   <li><b>sensitive</b> — 是否为敏感操作</li>
+ * </ul>
  */
 @Getter
 public enum OperationType {
 
     // ==================== 用户管理模块 ====================
     /** 用户注册 */
-    USER_REGISTER("USER", "REGISTER", "用户注册", false),
+    USER_REGISTER("USER", "REGISTER", "USER", "用户注册", false),
     /** 用户登录 */
-    USER_LOGIN("USER", "LOGIN", "用户登录", false),
+    USER_LOGIN("USER", "LOGIN", "USER", "用户登录", false),
     /** 用户注销 */
-    USER_LOGOUT("USER", "LOGOUT", "用户注销", false),
+    USER_LOGOUT("USER", "LOGOUT", "USER", "用户注销", false),
     /** 修改用户信息 */
-    USER_UPDATE("USER", "UPDATE", "修改用户信息", false),
+    USER_UPDATE("USER", "UPDATE", "USER", "修改用户信息", false),
     /** 禁用用户 */
-    USER_DISABLE("USER", "DISABLE", "禁用用户账号", true),
+    USER_DISABLE("USER", "DISABLE", "USER", "禁用用户账号", true),
     /** 启用用户 */
-    USER_ENABLE("USER", "ENABLE", "启用用户账号", true),
+    USER_ENABLE("USER", "ENABLE", "USER", "启用用户账号", true),
     /** 删除用户 */
-    USER_DELETE("USER", "DELETE", "删除用户", true),
+    USER_DELETE("USER", "DELETE", "USER", "删除用户", true),
 
     // ==================== 平台配置模块 ====================
     /** 创建平台配置 */
-    PLATFORM_CREATE("PLATFORM", "CREATE", "创建平台配置", true),
+    PLATFORM_CREATE("PLATFORM", "CREATE", "PLATFORM", "创建平台配置", true),
     /** 更新平台配置 */
-    PLATFORM_UPDATE("PLATFORM", "UPDATE", "更新平台配置", true),
+    PLATFORM_UPDATE("PLATFORM", "UPDATE", "PLATFORM", "更新平台配置", true),
     /** 删除平台配置 */
-    PLATFORM_DELETE("PLATFORM", "DELETE", "删除平台配置", true),
+    PLATFORM_DELETE("PLATFORM", "DELETE", "PLATFORM", "删除平台配置", true),
     /** 启用平台 */
-    PLATFORM_ENABLE("PLATFORM", "ENABLE", "启用平台", true),
+    PLATFORM_ENABLE("PLATFORM", "ENABLE", "PLATFORM", "启用平台", true),
     /** 禁用平台 */
-    PLATFORM_DISABLE("PLATFORM", "DISABLE", "禁用平台", true),
+    PLATFORM_DISABLE("PLATFORM", "DISABLE", "PLATFORM", "禁用平台", true),
 
     // ==================== B2B平台账号绑定模块 ====================
     /** 绑定B2B平台账号 */
-    ACCOUNT_BIND("ACCOUNT", "BIND", "绑定B2B平台账号", false),
+    ACCOUNT_BIND("ACCOUNT", "BIND", "ACCOUNT", "绑定B2B平台账号", false),
     /** 解绑B2B平台账号 */
-    ACCOUNT_UNBIND("ACCOUNT", "UNBIND", "解绑B2B平台账号", false),
+    ACCOUNT_UNBIND("ACCOUNT", "UNBIND", "ACCOUNT", "解绑B2B平台账号", false),
     /** 验证B2B平台账号 */
-    ACCOUNT_VERIFY("ACCOUNT", "VERIFY", "验证B2B平台账号", false),
+    ACCOUNT_VERIFY("ACCOUNT", "VERIFY", "ACCOUNT", "验证B2B平台账号", false),
 
     // ==================== 入驻信息模块 ====================
     /** 提交入驻信息 */
-    LICENSE_UPLOAD("LICENSE", "UPLOAD", "提交入驻信息", false),
+    LICENSE_UPLOAD("LICENSE", "UPLOAD", "LICENSE", "提交入驻信息", false),
     /** 审核入驻信息(通过) */
-    LICENSE_APPROVE("LICENSE", "APPROVE", "审核入驻信息(通过)", true),
+    LICENSE_APPROVE("LICENSE", "APPROVE", "LICENSE", "审核入驻信息(通过)", true),
     /** 审核入驻信息(驳回) */
-    LICENSE_REJECT("LICENSE", "REJECT", "审核入驻信息(驳回)", true),
+    LICENSE_REJECT("LICENSE", "REJECT", "LICENSE", "审核入驻信息(驳回)", true),
     /** 删除入驻信息 */
-    LICENSE_DELETE("LICENSE", "DELETE", "删除入驻信息", true),
+    LICENSE_DELETE("LICENSE", "DELETE", "LICENSE", "删除入驻信息", true),
 
     // ==================== 邀请系统模块 ====================
     /** 生成邀请码 */
-    INVITE_CODE_GENERATE("INVITE", "CODE_GENERATE", "生成邀请码", false),
+    INVITE_CODE_GENERATE("INVITE", "CODE_GENERATE", "INVITE", "生成邀请码", false),
     /** 发放邀请奖励 */
-    INVITE_REWARD_GRANT("INVITE", "REWARD_GRANT", "发放邀请奖励", true),
+    INVITE_REWARD_GRANT("INVITE", "REWARD_GRANT", "INVITE", "发放邀请奖励", true),
     /** 配置邀请规则 */
-    INVITE_CONFIG_UPDATE("INVITE", "CONFIG_UPDATE", "配置邀请规则", true),
+    INVITE_CONFIG_UPDATE("INVITE", "CONFIG_UPDATE", "INVITE", "配置邀请规则", true),
 
     // ==================== 优惠券模块 ====================
     /** 创建优惠券模板 */
-    COUPON_TEMPLATE_CREATE("COUPON", "TEMPLATE_CREATE", "创建优惠券模板", true),
+    COUPON_TEMPLATE_CREATE("COUPON", "TEMPLATE_CREATE", "COUPON", "创建优惠券模板", true),
     /** 发放优惠券 */
-    COUPON_ISSUE("COUPON", "ISSUE", "发放优惠券", true),
+    COUPON_ISSUE("COUPON", "ISSUE", "COUPON", "发放优惠券", true),
     /** 作废优惠券 */
-    COUPON_INVALIDATE("COUPON", "INVALIDATE", "作废优惠券", true),
+    COUPON_INVALIDATE("COUPON", "INVALIDATE", "COUPON", "作废优惠券", true),
 
     // ==================== 爬虫配额模块 ====================
     /** 手动发放爬虫配额 */
-    CRAWLER_QUOTA_GRANT("CRAWLER", "QUOTA_GRANT", "手动发放爬虫配额", true),
+    CRAWLER_QUOTA_GRANT("CRAWLER", "QUOTA_GRANT", "CRAWLER_QUOTA", "手动发放爬虫配额", true),
     /** 扣除爬虫配额 */
-    CRAWLER_QUOTA_DEDUCT("CRAWLER", "QUOTA_DEDUCT", "扣除爬虫配额", false),
+    CRAWLER_QUOTA_DEDUCT("CRAWLER", "QUOTA_DEDUCT", "CRAWLER_QUOTA", "扣除爬虫配额", false),
     /** 查询爬虫配额 */
-    CRAWLER_QUOTA_QUERY("CRAWLER", "QUOTA_QUERY", "查询爬虫配额", false),
+    CRAWLER_QUOTA_QUERY("CRAWLER", "QUOTA_QUERY", "CRAWLER_QUOTA", "查询爬虫配额", false),
 
     // ==================== 支付模块 ====================
     /** 创建支付方案 */
-    PAYMENT_PLAN_CREATE("PAYMENT", "PLAN_CREATE", "创建支付方案", true),
+    PAYMENT_PLAN_CREATE("PAYMENT", "PLAN_CREATE", "PAYMENT", "创建支付方案", true),
     /** 更新支付方案 */
-    PAYMENT_PLAN_UPDATE("PAYMENT", "PLAN_UPDATE", "更新支付方案", true),
+    PAYMENT_PLAN_UPDATE("PAYMENT", "PLAN_UPDATE", "PAYMENT", "更新支付方案", true),
     /** 支付方案上架/下架 */
-    PAYMENT_PLAN_TOGGLE("PAYMENT", "PLAN_TOGGLE", "支付方案上下架", true),
+    PAYMENT_PLAN_TOGGLE("PAYMENT", "PLAN_TOGGLE", "PAYMENT", "支付方案上下架", true),
 
     // ==================== 系统管理模块 ====================
     /** 修改系统配置 */
-    SYSTEM_CONFIG_UPDATE("SYSTEM", "CONFIG_UPDATE", "修改系统配置", true),
+    SYSTEM_CONFIG_UPDATE("SYSTEM", "CONFIG_UPDATE", "SYSTEM", "修改系统配置", true),
     /** 清除缓存 */
-    SYSTEM_CACHE_CLEAR("SYSTEM", "CACHE_CLEAR", "清除系统缓存", true),
+    SYSTEM_CACHE_CLEAR("SYSTEM", "CACHE_CLEAR", "SYSTEM", "清除系统缓存", true),
     /** 导出报表 */
-    SYSTEM_EXPORT("SYSTEM", "EXPORT", "导出报表", false),
+    SYSTEM_EXPORT("SYSTEM", "EXPORT", "SYSTEM", "导出报表", false),
     /** 备份数据 */
-    SYSTEM_BACKUP("SYSTEM", "BACKUP", "备份数据", true);
+    SYSTEM_BACKUP("SYSTEM", "BACKUP", "SYSTEM", "备份数据", true);
 
     /** 所属模块 */
     private final String module;
@@ -104,15 +113,19 @@ public enum OperationType {
     /** 操作类型代码 */
     private final String code;
 
+    /** 目标对象类型 */
+    private final String targetType;
+
     /** 操作描述 */
     private final String description;
 
     /** 是否为敏感操作 */
     private final boolean sensitive;
 
-    OperationType(String module, String code, String description, boolean sensitive) {
+    OperationType(String module, String code, String targetType, String description, boolean sensitive) {
         this.module = module;
         this.code = code;
+        this.targetType = targetType;
         this.description = description;
         this.sensitive = sensitive;
     }
@@ -128,4 +141,4 @@ public enum OperationType {
         }
         return null;
     }
-}
+}

+ 51 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/enums/OperatorRole.java

@@ -0,0 +1,51 @@
+package com.xuekairui.user.enums;
+
+import lombok.Getter;
+
+/**
+ * 审计日志操作人角色枚举
+ *
+ * 统一管理审计日志 operatorRole 字段的取值,避免在代码中硬编码字符串。
+ *
+ * 说明:与 {@link AdminRole} 的区别——
+ * <ul>
+ *   <li>{@code AdminRole} 仅覆盖 t_admin 表的管理员角色(ADMIN / SUPER_ADMIN),用于实体映射</li>
+ *   <li>{@code OperatorRole} 额外包含普通用户角色(USER),用于审计日志的操作人角色归类</li>
+ * </ul>
+ */
+@Getter
+public enum OperatorRole {
+
+    /** 普通用户 */
+    USER("USER", "普通用户"),
+    /** 运营人员 */
+    ADMIN("ADMIN", "运营人员"),
+    /** 超级管理员 */
+    SUPER_ADMIN("SUPER_ADMIN", "超级管理员");
+
+    /** 角色代码(存入数据库的值) */
+    private final String code;
+
+    /** 角色描述 */
+    private final String description;
+
+    OperatorRole(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    /**
+     * 根据代码查找枚举
+     */
+    public static OperatorRole fromCode(String code) {
+        if (code == null) {
+            return null;
+        }
+        for (OperatorRole role : values()) {
+            if (role.getCode().equals(code)) {
+                return role;
+            }
+        }
+        return null;
+    }
+}

+ 18 - 9
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -25,6 +25,7 @@ import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.event.UserRegisteredEvent;
 import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.util.JwtUtil;
@@ -124,10 +125,10 @@ public class AuthService {
 
         // 记录审计日志
         if (isNewUser) {
-            auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+            auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
                     OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
         }
-        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+        auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
                 OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId());
         return response;
     }
@@ -280,6 +281,8 @@ public class AuthService {
                     .phone(request.getPhone())
                     .nickname(nickname)
                     .userName(request.getUsername())
+                    .wechatMiniOpenId(request.getOpenId())
+                    .wechatUnionId(request.getUnionId())
                     .pharmacyName(request.getPharmacyName())
                     .province(request.getProvince())
                     .city(request.getCity())
@@ -333,6 +336,12 @@ public class AuthService {
             if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) {
                 user.setBusinessLicenseNo(request.getBusinessLicenseNo());
             }
+            if (request.getOpenId() != null && !request.getOpenId().isBlank()) {
+                user.setWechatMiniOpenId(request.getOpenId());
+            }
+            if (request.getUnionId() != null && !request.getUnionId().isBlank()) {
+                user.setWechatUnionId(request.getUnionId());
+            }
         }
 
         user.setLoginSource("MINIAPP");
@@ -346,7 +355,7 @@ public class AuthService {
         LoginResponse response = buildMiniAppLoginResponse(user);
         response.setNewUser(isNewUser);
 
-        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+        auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
                 isNewUser ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
                 "USER:" + user.getId(), user.getId());
         return response;
@@ -499,7 +508,7 @@ public class AuthService {
 
         // 5. 签发正式 Token
         LoginResponse response = buildMiniAppLoginResponse(user);
-        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+        auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
                 OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
         return response;
     }
@@ -868,6 +877,7 @@ public class AuthService {
         // 营业执照审核状态
         String licenseStatus = LicenseStatus.NOT_SUBMITTED.name();
         try {
+            assert user != null;
             com.xuekairui.user.entity.BusinessLicense license =
                     businessLicenseService.getByUserId(user.getId());
             if (license != null && license.getReviewStatus() != null) {
@@ -877,7 +887,6 @@ public class AuthService {
             log.warn("查询营业执照状态失败: userId={}, error={}", user.getId(), e.getMessage());
         }
         // 邀请人信息
-        assert user != null;
         Long inviterId = user.getInviterId();
         String inviterNickname = null;
         if (inviterId != null) {
@@ -964,7 +973,7 @@ public class AuthService {
 
         // 6. 生成Token
         LoginResponse response = buildLoginResponse(user);
-        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+        auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
                 OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId());
         return response;
     }
@@ -992,7 +1001,7 @@ public class AuthService {
         user.setPassword(passwordUtil.encode(request.getNewPassword()));
         userMapper.updateById(user);
         log.info("用户{}设置/修改密码成功", userId);
-        auditLogService.logSuccess(userId, user.getNickname(), "USER",
+        auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
                 OperationType.USER_UPDATE, "PHONE:" + userId, userId);
     }
 
@@ -1021,7 +1030,7 @@ public class AuthService {
         user.setPhone(request.getNewPhone());
         userMapper.updateById(user);
         log.info("用户{}修改手机号成功,旧手机号:{},新手机号:{}", userId, oldPhone, request.getNewPhone());
-        auditLogService.logSuccess(userId, user.getNickname(), "USER",
+        auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
                 OperationType.USER_UPDATE, "PHONE:" + userId, userId);
     }
 
@@ -1108,7 +1117,7 @@ public class AuthService {
         userMapper.updateById(user);
 
         LoginResponse response = buildMiniAppLoginResponse(user);
-        auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
+        auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
                 isNewLocal ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
                 "USER:" + user.getId(), user.getId());
         return response;

+ 24 - 11
zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java

@@ -10,6 +10,7 @@ import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
 import com.xuekairui.user.mapper.UserMapper;
 import lombok.RequiredArgsConstructor;
@@ -93,8 +94,12 @@ public class BusinessLicenseService {
 
     /**
      * 插入新入驻信息(通用方法,供同步等场景使用)
+     * <p>必须包含 userId,否则拒绝写入,避免产生无主入驻记录。
      */
     public void insert(BusinessLicense license) {
+        if (license.getUserId() == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "入驻信息缺少提交用户ID,无法写入");
+        }
         businessLicenseMapper.insert(license);
     }
 
@@ -187,6 +192,13 @@ public class BusinessLicenseService {
     public void approve(Long licenseId, Long reviewerId) {
         BusinessLicense license = validateReviewable(licenseId);
 
+        // 提交用户必须存在,否则拒绝审核
+        Long userId = license.getUserId();
+        User submitter = userMapper.selectById(userId);
+        if (submitter == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "提交用户(ID=" + userId + ")不存在,无法审核");
+        }
+
         license.setReviewStatus(LicenseStatus.APPROVED.name());
         license.setReviewerId(reviewerId);
         license.setReviewTime(LocalDateTime.now());
@@ -194,8 +206,7 @@ public class BusinessLicenseService {
         businessLicenseMapper.updateById(license);
 
         // 首次审核通过,赠送 30 天高级会员
-        Long userId = license.getUserId();
-        if (userId != null && !hasOtherApprovedLicense(userId, licenseId)) {
+        if (!hasOtherApprovedLicense(userId, licenseId)) {
             membershipService.grantOrExtendMembership(
                     userId,
                     MembershipLevel.PRO,
@@ -218,14 +229,12 @@ public class BusinessLicenseService {
         }
 
         // 审计日志:审核通过,记录客户信息
-        User submitter = userMapper.selectById(userId);
-        String userName = submitter != null && submitter.getNickname() != null
+        String userName = submitter.getNickname() != null
                 ? submitter.getNickname() : String.valueOf(userId);
         String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店";
         auditLogService.logSuccess(
                 reviewerId != null ? reviewerId : 0L,
-                "管理员",
-                "ADMIN",
+                OperatorRole.ADMIN,
                 OperationType.LICENSE_APPROVE,
                 "LICENSE:" + license.getId(),
                 license.getId(),
@@ -254,16 +263,20 @@ public class BusinessLicenseService {
         license.setRejectReason(rejectReason);
         businessLicenseMapper.updateById(license);
 
-        // 审计日志:审核驳回,记录客户信息
+        // 提交用户必须存在
         Long userId = license.getUserId();
         User submitter = userMapper.selectById(userId);
-        String userName = submitter != null && submitter.getNickname() != null
+        if (submitter == null) {
+            throw new BusinessException(ErrorCode.NOT_FOUND, "提交用户(ID=" + userId + ")不存在,无法审核");
+        }
+
+        // 审计日志:审核驳回,记录客户信息
+        String userName = submitter.getNickname() != null
                 ? submitter.getNickname() : String.valueOf(userId);
         String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店";
         auditLogService.logSuccess(
                 reviewerId != null ? reviewerId : 0L,
-                "管理员",
-                "ADMIN",
+                OperatorRole.ADMIN,
                 OperationType.LICENSE_REJECT,
                 "LICENSE:" + license.getId(),
                 license.getId(),
@@ -314,4 +327,4 @@ public class BusinessLicenseService {
         private String medicalDeviceClass2Url;
         private String medicalDeviceClass3Url;
     }
-}
+}

+ 5 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java

@@ -11,6 +11,7 @@ import com.xuekairui.user.entity.CrawlerUsageLog;
 import com.xuekairui.user.entity.UserCoupon;
 import com.xuekairui.user.entity.UserLevel;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.CouponMapper;
 import com.xuekairui.user.mapper.CrawlerUsageLogMapper;
 import com.xuekairui.user.mapper.UserCouponMapper;
@@ -172,7 +173,7 @@ public class CrawlerService {
                 consumeFromQuota(userId, quotaCount);
             }
             recordUsage(userId, count, "MIXED", "优惠券+" + shortage + ",配额+" + quotaCount, platform);
-            auditLogService.logSuccess(userId, getUserNickname(userId), "USER",
+            auditLogService.logSuccess(userId, getUserNickname(userId), OperatorRole.USER.getCode(),
                     OperationType.CRAWLER_QUOTA_DEDUCT, "CRAWLER_CONSUME:" + count, userId);
             return true;
         }
@@ -180,7 +181,7 @@ public class CrawlerService {
         // 日配额充足
         if (useCouponFirst && consumeWithCoupon(userId, count)) {
             recordUsage(userId, count, "COUPON", "使用优惠券", platform);
-            auditLogService.logSuccess(userId, getUserNickname(userId), "USER",
+            auditLogService.logSuccess(userId, getUserNickname(userId), OperatorRole.USER.getCode(),
                     OperationType.CRAWLER_QUOTA_DEDUCT, "CRAWLER_CONSUME:" + count, userId);
             return true;
         }
@@ -188,7 +189,7 @@ public class CrawlerService {
         // 从配额中扣减(额外配额优先,快过期优先)
         consumeFromQuota(userId, count);
         recordUsage(userId, count, "QUOTA", null, platform);
-        auditLogService.logSuccess(userId, getUserNickname(userId), "USER",
+        auditLogService.logSuccess(userId, getUserNickname(userId), OperatorRole.USER.getCode(),
                 OperationType.CRAWLER_QUOTA_DEDUCT, "CRAWLER_CONSUME:" + count, userId);
         return true;
     }
@@ -312,4 +313,4 @@ public class CrawlerService {
                         .ge(CrawlerUsageLog::getUsageDate, startDate)
                         .orderByDesc(CrawlerUsageLog::getUsageDate));
     }
-}
+}

+ 3 - 17
zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java

@@ -16,6 +16,7 @@ import com.xuekairui.user.entity.UserLevel;
 import com.xuekairui.user.entity.UserMembership;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.MembershipBenefitMapper;
 import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.mapper.UserMembershipMapper;
@@ -256,21 +257,6 @@ public class MembershipService {
         }
     }
 
-    /**
-     * 邀请人奖励:赠送1个月(30天)高级会员(PRO)
-     * <p>
-     * 无论邀请人当前是什么等级,统一赠送高级会员时长。
-     * 同等级续期叠加,不同等级则停用旧会员并启用新的PRO会员。
-     */
-    @Transactional
-    public void grantInviteRewardMembership(Long inviterId, Long inviteeId) {
-        grantOrExtendMembership(inviterId, MembershipLevel.PRO, 30,
-                "INVITE", "邀请奖励:邀请用户" + inviteeId + "注册");
-        auditLogService.logSuccess(inviterId, null, "USER",
-                OperationType.INVITE_REWARD_GRANT, "INVITE_REWARD:" + inviterId, inviterId);
-        log.info("邀请奖励:用户{}获得30天高级会员(PRO)", inviterId);
-    }
-
     /**
      * 支付购买激活会员(互斥模式:同等级续期,不同等级替换)
      * @param userId       用户ID
@@ -290,7 +276,7 @@ public class MembershipService {
         grantOrExtendMembership(userId, level, durationDays, "RECHARGE", "支付购买: " + orderNo);
 
         // 审计日志
-        auditLogService.logSuccess(userId, user.getNickname(), "USER",
+        auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
                 OperationType.USER_UPDATE,
                 "PAY:" + orderNo + "->" + level.getCode(), userId);
 
@@ -670,4 +656,4 @@ public class MembershipService {
             default -> Collections.emptyList();
         };
     }
-}
+}

+ 59 - 25
zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.user.entity.OperationAuditLog;
+import com.xuekairui.user.enums.AuditResult;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.OperationAuditLogMapper;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
@@ -61,7 +63,7 @@ public class OperationAuditLogService {
     public void logAdmin(Long operatorId, OperationType opType, String targetId, Long targetNum) {
         var admin = operatorId != null ? adminMapper.selectById(operatorId) : null;
         String name = admin != null ? admin.getUsername() : "管理员";
-        String role = admin != null ? admin.getRole() : "ADMIN";
+        String role = admin != null ? admin.getRole() : OperatorRole.ADMIN.getCode();
         logSuccess(operatorId != null ? operatorId : 0L, name, role, opType, targetId, targetNum);
     }
 
@@ -74,6 +76,39 @@ public class OperationAuditLogService {
                   targetIdentifier, targetId, null, null, null);
     }
 
+
+
+    /**
+     * 便捷方法:记录成功操作(带数据快照)
+     */
+    public void logSuccess(Long operatorId, OperatorRole operatorRole,
+                           OperationType operationType, String targetIdentifier, Long targetId,
+                           Object beforeData, Object afterData, String remark) {
+        AuditLogBuilder builder = AuditLogBuilder.builder()
+                .operatorId(operatorId)
+                .operatorName(operatorRole.getDescription())
+                .operatorRole(operatorRole.getCode())
+                .operationType(operationType.getCode())
+                .module(operationType.getModule())
+                .description(operationType.getDescription())
+                .targetIdentifier(targetIdentifier)
+                .targetId(targetId)
+                .targetType(operationType.getTargetType())
+                .result(AuditResult.SUCCESS.getCode())
+                .isSensitive(operationType.isSensitive())
+                .remark(remark);
+
+        if (beforeData != null) {
+            builder.beforeData(toJson(beforeData));
+        }
+        if (afterData != null) {
+            builder.afterData(toJson(afterData));
+        }
+
+        OperationAuditLog log = builder.build();
+        fillRequestInfo(log);
+        logOperation(log);
+    }
     /**
      * 便捷方法:记录成功操作(带数据快照)
      */
@@ -89,8 +124,8 @@ public class OperationAuditLogService {
                 .description(operationType.getDescription())
                 .targetIdentifier(targetIdentifier)
                 .targetId(targetId)
-                .targetType(getTargetType(operationType))
-                .result("SUCCESS")
+                .targetType(operationType.getTargetType())
+                .result(AuditResult.SUCCESS.getCode())
                 .isSensitive(operationType.isSensitive())
                 .remark(remark);
 
@@ -106,6 +141,25 @@ public class OperationAuditLogService {
         logOperation(log);
     }
 
+    /**
+     * 便捷方法:记录成功操作(OperatorRole 枚举版本,类型安全)
+     */
+    public void logSuccess(Long operatorId, String operatorName, OperatorRole operatorRole,
+                          OperationType operationType, String targetIdentifier, Long targetId) {
+        logSuccess(operatorId, operatorName, operatorRole.getCode(), operationType,
+                  targetIdentifier, targetId, null, null, null);
+    }
+
+    /**
+     * 便捷方法:记录成功操作-带数据快照(OperatorRole 枚举版本,类型安全)
+     */
+    public void logSuccess(Long operatorId, String operatorName, OperatorRole operatorRole,
+                          OperationType operationType, String targetIdentifier, Long targetId,
+                          Object beforeData, Object afterData, String remark) {
+        logSuccess(operatorId, operatorName, operatorRole.getCode(), operationType,
+                  targetIdentifier, targetId, beforeData, afterData, remark);
+    }
+
 
     /**
      * 查询用户的操作日志
@@ -214,26 +268,6 @@ public class OperationAuditLogService {
         }
     }
 
-    /**
-     * 根据操作类型推断目标对象类型(使用 module 字段判断)
-     */
-    private String getTargetType(OperationType operationType) {
-        String module = operationType.getModule();
-        return switch (module) {
-            case "USER" -> "USER";
-            case "LEVEL" -> "LEVEL_CONFIG";
-            case "PLATFORM" -> "PLATFORM";
-            case "ACCOUNT" -> "ACCOUNT";
-            case "LICENSE" -> "LICENSE";
-            case "INVITE" -> "INVITE";
-            case "COUPON" -> "COUPON";
-            case "TRIAL" -> "TRIAL_CONFIG";
-            case "CRAWLER" -> "CRAWLER_QUOTA";
-            case "PAYMENT" -> "PAYMENT";
-            default -> "SYSTEM";
-        };
-    }
-
     /**
      * 审计日志构建器(使用静态内部类实现Builder模式)
      */
@@ -351,7 +385,7 @@ public class OperationAuditLogService {
             log.setTargetIdentifier(this.targetIdentifier);
             log.setBeforeData(this.beforeData);
             log.setAfterData(this.afterData);
-            log.setResult(this.result != null ? this.result : "SUCCESS");
+            log.setResult(this.result != null ? this.result : AuditResult.SUCCESS.getCode());
             log.setErrorMessage(this.errorMessage);
             log.setIpAddress(this.ipAddress);
             log.setUserAgent(this.userAgent);
@@ -365,4 +399,4 @@ public class OperationAuditLogService {
             return log;
         }
     }
-}
+}