Przeglądaj źródła

优化代码,添加接口

liuchengsen 1 miesiąc temu
rodzic
commit
da2e927db7

+ 236 - 0
docs/前端对接变更说明-2026-06-30.md

@@ -0,0 +1,236 @@
+# 前端对接变更说明
+
+> 变更日期:2026-06-30  
+> 关联需求:支付渠道切换与重复支付追溯、邀请链接直接下载、邀请文案调整
+
+---
+
+## 一、变更范围概览
+
+本次后端改动涉及三大模块:
+
+1. **支付模块**:支持订单详情页切换支付渠道,15 分钟内原渠道二维码仍有效;多渠道重复支付时以首笔成功渠道为准,其余渠道记录备查。
+2. **邀请模块**:邀请链接 `/api/invite/{code}` 改为直接下载(302 重定向到安装包),删除独立下载接口 `/api/invite/{code}/download`。
+3. **数据库**:新增 `wechat_nickname`、`paid_channel` 字段及 `t_payment_order_extra_payment` 重复支付记录表。
+
+---
+
+## 二、后端接口变更清单
+
+### 2.1 邀请模块
+
+| 接口 | 变更前 | 变更后 |
+|---|---|---|
+| `GET /api/invite/{code}` | 302 重定向到安装包 URL | **服务端流式返回安装包二进制**,浏览器自动下载 |
+| `GET /api/invite/{code}/download` | 下载接口 | **已删除** |
+
+**响应行为:**
+- 服务端根据 `User-Agent` 自动判断系统类型(Windows x64 / macOS ARM64)。
+- 返回 `Content-Type: application/octet-stream` 和 `Content-Disposition: attachment; filename="..."`。
+- 从本地 `invite.download-base-path` 配置目录读取安装包文件写入响应流,不再暴露真实文件 URL。
+
+### 2.2 支付模块
+
+#### 新增接口:切换支付渠道
+
+```http
+POST /api/payment/order/{orderNo}/switch-channel
+Content-Type: application/json
+
+{
+  "channel": "ALIPAY"   // 可选值:WECHAT / ALIPAY
+}
+```
+
+**说明:**
+- 用于订单详情页切换支付方式。
+- 不关闭原渠道订单,原渠道二维码 15 分钟内仍有效。
+- 返回新渠道的支付二维码 `qrCodeBaseUrl` / `qrCodeBase64` / `payUrl`。
+
+#### 已有接口:查询订单
+
+```http
+GET /api/payment/order/{orderNo}
+```
+
+**变更说明:**
+- 待支付订单会返回当前渠道二维码字段,前端可直接展示扫码支付。
+- 关键返回字段:
+  - `qrCodeBase64`:Base64 图片二维码
+  - `payUrl`:支付链接(支付宝等可用)
+  - `channel`:当前订单渠道
+  - `expireHint`:过期提示文案
+
+#### 回调逻辑变更
+
+- 第一个支付成功渠道写入 `paidChannel`。
+- 若同一订单在另一个渠道也支付成功,后端记录到 `t_payment_order_extra_payment`,但不重复激活会员。
+- 前端正常展示"支付成功"即可,无需额外处理。
+
+---
+
+## 三、前端必须修改的内容
+
+### 3.1 邀请链接直接下载(高优先级)
+
+**删除旧调用:**
+
+```http
+GET /api/invite/{code}/download
+```
+
+**改为:**
+
+```http
+GET /api/invite/{code}
+```
+
+**实现建议:**
+- H5/小程序/Web:使用 `<a href="/api/invite/{code}" download>` 或 `window.open('/api/invite/{code}')`。
+- 桌面端:直接访问该 URL,浏览器自动处理下载。
+- 落地页中的"下载 APP / Windows 客户端"按钮,URL 使用后端返回的 `appDownloadUrl`(当前等于邀请链接本身)。
+- 注意:`inviteLink` 和 `downloadUrl` 现在统一为 `https://priceapi.kailin.com.cn/api/invite/{code}`(含 `/api` 路径),之前生成的 `/invite/{code}` 已修复。
+
+### 3.2 邀请分享文案(高优先级)
+
+**文案来源:**
+
+使用 `/api/invite/code` 接口返回的 `copyText` 字段,不要在前端硬编码。
+
+**文案格式示例:**
+
+```text
+我是Evan,在这里发现了一个药店采购神器——智价云!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。填我的邀请码 2XKADABS 完成注册,你也会获得会员权益!下载链接https://priceapi.kailin.com.cn/api/invite/C6GZJEU4
+```
+
+**兜底展示名规则:**
+
+后端已处理,优先级为:微信昵称 > 用户昵称 > 手机尾号(如"手机尾号6688")> "一位药店用户"。
+
+### 3.3 支付订单详情页新增"切换支付渠道"(高优先级)
+
+**新增交互:**
+
+- 在订单详情页增加"切换为微信支付" / "切换为支付宝支付"按钮。
+- 点击后调用:
+
+```http
+POST /api/payment/order/{orderNo}/switch-channel
+```
+
+**请求体:**
+
+```json
+{
+  "channel": "WECHAT"
+}
+```
+
+**前端处理:**
+
+1. 调用成功后,重新展示新渠道的二维码。
+2. 提示用户:"已切换支付方式,请使用新二维码完成支付。原二维码 15 分钟内仍有效。"
+3. 不要在前端主动关闭或禁用原二维码的展示(如果已展示)。
+
+### 3.4 查询订单二维码展示(中优先级)
+
+待支付订单详情需要展示二维码:
+
+```json
+{
+  "qrCodeBase64": "data:image/png;base64,iVBORw0KGgo...",
+  "payUrl": "https://qr.alipay.com/...",
+  "channel": "ALIPAY",
+  "expireHint": "请在15分钟内完成支付"
+}
+```
+
+- 微信支付:展示 `qrCodeBase64` 图片。
+- 支付宝支付:可展示 `qrCodeBase64`,或提供"打开支付宝"按钮使用 `payUrl`。
+
+### 3.5 落地页字段调整(中优先级)
+
+- `appDownloadUrl` 现在就是 `/api/invite/{code}`。
+- 落地页点击下载按钮直接访问该 URL 即可,无需拼接 `/download`。
+- `downloadUrl` 字段与 `inviteLink` 一致。
+
+---
+
+## 四、前端无需改动但需知悉
+
+| 项 | 说明 |
+|---|---|
+| 会员激活 | 仍以第一个支付成功渠道为准 |
+| 重复支付 | 第二个渠道支付只记录,不重复激活会员,前端正常展示成功即可 |
+| 订单过期 | 15 分钟未支付自动过期,过期后切换渠道会失败 |
+| 支付宝支付 | 当前仅保留扫码模式(`alipay.trade.precreate`) |
+| 微信支付 | Native API v3,返回 Base64 二维码 |
+
+---
+
+## 五、本地测试指引
+
+1. 启动网关服务。
+2. 访问测试页面:`http://localhost:{port}/invite-test.html`
+3. 在"支付模块"中找到:
+   - **创建支付订单**
+   - **查询订单**
+   - **切换支付渠道**
+4. 在"邀请模块"中找到:
+   - **邀请链接入口(直接下载)**
+   - **获取邀请码**
+
+---
+
+## 六、数据库上线注意
+
+已有环境需要执行 `zhijiayun-gateway/src/main/resources/db/migration-v5.sql`,主要变更:
+
+```sql
+-- t_user 新增微信昵称字段
+ALTER TABLE `t_user` ADD COLUMN `wechat_nickname` VARCHAR(128) DEFAULT NULL COMMENT '微信昵称';
+
+-- t_payment_order 新增实际支付渠道字段
+ALTER TABLE `t_payment_order` ADD COLUMN `paid_channel` VARCHAR(20) DEFAULT NULL COMMENT '实际支付渠道';
+
+-- 新增重复支付记录表
+CREATE TABLE `t_payment_order_extra_payment` (...);
+```
+
+---
+
+## 七、安装包部署路径
+
+流式下载会从 `invite.download-base-path` 配置目录直接读取安装包文件。
+
+生产环境默认:
+
+```
+/app/zhijiayun/downloads/desktop-updates/
+```
+
+需要放入:
+
+- `智价云-x64.exe`
+- `智价云-arm64.dmg`
+
+> 中文文件名已通过 `Content-Disposition: filename*=UTF-8''...` 编码,兼容主流浏览器。
+
+---
+
+## 八、变更文件列表
+
+| 文件 | 变更类型 | 说明 |
+|---|---|---|
+| `zhijiayun-invite/.../InviteController.java` | 修改 | `/api/invite/{code}` 改为服务端流式下载,删除 `/download` 接口 |
+| `zhijiayun-invite/.../InviteService.java` | 修改 | 邀请文案、下载链接(含 `/api` 路径修正)、展示名优先级调整、流式下载文件读取 |
+| `zhijiayun-payment/.../PaymentOrder.java` | 修改 | 新增 `paidChannel` 字段 |
+| `zhijiayun-payment/.../PaymentOrderExtraPayment.java` | 新增 | 重复支付记录实体 |
+| `zhijiayun-payment/.../PaymentOrderExtraPaymentMapper.java` | 新增 | 重复支付记录 Mapper |
+| `zhijiayun-payment/.../PaymentOrderService.java` | 修改 | 渠道切换、重复支付处理 |
+| `zhijiayun-payment/.../PaymentCallbackController.java` | 修改 | 回调传递 `paidChannel` |
+| `zhijiayun-gateway/.../db/schema.sql` | 修改 | 新增字段和表 |
+| `zhijiayun-gateway/.../db/migration-v5.sql` | 新增 | 已有环境幂等升级脚本 |
+| `zhijiayun-test/.../invite-test.html` | 修改 | 测试页面同步新增/调整接口说明 |
+| `zhijiayun-gateway/.../application-dev.yml` | 修改 | 新增 invite 下载文件路径配置 |
+| `zhijiayun-gateway/.../application-prod.yml` | 修改 | 新增 invite 下载文件路径配置 |

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

@@ -48,6 +48,10 @@ invite:
   base-url: http://localhost:8001
   # 补填邀请码有效窗口(天),注册后30天内可补填
   bind-window-days: 30
+  # 客户端安装包本地存储目录(流式下载)
+  download-base-path: ${INVITE_DOWNLOAD_BASE_PATH:/desktop-updates}
+  windows-filename: ${INVITE_WINDOWS_FILENAME:智价云-x64.exe}
+  mac-filename: ${INVITE_MAC_FILENAME:智价云-arm64.dmg}
 
 # 支付模块(开发环境启用真实支付,手动确认按钮用于测试回调)
 payment:

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

@@ -100,6 +100,10 @@ invite:
   base-url: ${INVITE_BASE_URL:https://priceapi.kailin.com.cn}
   # 补填邀请码有效窗口(天),注册后30天内可补填
   bind-window-days: ${INVITE_BIND_WINDOW_DAYS:30}
+  # 客户端安装包本地存储目录(流式下载)
+  download-base-path: ${INVITE_DOWNLOAD_BASE_PATH:/desktop-updates}
+  windows-filename: ${INVITE_WINDOWS_FILENAME:智价云-x64.exe}
+  mac-filename: ${INVITE_MAC_FILENAME:智价云-arm64.dmg}
 
 # 支付模块(生产环境必须设为 false)
 payment:

+ 6 - 10
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java

@@ -4,12 +4,10 @@ import com.xuekairui.common.Result;
 import com.xuekairui.invite.dto.*;
 import com.xuekairui.invite.service.InviteService;
 import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
-import java.net.URI;
 import java.util.List;
 import java.util.Map;
 
@@ -107,20 +105,18 @@ public class InviteController {
 
     /**
      * 浏览器直接访问邀请链接入口(公开,无需登录)
-     * 自动计入点击数,并根据访问设备直接重定向到对应桌面客户端下载
+     * 自动计入点击数,并根据访问设备流式返回对应桌面客户端安装
      * GET /api/invite/{code}
      * Windows x64 -> /desktop-updates/智价云-x64.exe
      * macOS ARM64 -> /desktop-updates/智价云-arm64.dmg
      */
     @GetMapping("/{code}")
-    public ResponseEntity<Void> openInviteLink(
+    public void openInviteLink(
             @PathVariable String code,
-            HttpServletRequest request) {
+            HttpServletRequest request,
+            HttpServletResponse response) {
         String userAgent = request.getHeader("User-Agent");
-        String downloadUrl = inviteService.resolveDownloadUrl(code, userAgent);
-        return ResponseEntity.status(HttpStatus.FOUND)
-                .location(URI.create(downloadUrl))
-                .build();
+        inviteService.downloadInviteFile(code, userAgent, response);
     }
 
     /**

+ 68 - 44
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -49,9 +49,17 @@ public class InviteService {
     @Value("${invite.base-url:https://app.zhijiayun.com}")
     private String inviteBaseUrl;
 
-    /** 桌面客户端下载基础URL,为空时自动从 inviteBaseUrl 推断域名 */
-    @Value("${invite.desktop-download-base-url:}")
-    private String desktopDownloadBaseUrl;
+    /** 客户端安装包本地存储目录 */
+    @Value("${invite.download-base-path:./downloads}")
+    private String downloadBasePath;
+
+    /** Windows 安装包文件名 */
+    @Value("${invite.windows-filename:zhijiayun-x64.exe}")
+    private String windowsFilename;
+
+    /** macOS 安装包文件名 */
+    @Value("${invite.mac-filename:zhijiayun-arm64.dmg}")
+    private String macFilename;
 
     /** 补填邀请码有效窗口(天),默认30天 */
     @Value("${invite.bind-window-days:30}")
@@ -157,8 +165,8 @@ public class InviteService {
         // 根据渠道生成提示文案
         String instructionText = generateInstructionText(code, channel, config);
 
-        // 落地页下载按钮指向 /api/invite/{code},访问时会再次记录点击并自动重定向到对应安装包
-        String inviteDownloadUrl = inviteBaseUrl + "/invite/" + code;
+        // 落地页下载按钮指向 /api/invite/{code},服务端按设备流式返回对应安装包
+        String inviteDownloadUrl = inviteBaseUrl + "/api/invite/" + code;
 
         // Windows/app 渠道使用邀请链接作为下载入口(自动识别设备并重定向),其他渠道保持配置中的下载地址
         String appDownloadUrl = ("windows".equals(channel) || "app".equals(channel))
@@ -246,56 +254,72 @@ public class InviteService {
     }
 
     /**
-     * 解析桌面客户端下载完整URL(公开,无需登录)
-     * 根据 User-Agent 判断系统架构,返回对应下载包地址
+     * 根据 User-Agent 解析本次应下载的安装包文件
+     * Windows x64 -> {downloadBasePath}/智价云-x64.exe
+     * macOS ARM64 -> {downloadBasePath}/智价云-arm64.dmg
+     * 其他默认返回 Windows 安装包
      */
-    public String resolveDownloadUrl(String code, String userAgent) {
-        // 校验邀请码存在性(可选:不存在也允许下载,这里只做简单校验并记录点击)
-        InviteCode inviteCode = inviteCodeMapper.selectOne(
-                new LambdaQueryWrapper<InviteCode>()
-                        .eq(InviteCode::getCode, code));
-        if (inviteCode != null) {
-            inviteCode.setClickCount(inviteCode.getClickCount() + 1);
-            inviteCodeMapper.updateById(inviteCode);
-            log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
-        }
-        return resolveDesktopDownloadUrl(userAgent);
-    }
-
-    /**
-     * 根据 User-Agent 判断系统并返回桌面客户端下载地址
-     * Windows x64 -> /desktop-updates/智价云-x64.exe
-     * macOS ARM64 -> /desktop-updates/智价云-arm64.dmg
-     * 其他默认返回 Windows x64 包
-     */
-    private String resolveDesktopDownloadUrl(String userAgent) {
-        String baseUrl = getDesktopDownloadBaseUrl();
-        String path = "/desktop-updates/智价云-x64.exe";
+    public java.io.File resolveDownloadFile(String userAgent) {
+        String filename = windowsFilename;
         if (userAgent != null) {
             String ua = userAgent.toLowerCase();
             boolean isMac = ua.contains("macintosh") || ua.contains("mac os");
             boolean isArm = ua.contains("arm64") || ua.contains("aarch64");
             if (isMac && isArm) {
-                path = "/desktop-updates/智价云-arm64.dmg";
+                filename = macFilename;
             }
         }
-        return baseUrl + path;
+        return new java.io.File(downloadBasePath, filename);
     }
 
     /**
-     * 获取桌面客户端下载基础URL
-     * 优先使用配置 invite.desktop-download-base-url,未配置时从 inviteBaseUrl 推断域名根地址
+     * 构造支持中文文件名的 Content-Disposition 响应头
+     * 使用 RFC 5987 的 filename*=UTF-8'' 编码,兼容主流浏览器
      */
-    private String getDesktopDownloadBaseUrl() {
-        if (desktopDownloadBaseUrl != null && !desktopDownloadBaseUrl.isBlank()) {
-            return desktopDownloadBaseUrl.replaceAll("/$", "");
+    private String buildContentDisposition(String filename) {
+        String asciiOnly = filename.replaceAll("[^\\x00-\\x7F]", "_");
+        String encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8)
+                .replaceAll("\\+", "%20");
+        return "attachment; filename=\"" + asciiOnly + "\"; filename*=UTF-8''" + encoded;
+    }
+
+    /**
+     * 流式下载邀请链接对应的安装包(公开,无需登录)
+     * 自动计入点击数,并根据 User-Agent 选择安装包文件写入响应流
+     */
+    public void downloadInviteFile(String code, String userAgent, jakarta.servlet.http.HttpServletResponse response) {
+        // 校验邀请码存在性并记录点击
+        InviteCode inviteCode = inviteCodeMapper.selectOne(
+                new LambdaQueryWrapper<InviteCode>()
+                        .eq(InviteCode::getCode, code));
+        if (inviteCode == null) {
+            throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
         }
-        try {
-            java.net.URL url = new java.net.URL(inviteBaseUrl);
-            return url.getProtocol() + "://" + url.getAuthority();
-        } catch (Exception e) {
-            log.warn("无法从 inviteBaseUrl 推断下载域名: {}", inviteBaseUrl, e);
-            return inviteBaseUrl.replaceAll("/$", "");
+        inviteCode.setClickCount(inviteCode.getClickCount() + 1);
+        inviteCodeMapper.updateById(inviteCode);
+        log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
+
+        java.io.File file = resolveDownloadFile(userAgent);
+        if (!file.exists() || !file.isFile()) {
+            log.error("安装包文件不存在: {}", file.getAbsolutePath());
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "安装包文件不存在,请联系管理员");
+        }
+
+        response.setContentType("application/octet-stream");
+        response.setHeader("Content-Disposition", buildContentDisposition(file.getName()));
+        response.setContentLengthLong(file.length());
+
+        try (java.io.InputStream in = new java.io.FileInputStream(file);
+             java.io.OutputStream out = response.getOutputStream()) {
+            byte[] buffer = new byte[8192];
+            int len;
+            while ((len = in.read(buffer)) != -1) {
+                out.write(buffer, 0, len);
+            }
+            out.flush();
+        } catch (java.io.IOException e) {
+            log.error("流式下载安装包失败: {}", file.getAbsolutePath(), e);
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "下载失败,请重试");
         }
     }
 
@@ -370,7 +394,7 @@ public class InviteService {
 
         return InviteLinkResolveResponse.builder()
                 .inviteCode(code)
-                .inviteLink(inviteBaseUrl + "/invite/" + code)
+                .inviteLink(inviteBaseUrl + "/api/invite/" + code)
                 .inviterNickname(inviterNickname)
                 .inviterAvatar(inviterAvatar)
                 .appName(config.getAppName())
@@ -853,7 +877,7 @@ public class InviteService {
         InviteConfig config = inviteConfigService.getActiveConfig();
         User inviter = userMapper.selectById(code.getUserId());
         String inviterNickname = getDisplayName(inviter);
-        String inviteLink = inviteBaseUrl + "/invite/" + code.getCode();
+        String inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
 
         // 生成分享文案(邀请人 + 产品价值 + 操作指引)
         // 分享标题:让接收方一眼知道是谁邀请的

+ 8 - 5
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -779,9 +779,9 @@ const FIELD_DESC = {
         ['createTime', 'DateTime', '创建时间'],
         ['shareTitle', 'String', '分享标题'],
         ['shareDescription', 'String', '分享描述'],
-        ['copyText', 'String', '一键复制文本'],
+        ['copyText', 'String', '一键复制文本(格式:我是{昵称},在这里发现了一个药店采购神器——{appName}!...下载链接{inviteLink})'],
         ['rewardDescription', 'String', '奖励说明'],
-        ['downloadUrl', 'String', '下载链接(与 inviteLink 一致,访问后根据设备自动重定向到对应安装包)']
+        ['downloadUrl', 'String', '下载链接(与 inviteLink 一致,访问 /api/invite/{code} 服务端按设备流式返回安装包)']
     ],
     'myInviter': [
         ['invited', 'Boolean', '是否被邀请过'],
@@ -824,7 +824,7 @@ const FIELD_DESC = {
         ['appName', 'String', '应用名称'],
         ['landingTitle', 'String', '落地页标题'],
         ['landingDesc', 'String', '落地页描述'],
-        ['appDownloadUrl', 'String', 'Windows下载入口URL(channel=windows/app),访问 /api/invite/{code} 会根据设备自动重定向到对应安装包'],
+        ['appDownloadUrl', 'String', 'Windows下载入口URL(channel=windows/app),访问 /api/invite/{code} 会根据设备流式返回对应安装包'],
         ['miniappPath', 'String', '小程序路径'],
         ['miniappAppId', 'String', '小程序AppID'],
         ['wechatRedirectUrl', 'String', '微信跳转URL'],
@@ -1270,7 +1270,7 @@ function renderInviteModule() {
         [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''},
          {label:'渠道(channel)', name:'channel', placeholder:'windows/app/wechat/miniapp/dingtalk/feishu', default:'windows'}], true, 'invitePage', '浏览器打开邀请链接时展示的落地页数据,默认渠道为windows(Windows桌面客户端)')}
     ${testSection('invite-link-entry', '邀请链接入口(直接下载)', 'GET', 'get', '/api/invite/{code}', '公开', 'auth-public',
-        [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], true, 'invitePage', '浏览器直接访问邀请链接,根据User-Agent自动重定向到对应桌面客户端安装包(Windows x64 -> .exe / macOS ARM64 -> .dmg),并自动计入点击数')}
+        [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], true, 'invitePage', '浏览器直接访问邀请链接,服务端根据 User-Agent 流式返回对应桌面客户端安装包(Windows x64 -> .exe / macOS ARM64 -> .dmg),响应头 Content-Disposition: attachment,并自动计入点击数')}
     ${testSection('invite-click', '点击追踪', 'POST', 'post', '/api/invite/click/{code}', '公开', 'auth-public',
         [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], false, null, '追踪邀请链接被打开事件')}
     ${testSection('invite-resolve', '解析链接', 'GET', 'get', '/api/invite/resolve-link?link=', '公开', 'auth-public',
@@ -1308,7 +1308,10 @@ function renderPaymentModule() {
         [{label:'方案ID(planId)', name:'planId', placeholder:'1', default:'1'},
          {label:'支付渠道(channel)', name:'channel', placeholder:'WECHAT/ALIPAY', default:'WECHAT'}], true, null, '创建支付订单,微信返回二维码(qrCodeBase64)供扫码支付,支付宝通过alipay.trade.precreate返回qr_code,前端自定义弹窗展示二维码并自动轮询支付状态。15分钟内未付款订单自动过期')}
     ${testSection('payment-query-order', '查询订单', 'GET', 'get', '/api/payment/order/{orderNo}', '需认证', 'auth-required',
-        [{label:'订单号(orderNo)', name:'orderNo', placeholder:'OP...', default:''}], true, null, '根据订单号查询支付订单详情,响应中expireHint含过期提示')}
+        [{label:'订单号(orderNo)', name:'orderNo', placeholder:'OP...', default:''}], true, null, '根据订单号查询支付订单详情,待支付订单会返回当前渠道二维码qrCodeBase64和payUrl')}
+    ${testSection('payment-switch-channel', '切换支付渠道', 'POST', 'post', '/api/payment/order/{orderNo}/switch-channel', '需认证', 'auth-required',
+        [{label:'订单号(orderNo)', name:'orderNo', placeholder:'OP...', default:''},
+         {label:'新渠道(channel)', name:'channel', placeholder:'WECHAT/ALIPAY', default:'ALIPAY'}], true, null, '订单详情页切换支付渠道,不关闭原渠道订单,重新生成新渠道二维码。15分钟内原渠道二维码仍有效,以第一个支付成功渠道为准')}
     ${testSection('payment-orders', '我的订单列表', 'GET', 'get', '/api/payment/orders', '需认证', 'auth-required', null, true, null, '查询当前用户的所有支付订单,待支付订单含expireHint过期提示')}
     ${testSection('payment-mock-pay', '手动确认支付', 'POST', 'post', '/api/payment/order/{orderNo}/pay', '需认证', 'auth-required',
         [{label:'订单号(orderNo)', name:'orderNo', placeholder:'OP...', default:''}], false, null, '开发测试用。mock模式=模拟支付自动激活会员;真实支付模式=手动确认支付(不实际扣款,用于测试回调流程)。需在15分钟过期前操作')}